[
  {
    "path": ".agents/skills/codex-worker/SKILL.md",
    "content": "---\nname: codex-worker\ndescription: Spawn and manage multiple Codex CLI agents via tmux to work on tasks in parallel. Use whenever a task can be decomposed into independent subtasks (e.g. batch triage, parallel fixes, multi-file refactors). When codex and tmux are available, prefer this over the built-in Task tool for parallelism.\n---\n\n# codex-worker\n\nOrchestrate multiple Codex CLI (`codex`) agents running in parallel, each in its\nown git worktree and tmux session.\n\n**When to use:** Whenever you identify that a task can be split into independent\nsubtasks — don't wait for the user to ask for parallelism. Examples:\n- User says \"triage all open issues updated in the last 7 days\" → fetch the\n  issue list, then spawn one codex worker per issue.\n- User says \"refactor these 5 modules\" → one worker per module.\n- User says \"fix lint errors across packages\" → one worker per package.\n\n**Replaces Task tool:** When `codex` and `tmux` are available in the\nenvironment, use codex workers instead of the built-in Task (subagent) tool.\nCodex workers are full-featured agents with their own file system access, shell,\nand isolated worktree — far more capable than subagents.\n\n## Preflight check\n\n```bash\ncommand -v codex && codex login status && command -v tmux\n```\n\nAll three must succeed. The project must be a git repository.\n\n## Naming convention\n\nGit branch and worktree directory share a **task name**:\n\n```\n<type>-<issue number (optional)>-<short description>\n```\n\nThe tmux session adds a `codex-worker-` prefix so workers are easy to filter:\n\n| | Format | Example |\n|---|---|---|\n| Task name | `<type>-<number>-<desc>` | `issue-836-prompt-dollar-sign` |\n| Git branch | same as task name | `issue-836-prompt-dollar-sign` |\n| Worktree dir | `<project>.worktrees/<task>` | `kimi-cli.worktrees/issue-836-prompt-dollar-sign` |\n| tmux session | `codex-worker-<task>` | `codex-worker-issue-836-prompt-dollar-sign` |\n\nMore examples:\n- `issue-518-mcp-config-isolation`\n- `fix-share-dir-skills-path`\n- `feat-ask-user-tool`\n- `refactor-jinja-templates`\n\nList only codex workers: `tmux ls | grep ^codex-worker-`\n\n## Usage\n\nPrefer tmux + interactive codex for all tasks. It supports multi-turn dialogue,\nthe user can `tmux attach` to inspect or intervene, and you can send follow-up\nprompts from outside.\n\n### Spawn a worker\n\n```bash\nNAME=\"issue-836-prompt-dollar-sign\"        # task name\nSESSION=\"codex-worker-$NAME\"               # tmux session name\nPROJECT_DIR=\"$(pwd)\"\nWORKTREE_DIR=\"$PROJECT_DIR.worktrees\"\n\n# 1. Create worktree (skip if exists)\ngit worktree add \"$WORKTREE_DIR/$NAME\" -b \"$NAME\" main 2>/dev/null\n\n# 2. Launch interactive codex inside tmux\ntmux new-session -d -s \"$SESSION\" -x 200 -y 50 \\\n  \"cd $WORKTREE_DIR/$NAME && codex --dangerously-bypass-approvals-and-sandbox\"\n```\n\n### Send a prompt\n\nThe Codex TUI needs time to initialize before it accepts input.\nAfter launching a session, **wait at least 5 seconds** before sending\na prompt. Then send the text followed by `Enter`. If the prompt stays\nin the input field without being submitted, send an additional `Enter`.\n\n```bash\nsleep 5  # wait for Codex TUI to initialize\ntmux send-keys -t \"$SESSION\" \"Your prompt here\" Enter\n# If it doesn't submit, send another Enter:\n# tmux send-keys -t \"$SESSION\" Enter\n```\n\n### Peek at output\n\n```bash\ntmux capture-pane -t \"$SESSION\" -p | tail -30\n```\n\n### Attach for hands-on interaction\n\n```bash\ntmux attach -t \"$SESSION\"\n```\n\n### Parallel fan-out\n\n```bash\nTASKS=(\n  \"issue-518-mcp-config-isolation|Triage #518: MCP config 被子 agent 继承的隔离问题。分析根因，给出修复方案。\"\n  \"issue-836-prompt-dollar-sign|Triage #836: prompt 包含 $ 时启动静默失败。分析根因，给出修复方案。\"\n)\n\nPROJECT_DIR=\"$(pwd)\"\nWORKTREE_DIR=\"$PROJECT_DIR.worktrees\"\n\nfor entry in \"${TASKS[@]}\"; do\n  NAME=\"${entry%%|*}\"\n  PROMPT=\"${entry#*|}\"\n  SESSION=\"codex-worker-$NAME\"\n  git worktree add \"$WORKTREE_DIR/$NAME\" -b \"$NAME\" main 2>/dev/null\n  tmux new-session -d -s \"$SESSION\" -x 200 -y 50 \\\n    \"cd $WORKTREE_DIR/$NAME && codex --dangerously-bypass-approvals-and-sandbox\"\n  sleep 5  # wait for Codex TUI to fully initialize\n  tmux send-keys -t \"$SESSION\" \"$PROMPT\" Enter\ndone\n```\n\n### Fallback: `codex exec`\n\nOnly use `codex exec` when you explicitly don't need follow-up (e.g. CI, pure\nanalysis with `-o` output). It does not support multi-turn dialogue.\n\n```bash\ncodex exec --dangerously-bypass-approvals-and-sandbox \\\n  -o \"/tmp/$NAME-result.md\" \\\n  \"Your prompt here\"\n```\n\n## Lifecycle management\n\nList active workers:\n\n```bash\ntmux ls | grep ^codex-worker-\n```\n\nKill a finished worker:\n\n```bash\ntmux kill-session -t \"codex-worker-$NAME\"\n```\n\nClean up worktree after merging:\n\n```bash\ntmux kill-session -t \"codex-worker-$NAME\" 2>/dev/null\ngit worktree remove \"$WORKTREE_DIR/$NAME\"\ngit branch -d \"$NAME\"\n```\n\nBatch cleanup of dead sessions:\n\n```bash\ntmux list-sessions -F '#{session_name}:#{pane_dead}' \\\n  | grep ':1$' \\\n  | cut -d: -f1 \\\n  | xargs -I{} tmux kill-session -t {}\n```\n"
  },
  {
    "path": ".agents/skills/feature-smoke-test/SKILL.md",
    "content": "---\nname: feature-smoke-test\ndescription: 针对 Kimi Code CLI 的新增或变更功能，规划并执行可重复的端到端冒烟测试。从 git diff 推断功能边界，读取相关文档和代码，设计测试 prompt，以 --print 非交互模式运行本地 CLI，检查进程退出码和 session 产物，总结预期与实际行为之间的差异。发现问题时自动启动多路并行探查以定位根因。\n---\n\n冒烟测试是运行时验证，不是写完 prompt 或读完代码就结束。必须实际执行、实际检查产物。\n\n## 确定测试范围\n\n动手之前，先从代码变更推断功能边界：\n\n```sh\ngit diff main --name-only\ngit diff main --stat\n```\n\n根据变更文件集合，明确写下：\n\n- 被测的功能边界\n- 用户可感知的行为变化\n- 运行前的准备工作和运行后的清理工作\n- 能够证明成功或失败的证据\n\n如果功能涉及状态、异步、审批流或时序敏感逻辑，默认认为单条 prompt 不够。\n\n## 先读事实来源\n\n读取定义该功能真实行为的最小文件集合：\n\n- 面向用户的文档、changelog 或设计笔记\n- 暴露该功能的 agent prompt 或 tool prompt\n- 实现层入口\n- 已有测试——当测试比文档更准确地描述行为时优先看测试\n\n不要信任过时的 prompt 示例。先从代码或当前文档重建真实的工具接口。\n\n## 制定最小测试计划\n\n默认覆盖三个场景：\n\n1. 正常路径\n2. 边界条件、非法输入或容量极限\n3. 中断、重试、清理或恢复\n\n每个场景记录：\n\n- `目标`\n- `前置准备`\n- `prompt 策略`\n- `成功信号`\n- `失败信号`\n- `需要检查的产物`\n\n如果功能存在竞态条件，必须显式标注时序。用长时间运行的命令或刻意的等待来制造时序窗口，不要用\"快速再跑一个\"这种模糊说法。\n\n## 优先使用多轮 prompt\n\n默认采用多轮流程：\n\n1. 探索轮：让 agent 在阅读事实来源后复述当前真实接口\n2. 执行轮：只执行一个场景\n3. 观察轮：只读取输出、工具状态和产物文件，不扩大范围\n4. 清理轮：停止、回滚或关闭有状态资源\n\n仅对无状态的简单功能使用单轮 prompt。\n\n多轮测试时，每一轮单独调用 CLI，在两轮之间检查上一轮的输出和产物，再决定下一轮的 prompt。不要把多轮 prompt 一次性塞进 stdin——那是盲写，无法根据上一轮结果调整。\n\n测试时，显式要求 agent 先列举当前可用的工具，不要臆造历史工具名。可复用的 prompt 模板见 `references/prompt-patterns.md`。\n\n## 以非交互模式隔离运行 CLI\n\n使用 `/tmp` 下的一次性目录作为 `--work-dir`，实现 session 隔离。CLI 的 session 路径由 `~/.kimi/sessions/<md5(work_dir)>/` 决定，不同的 work-dir 自动产生独立的 session 命名空间，不会污染正常项目的 session。认证状态保留在 `~/.kimi` 下，无需额外配置。\n\n### 环境准备\n\n```sh\nSMOKE_DIR=\"$(mktemp -d /tmp/kimi-smoke-XXXXXX)\"\n```\n\n如果功能需要仓库上下文（读取代码、git 信息等），把仓库文件复制或软链到 `SMOKE_DIR`。如果功能会编辑文件，绝不要用活跃仓库作为 work-dir。\n\n### 默认执行方式\n\n绝大多数场景使用这个模式：\n\n```sh\nuv run python -m kimi_cli.cli \\\n  --print \\\n  --prompt \"你的测试 prompt\" \\\n  --work-dir \"$SMOKE_DIR\"\necho \"exit_code=$?\"\n```\n\n`--print` 会自动启用 `--yolo`（自动批准所有操作），适合无人值守的冒烟测试。\n\n执行后**必须先检查退出码**：非零表示 CLI 本身崩溃或超时，应优先排查进程级错误，再看 session 产物。\n\n### 备选执行模式\n\n当默认方式不满足需求时，按需选用：\n\n- **长 prompt**：通过 stdin 传入——`cat <<'PROMPT' | uv run python -m kimi_cli.cli --print --input-format text --work-dir \"$SMOKE_DIR\"`\n- **结构化输出**：加 `--output-format stream-json`，输出逐行 JSON，便于程序化解析\n- **只看最终结果**：用 `--quiet`，等价于 `--print --output-format text --final-message-only`\n\n### 注意事项\n\n- 运行过程中记录这些路径：`SMOKE_DIR`、最终 session 目录（可通过 `inspect_session.py` 定位）、功能特定的输出文件。\n\n## 刻意执行\n\n执行过程中维护一份简短的运行日志：\n\n- 使用的完整 prompt\n- 关键的工具调用或命令\n- 功能产生的 task id、输出路径或审批 id\n- 时序敏感时记录时间戳\n\n不要仅凭 assistant 的最终文本推断正确性。运行时文件和工具结果才是事实来源。\n\n## 检查 session 产物\n\n首先检查：\n\n- `~/.kimi/sessions/.../context.jsonl`\n- `~/.kimi/sessions/.../wire.jsonl`\n- 功能创建的 session 级文件\n\n使用 `scripts/inspect_session.py` 查找并汇总最新 session：\n\n```sh\nuv run python .agents/skills/feature-smoke-test/scripts/inspect_session.py --share-dir ~/.kimi\n```\n\n脚本退出码含义：0 = 正常汇总，1 = session 目录缺失或无法解析。\n\n如果功能会创建后台任务、通知或附属文件，直接检查这些文件，不要只依赖模型摘要。\n\n## 汇报结论\n\n将结果分为三类：\n\n- **已确认**的行为\n- **与预期不符**的行为\n- **仍有歧义**、需要更确定性复现的行为\n\n对于每个 bug 或回归，记录：\n\n- 触发它的 prompt\n- 精确的 session 路径\n- 证明它的产物路径\n- 能推导出的最小复现步骤\n\n## 问题探查\n\n当发现与预期不符的行为时，不要停在报告层面。启动并行探查流程定位根因：\n\n### 探查策略\n\n针对每个发现的问题，**同时启动多个独立的探查方向**（使用 Agent 工具并行执行）。根据问题的具体表现自行判断最有价值的探查角度，常见方向包括但不限于：\n\n- 从触发问题的入口沿调用链追踪实际执行路径\n- 检查输入数据经过各处理阶段后的变化\n- 检查持久化状态（session 文件、后台任务、通知记录等）是否一致\n- 运行相关单元测试和集成测试，确认测试是否覆盖了出问题的路径\n\n不必机械地覆盖所有方向。根据 session 产物中的具体异常信号，选择最可能命中根因的 2-3 个方向并行展开。\n\n### 探查输出\n\n每条探查方向独立汇报：\n\n- 探查的具体方向和范围\n- 发现的事实（附文件路径和行号）\n- 该方向的结论：已定位根因 / 已排除 / 需要进一步调查\n\n### 综合定位\n\n汇总所有探查结果后，输出：\n\n- **根因**：一句话总结问题的本质原因\n- **证据链**：从触发 prompt → 代码路径 → 出错点 → 产物表现的完整链路\n- **修复建议**：最小改动方案，附具体文件和行号\n- **回归风险**：修复后需要额外验证的相关功能\n"
  },
  {
    "path": ".agents/skills/feature-smoke-test/references/prompt-patterns.md",
    "content": "# Prompt 模板\n\n以下模板作为脚手架使用。运行前替换占位符。\n\n## 单轮还是多轮\n\n满足以下任一条件时使用多轮：\n\n- 功能有状态\n- 功能依赖时序或并发\n- 功能需要审批、清理或恢复\n- session 产物本身是证据的一部分\n- 工具接口可能近期发生过变化\n\n仅对无状态的窄范围检查使用单轮。\n\n## 变量\n\n起草 prompt 前填写以下字段：\n\n- `<feature>` — 被测功能名称\n- `<goal>` — 当前场景的目标\n- `<source_paths>` — 需要阅读的源码路径\n- `<constraints>` — 执行约束\n- `<success_signals>` — 成功信号\n- `<failure_signals>` — 失败信号\n- `<artifact_paths>` — 需要检查的产物路径\n- `<session_dir>` — session 目录路径\n\n## 探索 prompt\n\n```text\n我要验证 <feature>。\n\n先阅读这些文件并只总结当前真实对外接口，不要假设旧文档、旧 prompt 或旧 tool 名称仍然正确：\n<source_paths>\n\n然后给我一个最小 smoke test 计划，只包含：\n1. happy path\n2. 一个边界/异常场景\n3. 一个清理、恢复或中断场景\n\n每个场景都写清楚目标、预期信号和要检查的产物。\n```\n\n## 执行 prompt\n\n```text\n在当前 session 里只执行这个场景：<goal>\n\n约束：\n<constraints>\n\n执行前先复述你将使用的工具或命令。执行时记录关键 task id、输出片段、文件路径和任何需要后续复盘的标识符。不要扩展到其他场景。\n```\n\n## 观察 prompt\n\n```text\n现在不要继续跑新的测试。\n\n只读取并总结这次运行已经产生的状态和文件：\n<artifact_paths>\n\n请明确指出哪些证据支持了预期，哪些证据反驳了预期，哪些地方仍然不确定。\n```\n\n## 复盘 prompt\n\n```text\n请根据这个 session 目录复盘整个 smoke test：\n<session_dir>\n\n重点阅读 context.jsonl、wire.jsonl 和相关运行产物。输出：\n1. 实际执行流程\n2. 关键 tool 调用与结果\n3. 与预期不一致的点\n4. 最小复现步骤\n```\n\n## 兼容性校验 prompt\n\n```text\n在运行 smoke test 之前，先从提供的文档或代码中复述当前真实可用的工具及其准确名称。不要臆造旧版工具名。如果任务涉及状态或时序，将工作拆分为多轮而非一次性长回复。\n```\n"
  },
  {
    "path": ".agents/skills/feature-smoke-test/scripts/inspect_session.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Locate and summarize a Kimi CLI session for smoke-test review.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport sys\nfrom collections import Counter\nfrom pathlib import Path\nfrom typing import Any\n\n\ndef parse_args() -> argparse.Namespace:\n    parser = argparse.ArgumentParser(\n        description=\"Locate and summarize a Kimi CLI session for smoke-test review.\"\n    )\n    parser.add_argument(\"--share-dir\", type=Path, help=\"Share dir that contains sessions/\")\n    parser.add_argument(\"--session-dir\", type=Path, help=\"Explicit session directory to inspect\")\n    parser.add_argument(\n        \"--tail-lines\", type=int, default=12, help=\"How many recent records to show\"\n    )\n    parser.add_argument(\n        \"--max-text\",\n        type=int,\n        default=220,\n        help=\"Maximum characters to show for any text preview\",\n    )\n    return parser.parse_args()\n\n\ndef truncate(text: str, max_text: int) -> str:\n    text = \" \".join(text.split())\n    if len(text) <= max_text:\n        return text\n    return text[: max_text - 3] + \"...\"\n\n\ndef extract_text(content: Any) -> str:\n    if isinstance(content, str):\n        return content\n    if isinstance(content, list):\n        parts: list[str] = []\n        for item in content:\n            if not isinstance(item, dict):\n                parts.append(str(item))\n                continue\n            kind = item.get(\"type\")\n            if kind == \"text\" and isinstance(item.get(\"text\"), str):\n                parts.append(item[\"text\"])\n            elif kind == \"think\" and isinstance(item.get(\"think\"), str):\n                parts.append(item[\"think\"])\n            elif kind == \"shell\" and isinstance(item.get(\"command\"), str):\n                parts.append(item[\"command\"])\n            else:\n                parts.append(json.dumps(item, ensure_ascii=False))\n        return \" \".join(parts)\n    return json.dumps(content, ensure_ascii=False)\n\n\ndef load_json(path: Path) -> dict[str, Any] | None:\n    if not path.exists():\n        return None\n    try:\n        return json.loads(path.read_text())\n    except Exception:\n        return None\n\n\ndef iter_jsonl(path: Path) -> list[dict[str, Any]]:\n    records: list[dict[str, Any]] = []\n    if not path.exists():\n        return records\n    with path.open() as f:\n        for line in f:\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                obj = json.loads(line)\n            except json.JSONDecodeError:\n                obj = {\"_raw\": line}\n            records.append(obj)\n    return records\n\n\ndef find_latest_session(share_dir: Path) -> Path:\n    sessions_root = share_dir / \"sessions\"\n    if not sessions_root.exists():\n        raise FileNotFoundError(f\"sessions directory not found: {sessions_root}\")\n\n    candidates: list[tuple[float, Path]] = []\n    for path in sessions_root.glob(\"*/*\"):\n        if not path.is_dir():\n            continue\n        context_path = path / \"context.jsonl\"\n        wire_path = path / \"wire.jsonl\"\n        if context_path.exists():\n            mtime = context_path.stat().st_mtime\n        elif wire_path.exists():\n            mtime = wire_path.stat().st_mtime\n        else:\n            mtime = path.stat().st_mtime\n        candidates.append((mtime, path))\n\n    if not candidates:\n        raise FileNotFoundError(f\"no session directories found under: {sessions_root}\")\n\n    candidates.sort(key=lambda item: item[0], reverse=True)\n    return candidates[0][1]\n\n\ndef print_header(title: str) -> None:\n    print()\n    print(f\"== {title} ==\")\n\n\ndef summarize_context_record(record: dict[str, Any], max_text: int) -> str:\n    if \"_raw\" in record:\n        return truncate(record[\"_raw\"], max_text)\n\n    role = record.get(\"role\", \"<unknown>\")\n    if role == \"_system_prompt\":\n        return \"role=_system_prompt\"\n    if role == \"_checkpoint\":\n        return f\"role=_checkpoint id={record.get('id')}\"\n    if role == \"_usage\":\n        return f\"role=_usage token_count={record.get('token_count')}\"\n\n    text = truncate(extract_text(record.get(\"content\")), max_text)\n\n    if role == \"assistant\":\n        tool_calls = record.get(\"tool_calls\") or []\n        tool_names = [\n            call.get(\"function\", {}).get(\"name\")\n            for call in tool_calls\n            if isinstance(call, dict) and isinstance(call.get(\"function\"), dict)\n        ]\n        parts: list[str] = [f\"role={role}\"]\n        if tool_names:\n            parts.append(\"tools=\" + \",\".join(name for name in tool_names if name))\n        if text:\n            parts.append(f\"text={text}\")\n        return \" | \".join(parts)\n\n    if role == \"tool\":\n        parts = [f\"role={role}\"]\n        if record.get(\"tool_call_id\"):\n            parts.append(f\"tool_call_id={record['tool_call_id']}\")\n        if text:\n            parts.append(f\"text={text}\")\n        return \" | \".join(parts)\n\n    if text:\n        return f\"role={role} | text={text}\"\n    return f\"role={role}\"\n\n\ndef summarize_wire_record(record: dict[str, Any], max_text: int) -> str:\n    if \"_raw\" in record:\n        return truncate(record[\"_raw\"], max_text)\n\n    message = record.get(\"message\")\n    if not isinstance(message, dict):\n        return truncate(json.dumps(record, ensure_ascii=False), max_text)\n\n    message_type = message.get(\"type\", \"<unknown>\")\n    payload = message.get(\"payload\", {})\n    parts = [f\"type={message_type}\"]\n\n    if message_type == \"StepBegin\":\n        parts.append(f\"n={payload.get('n')}\")\n    elif message_type == \"ContentPart\":\n        part_type = payload.get(\"type\")\n        parts.append(f\"part={part_type}\")\n        if part_type in {\"text\", \"think\"}:\n            raw = payload.get(\"text\") or payload.get(\"think\") or \"\"\n            parts.append(\"text=\" + truncate(raw, max_text))\n    elif message_type == \"ToolCall\":\n        function = payload.get(\"function\", {})\n        if isinstance(function, dict):\n            parts.append(f\"tool={function.get('name')}\")\n    elif message_type == \"ApprovalRequest\":\n        parts.append(f\"action={payload.get('action')}\")\n        if payload.get(\"description\"):\n            parts.append(\"desc=\" + truncate(str(payload[\"description\"]), max_text))\n    elif message_type == \"TurnBegin\":\n        user_input = payload.get(\"user_input\") or []\n        parts.append(f\"user_parts={len(user_input)}\")\n    elif message_type == \"StatusUpdate\":\n        parts.append(f\"context_tokens={payload.get('context_tokens')}\")\n\n    return \" | \".join(parts)\n\n\ndef print_jsonl_summary(title: str, path: Path, tail_lines: int, max_text: int) -> None:\n    if not path.exists():\n        print_header(title)\n        print(\"missing\")\n        return\n\n    records = iter_jsonl(path)\n    print_header(title)\n    print(path)\n    print(f\"records: {len(records)}\")\n\n    if path.name == \"context.jsonl\":\n        counter = Counter(record.get(\"role\", \"<raw>\") for record in records)\n        print(\"roles:\", \", \".join(f\"{key}={value}\" for key, value in sorted(counter.items())))\n        tail = records[-tail_lines:]\n        for idx, record in enumerate(tail, start=max(1, len(records) - len(tail) + 1)):\n            print(f\"[{idx}] {summarize_context_record(record, max_text)}\")\n    else:\n        counter = Counter(\n            record.get(\"message\", {}).get(\"type\", \"<raw>\")\n            if isinstance(record.get(\"message\"), dict)\n            else \"<raw>\"\n            for record in records\n        )\n        print(\"types:\", \", \".join(f\"{key}={value}\" for key, value in sorted(counter.items())))\n        tail = records[-tail_lines:]\n        for idx, record in enumerate(tail, start=max(1, len(records) - len(tail) + 1)):\n            print(f\"[{idx}] {summarize_wire_record(record, max_text)}\")\n\n\ndef print_file_inventory(session_dir: Path) -> None:\n    print_header(\"Files\")\n    for path in sorted(session_dir.rglob(\"*\")):\n        if path.is_dir():\n            continue\n        relative = path.relative_to(session_dir)\n        size = path.stat().st_size\n        print(f\"{relative} ({size} bytes)\")\n\n\ndef tail_text_file(path: Path, tail_lines: int, max_text: int) -> list[str]:\n    if not path.exists():\n        return []\n    lines = path.read_text(errors=\"replace\").splitlines()\n    return [truncate(line, max_text) for line in lines[-tail_lines:]]\n\n\ndef print_task_summary(session_dir: Path, tail_lines: int, max_text: int) -> None:\n    tasks_dir = session_dir / \"tasks\"\n    if not tasks_dir.exists():\n        return\n\n    task_dirs = sorted(path for path in tasks_dir.iterdir() if path.is_dir())\n    if not task_dirs:\n        return\n\n    print_header(\"Background Tasks\")\n    for task_dir in task_dirs:\n        spec = load_json(task_dir / \"spec.json\") or {}\n        runtime = load_json(task_dir / \"runtime.json\") or {}\n        control = load_json(task_dir / \"control.json\") or {}\n        consumer = load_json(task_dir / \"consumer.json\") or {}\n        print(f\"task_id: {task_dir.name}\")\n        print(f\"  description: {spec.get('description')}\")\n        print(f\"  kind: {spec.get('kind')}\")\n        print(f\"  status: {runtime.get('status')}\")\n        if runtime.get(\"exit_code\") is not None:\n            print(f\"  exit_code: {runtime.get('exit_code')}\")\n        if spec.get(\"cwd\"):\n            print(f\"  cwd: {spec.get('cwd')}\")\n        if spec.get(\"timeout_s\") is not None:\n            print(f\"  timeout_s: {spec.get('timeout_s')}\")\n        for key in (\n            \"created_at\",\n            \"started_at\",\n            \"finished_at\",\n            \"heartbeat_at\",\n            \"failure_reason\",\n            \"worker_pid\",\n            \"child_pid\",\n        ):\n            value = runtime.get(key)\n            if value is not None:\n                print(f\"  {key}: {value}\")\n        for key in (\"kill_requested_at\", \"kill_reason\"):\n            value = control.get(key)\n            if value is not None:\n                print(f\"  {key}: {value}\")\n        for key in (\"last_read_offset\", \"last_viewed_at\"):\n            value = consumer.get(key)\n            if value is not None:\n                print(f\"  {key}: {value}\")\n        output_path = task_dir / \"output.log\"\n        if output_path.exists():\n            print(f\"  output_log: {output_path}\")\n            for line in tail_text_file(output_path, tail_lines, max_text):\n                print(f\"    {line}\")\n        print()\n\n\ndef main() -> int:\n    args = parse_args()\n\n    try:\n        if args.session_dir:\n            session_dir = args.session_dir.expanduser().resolve()\n        elif args.share_dir:\n            session_dir = find_latest_session(args.share_dir.expanduser().resolve())\n        else:\n            print(\"error: pass --session-dir or --share-dir\", file=sys.stderr)\n            return 1\n    except FileNotFoundError as exc:\n        print(f\"error: {exc}\", file=sys.stderr)\n        return 1\n\n    if not session_dir.is_dir():\n        print(f\"error: session directory does not exist: {session_dir}\", file=sys.stderr)\n        return 1\n\n    print(f\"Session dir: {session_dir}\")\n    print_file_inventory(session_dir)\n    print_jsonl_summary(\"Context\", session_dir / \"context.jsonl\", args.tail_lines, args.max_text)\n    print_jsonl_summary(\"Wire\", session_dir / \"wire.jsonl\", args.tail_lines, args.max_text)\n    print_task_summary(session_dir, args.tail_lines, args.max_text)\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": ".agents/skills/gen-changelog/SKILL.md",
    "content": "---\nname: gen-changelog\ndescription: Generate changelog entries for code changes.\n---\n\n根据当前分支相对于 main 分支的修改，生成更新日志条目并同步到文档站点。\n\n## 步骤\n\n1. **分析变更**：查看 `git log main..HEAD --oneline` 和 `git diff main..HEAD --stat`，理解所有变更。\n2. **更新源 CHANGELOG**：在根目录 `CHANGELOG.md` 的 `## Unreleased` 下添加条目；如果变更属于 `packages/` 或 `sdks/` 下的子包，同时更新对应目录的 `CHANGELOG.md`。\n3. **同步英文文档 changelog**：运行 `node docs/scripts/sync-changelog.mjs` 将根 `CHANGELOG.md` 同步到 `docs/en/release-notes/changelog.md`。\n4. **更新中文文档 changelog**：在 `docs/zh/release-notes/changelog.md` 的 `## 未发布` 下添加对应的中文翻译条目，遵循现有格式和用词规范（参考 `docs/AGENTS.md` 中的术语表和排版规范）。\n5. **Breaking changes**（如有）：如果变更包含破坏性变更（如移除/重命名选项、更改默认行为、迁移配置格式等），还需在 `docs/en/release-notes/breaking-changes.md` 和 `docs/zh/release-notes/breaking-changes.md` 的 `## Unreleased` / `## 未发布` 下添加对应条目，遵循现有的格式（版本标题 + 小节 + 受影响/迁移说明）。\n\n## 注意事项\n\n- 条目风格遵循现有 CHANGELOG 的格式：`- 分类: 描述`（如 `- Core: ...`、`- Web: ...`）。\n- 只写对用户有意义的变更，不写纯内部重构。\n- 中文翻译应遵循 `docs/AGENTS.md` 中的术语映射和排版规范。\n"
  },
  {
    "path": ".agents/skills/gen-docs/SKILL.md",
    "content": "---\nname: gen-docs\ndescription: Update Kimi Code CLI user documentation.\n---\n\n现在我们正在为当前项目 Kimi Code CLI 编写和维护用户文档，文档内容在 docs 目录下，docs/AGENTS.md 中有对文档的说明。\n\n我们现在对代码库有了一些修改，请你参考最近的 git commit、staged changes、changelog.md 等的内容，根据 AGENTS.md 中的信息，必要时找到实际的代码全文，确保理解了所有变更对产品用户体验的真实改变，然后逐页、逐段地检查和更新文档内容。\n\n你应该首先确保英文 changelog 使用 `node docs/scripts/sync-changelog.mjs` 进行了同步，然后确保中文文档符合最新代码的行为，最后，使用 translate-docs skill 进行双语同步。\n"
  },
  {
    "path": ".agents/skills/gen-rust/SKILL.md",
    "content": "---\nname: gen-rust\ndescription: Sync Rust implementation with Python changes (exclude UI/login) by reviewing recent changes, mapping modules, porting logic, and updating tests.\n---\n\n# gen-rust\n\nUse this skill when the user wants Rust (kagent/kosong/kaos) to stay logically identical to Python (kimi_cli/kosong/kaos), excluding UI and login/auth. This includes code and tests: Rust behavior and tests must be fully synchronized with Python changes.\n\nNote: The Rust binary is named `kagent`. User-facing CLI/output text in Rust must use `kagent`\ninstead of `kimi` to match the Rust command name.\n\n## Quick workflow\n\n1) **Build a complete change inventory**\n\nReview recent changes to understand what needs syncing:\n\n```sh\n# Check staged changes\ngit diff --cached --name-only\ngit diff --cached -- src packages\n\n# Check recent commits\ngit log --oneline -20 -- src packages\ngit diff HEAD~20..HEAD -- src packages\n\n# Review CHANGELOG.md for context\nhead -50 CHANGELOG.md\n```\n\n2) **Classify changes**\n\n- Exclude UI and login/auth changes (Shell/Print/ACP UI, login/logout commands).\n- Everything else must be mirrored in Rust.\n- Keep a small checklist: file -> change summary -> Rust target -> status.\n\n3) **Map Python -> Rust**\n\nCommon mappings:\n- `src/kimi_cli/llm.py` -> `rust/kagent/src/llm.rs`\n- `src/kimi_cli/soul/*` -> `rust/kagent/src/soul/*`\n- `src/kimi_cli/tools/*` -> `rust/kagent/src/tools/*`\n- `src/kimi_cli/utils/*` -> `rust/kagent/src/utils/*`\n- `src/kimi_cli/wire/*` -> `rust/kagent/src/wire/*`\n- `packages/kosong/*` -> `rust/kosong/*`\n- `packages/kaos/*` -> `rust/kaos/*`\n\n4) **Port logic carefully**\n\n- Match error messages and tool output text exactly (tests often assert strings).\n- Preserve output types (text vs parts) and ordering.\n- For media/tool outputs, verify ContentPart wrapping and serialization.\n- If Python adds new helper modules, mirror minimal Rust utilities.\n- Use `rg` to find existing analogs and references.\n\n5) **Update tests**\n\n- Update Rust tests that assert content/strings/parts.\n- Mirror Python unit and integration tests when they exist; add missing Rust tests so coverage matches intent.\n- Ensure E2E parity: use the existing Python E2E suite against the Rust binary by setting\n  `KIMI_E2E_WIRE_CMD` (do not rewrite E2E in Rust). All E2E cases must pass or the gap must be documented.\n- Prefer targeted tests first (`cargo test -p kagent --test <name>`), then full suite if asked.\n\n6) **Verification is mandatory**\n\n- Run the full Rust test suite and ensure all Rust tests pass.\n- Run E2E tests with the wire command swapped to Rust (set `KIMI_E2E_WIRE_CMD`), and ensure they pass.\n\n7) **Final report**\n\n- List synced files and logic.\n- Call out intentionally skipped UI/login changes.\n- List tests run and results (must include full Rust tests and Rust E2E with wire command override).\n\n## Pitfalls to avoid\n\n- Skipping `llm.py`: it often changes model capability logic.\n- Using commit message filtering instead of full diff.\n- Forgetting to update Rust tests when output text/parts change.\n- Mixing UI/login changes into core sync.\n- Leaving test parity ambiguous; always state unit/integration/E2E status.\n\n## Minimal diff checklist (template)\n\n- [ ] Recent changes reviewed (staged, commits, changelog)\n- [ ] Python diffs inspected for core logic\n- [ ] Rust mappings applied\n- [ ] Tests updated\n- [ ] Targeted tests run\n- [ ] Full Rust test suite passed\n- [ ] Rust E2E passed with `KIMI_E2E_WIRE_CMD`\n"
  },
  {
    "path": ".agents/skills/pull-request/SKILL.md",
    "content": "---\nname: pull-request\ndescription: Create and submit a GitHub Pull Request.\ntype: flow\n---\n\n```mermaid\nflowchart TB\n    A([\"BEGIN\"]) --> B[\"当前分支有没有 dirty change？\"]\n    B -- 有 --> D([\"END\"])\n    B -- 没有 --> n1[\"确保当前分支是一个不同于 main 的独立分支\"]\n    n1 --> n2[\"根据当前分支相对于 main 分支的修改，push 并提交一个 PR（利用 gh 命令），用英文编写 PR 标题和 description，描述所做的更改。PR title 要符合先前的 commit message 规范（PR title 就是 squash merge 之后的 commit message）。\"]\n    n2 --> D\n```\n"
  },
  {
    "path": ".agents/skills/release/SKILL.md",
    "content": "---\nname: release\ndescription: Execute the release workflow for Kimi Code CLI packages.\ntype: flow\n---\n\n```d2\nunderstand: |md\n  Understand the release automation by reading AGENTS.md and\n  .github/workflows/release*.yml.\n|\ncheck_changes: |md\n  Check each package under packages/, sdks/, and repo root for changes since the\n  last release (by tag). Note packages/kimi-code is a thin wrapper and must stay\n  version-synced with kimi-cli.\n|\nhas_changes: \"Any packages changed?\"\nconfirm_versions: |md\n  For each changed package, confirm the new version with the user. Follow the\n  project versioning policy: patch is always 0, bump minor for any change,\n  major only changes by explicit manual decision.\n|\nupdate_files: |md\n  Update the relevant pyproject.toml (and rust/Cargo.toml if root version changes),\n  CHANGELOG.md (keep the Unreleased header), and breaking-changes.md in both languages.\n|\nroot_change: \"Is the root package version changing?\"\nsync_kimi_code: |md\n  Sync packages/kimi-code/pyproject.toml version and dependency\n  `kimi-cli==<version>`.\n|\nsync_kagent: |md\n  Sync rust/Cargo.toml workspace version to match the root package version.\n|\nuv_sync: \"Run uv sync.\"\ngen_docs: |md\n  Follow the gen-docs skill instructions to ensure docs are up to date.\n|\n\nnew_branch: |md\n  Create a new branch `bump-<package>-<new-version>` (multiple packages can share\n  one branch; name it appropriately).\n|\nopen_pr: |md\n  Commit all changes, push to remote, and open a PR with gh describing the\n  updates.\n|\nmonitor_pr: \"Monitor the PR until it is merged.\"\npost_merge: |md\n  After merge, switch to main, pull latest changes, and tell the user the git\n  tag command needed for the final release tag (they will tag + push tags). Note:\n  a single numeric tag releases kimi-cli, kimi-code, and kagent together.\n|\n\nBEGIN -> understand -> check_changes -> has_changes\nhas_changes -> END: no\nhas_changes -> confirm_versions: yes\nconfirm_versions -> update_files -> root_change\nroot_change -> sync_kimi_code: yes\nroot_change -> uv_sync: no\nsync_kimi_code -> sync_kagent\nsync_kagent -> uv_sync\nuv_sync -> gen_docs -> new_branch -> open_pr -> monitor_pr -> post_merge -> END\n```\n"
  },
  {
    "path": ".agents/skills/translate-docs/SKILL.md",
    "content": "---\nname: translate-docs\ndescription: Translate and sync bilingual documentation.\n---\n\n现在我们正在为当前项目 Kimi Code CLI 编写和维护用户文档，文档内容在 docs 目录下，docs/AGENTS.md 中有对文档的说明。\n\n中文文档和英文 changelog 已经确保是正确符合预期的，现在请你逐页、逐段地翻译文档内容，确保中英双语保持同步。\n\n## 翻译方向\n\n- **Changelog**: 以英文为准，翻译到中文\n- **其他所有页面**: 以中文为准，翻译到英文\n\n## 注意事项\n\n- 必要时可以参考代码文件以确保翻译的准确性\n- 要保证不同语言的表达风格、结构标记等保持一致\n- 但需要遵守两者可能不同的用词和排版偏好（主要是 sentence case、翻译对照之类的）\n"
  },
  {
    "path": ".agents/skills/worktree-status/SKILL.md",
    "content": "---\nname: worktree-status\ndescription: Audit all git worktrees in the current project. Use when the user asks about worktree status, which branches are merged, which have uncommitted changes, or which worktrees can be safely cleaned up.\n---\n\n# worktree-status\n\nReport the status of every git worktree for the current project, covering\ndirty state and merge status.\n\n## When to use\n\n- User asks \"which worktrees can I clean up?\"\n- User asks \"what's the status of my worktrees / branches?\"\n- Before batch-cleaning worktrees, to avoid losing uncommitted work\n\n## Procedure\n\n### 1. Pull latest main (MANDATORY)\n\nYou MUST pull latest main before any status checks. Without this, merge\ndetection (both ancestry and content diff) will produce stale results and\nyou may mistakenly conclude a branch is not merged.\n\n```bash\ncd \"$(git rev-parse --show-toplevel)\" && git pull origin main\n```\n\n### 2. Collect worktree info\n\n```bash\nPROJECT_DIR=\"$(git rev-parse --show-toplevel)\"\n\nfor wt in $(git worktree list --porcelain | grep \"^worktree \" | sed 's/^worktree //' | grep -v \"$PROJECT_DIR$\"); do\n  branch=$(git -C \"$wt\" branch --show-current 2>/dev/null)\n  [ -z \"$branch\" ] && branch=\"(detached)\"\n  name=$(basename \"$wt\")\n\n  # dirty?\n  if [ -z \"$(git -C \"$wt\" status --short 2>/dev/null)\" ]; then\n    dirty=\"clean\"\n  else\n    dirty=\"DIRTY\"\n  fi\n\n  # merged into origin/main?\n  # NOTE: This project uses squash merges exclusively. `git merge-base\n  # --is-ancestor` does NOT detect squash-merged branches. Always follow\n  # up with a content diff (step 3) for branches that appear \"not merged\".\n  if [ \"$branch\" != \"(detached)\" ]; then\n    if git merge-base --is-ancestor \"$branch\" origin/main 2>/dev/null; then\n      merged=\"merged\"\n    else\n      merged=\"not merged (verify with content diff)\"\n    fi\n  else\n    merged=\"n/a\"\n  fi\n\n  echo \"\"\n  echo \"[$name]  branch=$branch  $dirty  $merged\"\n  if [ \"$dirty\" = \"DIRTY\" ]; then\n    git -C \"$wt\" status --short 2>/dev/null | sed 's/^/  /'\n  fi\ndone\n```\n\n### 3. Detect squash-merged branches (content diff)\n\nFor any branch that shows \"not merged\", check whether the branch's\nchanges are already in main. The correct method is:\n\n1. Find the files the branch actually changed (relative to merge-base).\n2. For each changed file, compare the branch version with main.\n   If all files are identical, the branch was squash-merged.\n\n**⚠️ Do NOT use `git diff origin/main <branch>`** — that compares the\ntwo tips directly, so commits added to main *after* the branch diverged\nwill show up as false differences.\n\n```bash\nBRANCH=\"<branch>\"\nBASE=$(git merge-base origin/main \"$BRANCH\")\n\n# List files the branch touched\nFILES=$(git diff --name-only \"$BASE\" \"$BRANCH\")\n\n# Compare each file between branch and current main\nfor f in $FILES; do\n  d=$(git diff \"$BRANCH\" origin/main -- \"$f\" | wc -l)\n  if [ \"$d\" != \"0\" ]; then\n    echo \"❌ $f — differs\"\n  else\n    echo \"✅ $f — identical in main\"\n  fi\ndone\n# All ✅ = squash-merged\n```\n\n### 4. (Optional) Check for associated tmux sessions\n\nOnly run this if `tmux` is available and relevant (e.g. worktrees were\ncreated by codex-worker or similar tooling). Skip if not applicable.\n\n```bash\ntmux ls 2>/dev/null | grep -E 'codex-worker|<other-pattern>' || true\n```\n\n### 5. Present results\n\n**Always present results as a Markdown table.** Every worktree must appear\nas a row. Never use abbreviated or prose-only summaries.\n\n| Worktree | Branch | Dirty | Merged | Can clean? |\n|---|---|---|---|---|\n| `example-wt` | `feat-foo` | ✅ clean | ✅ squash-merged | ✅ |\n| `another-wt` | `fix-bar` | ⚠️ 3 files | ❌ not merged | ❌ dirty + not merged |\n| `detached-wt` | (detached) | ⚠️ 14 files | n/a | ❌ has uncommitted changes |\n\nColumn definitions:\n\n- **Dirty**: `✅ clean` or `⚠️ N files`\n- **Merged**: `✅ merged` / `✅ squash-merged` (confirmed via content diff) / `❌ not merged` / `n/a`\n- **Can clean?**: `✅` only when merged (or squash-merged) AND clean\n\nAdd extra columns (e.g. tmux session, notes) only when relevant.\n\n### 6. Cleanup (only when asked)\n\nOnly clean worktrees the user explicitly approves. For each:\n\n```bash\nNAME=\"<worktree-name>\"\ngit worktree remove \"/path/to/$NAME\"\ngit branch -D \"<branch>\"  # only if the branch is no longer needed\n```\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1-bug-report.yml",
    "content": "name: Bug Report\ndescription: Report an issue that should be fixed\nlabels:\n  - bug\n  - needs triage\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for submitting a bug report! It helps make Kimi Code CLI better for everyone.\n\n        If you need help or support using Kimi Code CLI, and are not reporting a bug, please post on [kimi-cli/discussions](https://github.com/MoonshotAI/kimi-cli/discussions), where you can ask questions or engage with others on ideas for how to improve Kimi Code CLI.\n\n        Make sure you are running the latest version of Kimi Code CLI (`uv tool upgrade kimi-cli` to upgrade). The bug you are experiencing may already have been fixed.\n\n        Please try to include as much information as possible.\n\n  - type: input\n    id: version\n    attributes:\n      label: What version of Kimi Code CLI is running?\n      description: Copy the output of `kimi --version` or `/version`\n    validations:\n      required: true\n  - type: input\n    id: plan\n    attributes:\n      label: Which open platform/subscription were you using?\n      description: The one you selected when running `/login` or `/setup`\n    validations:\n      required: true\n  - type: input\n    id: model\n    attributes:\n      label: Which model were you using?\n      description: The one you can see on the bottom status line, like `kimi-k2-turbo-preview`, `kimi-for-coding`, etc.\n  - type: input\n    id: platform\n    attributes:\n      label: What platform is your computer?\n      description: |\n        For MacOS and Linux: copy the output of `uname -mprs`\n        For Windows: copy the output of `\"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { \"x64\" } else { \"x86\" })\"` in the PowerShell console\n  - type: textarea\n    id: actual\n    attributes:\n      label: What issue are you seeing?\n      description: Please include the full error messages and prompts with any private information redacted. If possible, please provide text instead of a screenshot.\n    validations:\n      required: true\n  - type: textarea\n    id: steps\n    attributes:\n      label: What steps can reproduce the bug?\n      description: Explain the bug and provide a code snippet that can reproduce it. Please include session id and context usage if applicable.\n    validations:\n      required: true\n  - type: textarea\n    id: expected\n    attributes:\n      label: What is the expected behavior?\n      description: If possible, please provide text instead of a screenshot.\n  - type: textarea\n    id: notes\n    attributes:\n      label: Additional information\n      description: Is there anything else you think we should know?\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2-feature-request.yml",
    "content": "name: Feature Request\ndescription: Propose a new feature for Kimi Code CLI\nlabels:\n  - enhancement\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Is Kimi Code CLI missing a feature that you'd like to see? Feel free to propose it here.\n\n        Before you submit a feature:\n        1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one.\n        2. The Kimi Code CLI team will try to balance the varying needs of the community when prioritizing or rejecting new features. Please understand that not all features will be accepted.\n\n  - type: textarea\n    id: feature\n    attributes:\n      label: What feature would you like to see?\n    validations:\n      required: true\n  - type: textarea\n    id: notes\n    attributes:\n      label: Additional information\n      description: Is there anything else you think we should know?\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n  - name: Questions and General Discussion\n    url: https://github.com/MoonshotAI/kimi-cli/discussions\n    about: Have questions? Welcome to open a discussion!\n"
  },
  {
    "path": ".github/actions/macos-code-sign/action.yml",
    "content": "name: macos-code-sign\ndescription: Sign and notarize macOS PyInstaller binaries\n\ninputs:\n  binary-path:\n    description: Path to the binary to sign\n    required: true\n  apple-certificate-p12:\n    description: Base64-encoded Apple signing certificate (P12)\n    required: true\n  apple-certificate-password:\n    description: Password for the signing certificate\n    required: true\n  apple-notarization-key-p8:\n    description: Base64-encoded Apple notarization key (P8)\n    required: true\n  apple-notarization-key-id:\n    description: Apple notarization key ID\n    required: true\n  apple-notarization-issuer-id:\n    description: Apple notarization issuer ID\n    required: true\n\nruns:\n  using: composite\n  steps:\n    - name: Import signing certificate\n      shell: bash\n      env:\n        APPLE_CERTIFICATE_P12: ${{ inputs.apple-certificate-p12 }}\n        APPLE_CERTIFICATE_PASSWORD: ${{ inputs.apple-certificate-password }}\n        KEYCHAIN_PASSWORD: actions\n      run: |\n        set -euo pipefail\n\n        # Decode certificate\n        cert_path=\"${RUNNER_TEMP}/certificate.p12\"\n        echo \"$APPLE_CERTIFICATE_P12\" | base64 -d > \"$cert_path\"\n\n        # Create temporary keychain\n        keychain_path=\"${RUNNER_TEMP}/signing.keychain-db\"\n        security create-keychain -p \"$KEYCHAIN_PASSWORD\" \"$keychain_path\"\n        security set-keychain-settings -lut 21600 \"$keychain_path\"\n        security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" \"$keychain_path\"\n\n        # Add to keychain search list\n        security list-keychains -d user -s \"$keychain_path\" $(security list-keychains -d user | tr -d '\"')\n        security default-keychain -s \"$keychain_path\"\n\n        # Import certificate\n        security import \"$cert_path\" -k \"$keychain_path\" -P \"$APPLE_CERTIFICATE_PASSWORD\" -T /usr/bin/codesign -T /usr/bin/security\n        security set-key-partition-list -S apple-tool:,apple: -s -k \"$KEYCHAIN_PASSWORD\" \"$keychain_path\" > /dev/null\n\n        # Find signing identity\n        IDENTITY=$(security find-identity -v -p codesigning \"$keychain_path\" | grep \"Developer ID Application\" | head -1 | sed -n 's/.*\"\\(Developer ID Application[^\"]*\\)\".*/\\1/p')\n        \n        if [[ -z \"$IDENTITY\" ]]; then\n          echo \"❌ No Developer ID Application identity found\"\n          security find-identity -v -p codesigning \"$keychain_path\"\n          exit 1\n        fi\n\n        echo \"✅ Found signing identity: $IDENTITY\"\n        echo \"APPLE_SIGNING_IDENTITY=$IDENTITY\" >> \"$GITHUB_ENV\"\n        echo \"APPLE_KEYCHAIN_PATH=$keychain_path\" >> \"$GITHUB_ENV\"\n\n        rm -f \"$cert_path\"\n\n    - name: Sign PyInstaller binary and embedded libraries\n      shell: bash\n      env:\n        BINARY_PATH: ${{ inputs.binary-path }}\n      run: |\n        set -euo pipefail\n\n        echo \"Signing PyInstaller binary: $BINARY_PATH\"\n\n        # PyInstaller onefile binaries embed libraries that get extracted at runtime.\n        # We need to unpack, sign everything, and repack.\n        \n        # First, try signing the binary directly with --deep\n        # For single-file PyInstaller executables, this should work\n        codesign --deep --force --options runtime --timestamp \\\n          --sign \"$APPLE_SIGNING_IDENTITY\" \\\n          --keychain \"$APPLE_KEYCHAIN_PATH\" \\\n          \"$BINARY_PATH\"\n\n        echo \"✅ Binary signed\"\n        codesign -dv --verbose=2 \"$BINARY_PATH\"\n\n    - name: Notarize binary\n      shell: bash\n      env:\n        BINARY_PATH: ${{ inputs.binary-path }}\n        APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }}\n        APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }}\n        APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }}\n      run: |\n        set -euo pipefail\n\n        # Save API key\n        key_path=\"${RUNNER_TEMP}/AuthKey.p8\"\n        echo \"$APPLE_NOTARIZATION_KEY_P8\" | base64 -d > \"$key_path\"\n\n        # Create zip for notarization\n        binary_name=$(basename \"$BINARY_PATH\")\n        zip_path=\"${RUNNER_TEMP}/${binary_name}.zip\"\n        ditto -c -k --keepParent \"$BINARY_PATH\" \"$zip_path\"\n\n        echo \"Submitting for notarization...\"\n        \n        # Submit and wait\n        result=$(xcrun notarytool submit \"$zip_path\" \\\n          --key \"$key_path\" \\\n          --key-id \"$APPLE_NOTARIZATION_KEY_ID\" \\\n          --issuer \"$APPLE_NOTARIZATION_ISSUER_ID\" \\\n          --wait \\\n          --timeout 10m \\\n          --output-format json 2>&1) || true\n\n        echo \"$result\"\n        \n        status=$(echo \"$result\" | grep -o '\"status\":\"[^\"]*\"' | cut -d'\"' -f4 || echo \"unknown\")\n        \n        if [[ \"$status\" == \"Accepted\" ]]; then\n          echo \"✅ Notarization successful\"\n        else\n          echo \"⚠️ Notarization status: $status\"\n          # Get detailed log\n          submission_id=$(echo \"$result\" | grep -o '\"id\":\"[^\"]*\"' | cut -d'\"' -f4 || echo \"\")\n          if [[ -n \"$submission_id\" ]]; then\n            echo \"Fetching notarization log...\"\n            xcrun notarytool log \"$submission_id\" \\\n              --key \"$key_path\" \\\n              --key-id \"$APPLE_NOTARIZATION_KEY_ID\" \\\n              --issuer \"$APPLE_NOTARIZATION_ISSUER_ID\" || true\n          fi\n          exit 1\n        fi\n\n        # Cleanup\n        rm -f \"$key_path\" \"$zip_path\"\n\n    - name: Verify signature\n      shell: bash\n      env:\n        BINARY_PATH: ${{ inputs.binary-path }}\n      run: |\n        set -euo pipefail\n        \n        echo \"Verifying signature and notarization...\"\n        codesign -dv --verbose=2 \"$BINARY_PATH\"\n        echo \"\"\n        echo \"Gatekeeper check:\"\n        spctl -a -vv \"$BINARY_PATH\" 2>&1 || true\n\n    - name: Cleanup keychain\n      if: always()\n      shell: bash\n      run: |\n        if [[ -n \"${APPLE_KEYCHAIN_PATH:-}\" && -f \"${APPLE_KEYCHAIN_PATH}\" ]]; then\n          security delete-keychain \"$APPLE_KEYCHAIN_PATH\" || true\n        fi\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n  - package-ecosystem: \"uv\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".github/pr-title-checker-config.json",
    "content": "{\n    \"LABEL\": {\n        \"name\": \"Invalid PR Title\",\n        \"color\": \"B60205\"\n    },\n    \"CHECKS\": {\n        \"regexp\": \"^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\\\(.*\\\\))?:.*\"\n    },\n    \"MESSAGES\": {\n        \"failure\": \"The PR title is invalid. Please refer to https://www.conventionalcommits.org/en/v1.0.0/ for the convention.\"\n    }\n}\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "<!--\nThank you for your contribution to Kimi Code CLI!\nPlease make sure you already discussed the feature or bugfix you are proposing in an issue with the maintainers.\nPlease understand that if you have not gotten confirmation from the maintainers, your pull request may be closed or ignored without further review due to limited bandwidth.\n\nSee https://github.com/MoonshotAI/kimi-cli/blob/main/CONTRIBUTING.md for more.\n-->\n\n## Related Issue\n\n<!-- Please link to the issue here. -->\n\nResolve #(issue_number)\n\n## Description\n\n<!-- Please describe your changes in detail. -->\n\n## Checklist\n\n- [ ] I have read the [CONTRIBUTING](https://github.com/MoonshotAI/kimi-cli/blob/main/CONTRIBUTING.md) document.\n- [ ] I have linked the related issue, if any.\n- [ ] I have added tests that prove my fix is effective or that my feature works.\n- [ ] I have run `make gen-changelog` to update the changelog.\n- [ ] I have run `make gen-docs` to update the user documentation.\n"
  },
  {
    "path": ".github/workflows/ci-docs.yml",
    "content": "name: CI (docs)\n\non:\n  pull_request:\n    paths:\n      - \".github/workflows/ci-docs.yml\"\n      - \".github/workflows/docs-pages.yml\"\n      - \"docs/**\"\n      - \"CHANGELOG.md\"\n  push:\n    branches:\n      - main\n    paths:\n      - \".github/workflows/ci-docs.yml\"\n      - \".github/workflows/docs-pages.yml\"\n      - \"docs/**\"\n      - \"CHANGELOG.md\"\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n\n      - name: Cache docs node_modules\n        uses: actions/cache@v4\n        with:\n          path: docs/node_modules\n          key: ${{ runner.os }}-docs-node-modules-${{ hashFiles('docs/package.json') }}\n          restore-keys: |\n            ${{ runner.os }}-docs-node-modules-\n\n      - name: Install docs dependencies\n        working-directory: docs\n        run: npm install --no-package-lock\n\n      - name: Build docs\n        working-directory: docs\n        run: npm run build\n"
  },
  {
    "path": ".github/workflows/ci-kimi-cli.yml",
    "content": "name: CI (kimi-cli)\n\non:\n  pull_request:\n    paths:\n      - \".github/workflows/**\"\n      - \"packages/**\"\n      - \"src/**\"\n      - \"tests/**\"\n      - \"tests_e2e/**\"\n      - \"tests_ai/**\"\n      - \"web/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n  push:\n    branches:\n      - main\n    paths:\n      - \".github/workflows/**\"\n      - \"packages/**\"\n      - \"src/**\"\n      - \"tests/**\"\n      - \"tests_e2e/**\"\n      - \"tests_ai/**\"\n      - \"web/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n\nenv:\n  NO_COLOR: \"1\"\n  TERM: dumb\n\njobs:\n  check:\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        id: setup-python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Prepare building environment\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make prepare\n\n      - name: Run checks\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make check-kimi-cli\n\n  test:\n    strategy:\n      fail-fast: false\n      matrix:\n        python-version: [\"3.12\", \"3.13\", \"3.14\"]\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python ${{ matrix.python-version }}\n        id: setup-python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python-version }}\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Prepare building environment\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make prepare\n\n      - name: Run tests\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n          PYTHONUTF8: \"1\"\n        run: make test-kimi-cli\n\n  build:\n    strategy:\n      fail-fast: true\n      matrix:\n        include:\n          - runner: ubuntu-22.04\n            target: x86_64-unknown-linux-gnu\n            binary_path: dist/onefile/kimi\n          - runner: ubuntu-22.04-arm\n            target: aarch64-unknown-linux-gnu\n            binary_path: dist/onefile/kimi\n          - runner: macos-14\n            target: aarch64-apple-darwin\n            binary_path: dist/onefile/kimi\n          - runner: windows-2022\n            target: x86_64-pc-windows-msvc\n            binary_path: dist/onefile/kimi.exe\n    runs-on: ${{ matrix.runner }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install GNU Make (Windows)\n        if: runner.os == 'Windows'\n        run: choco install make -y\n\n      - name: Set up Python 3.13\n        id: setup-python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.13\"\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Set up Node.js (web build)\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n          cache-dependency-path: web/package-lock.json\n\n      - name: Prepare building environment\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make prepare\n\n      - name: Build standalone binary\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make build-bin\n\n      - name: Smoke test binary --help\n        shell: python\n        run: |\n          import os\n          import subprocess\n\n          binary = os.path.abspath(os.environ[\"BINARY_PATH\"])\n          result = subprocess.run([binary, \"--help\"], capture_output=True, text=True, check=True)\n          if \"Kimi\" not in result.stdout:\n              raise SystemExit(\"'Kimi' not found in --help output\")\n        env:\n          BINARY_PATH: ${{ matrix.binary_path }}\n\n      - name: Upload binary artifact\n        if: success()\n        uses: actions/upload-artifact@v4\n        with:\n          name: kimi-${{ matrix.target }}\n          path: ${{ matrix.binary_path }}\n          if-no-files-found: error\n          retention-days: 7\n\n  release-validate:\n    if: github.event_name == 'pull_request'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Detect version bump\n        id: version\n        shell: python\n        run: |\n          import os\n          import subprocess\n          import tomllib\n\n          output_path = os.environ[\"GITHUB_OUTPUT\"]\n          event_name = os.environ.get(\"GITHUB_EVENT_NAME\", \"\")\n          base_ref = os.environ.get(\"GITHUB_BASE_REF\")\n          if event_name != \"pull_request\" or not base_ref:\n              with open(output_path, \"a\", encoding=\"utf-8\") as output:\n                  output.write(\"bump=false\\n\")\n              raise SystemExit(0)\n\n          subprocess.run([\"git\", \"fetch\", \"origin\", base_ref, \"--depth=1\"], check=True)\n          base_blob = subprocess.check_output(\n              [\"git\", \"show\", f\"origin/{base_ref}:pyproject.toml\"],\n          )\n          base_version = tomllib.loads(base_blob.decode())[\"project\"][\"version\"]\n          with open(\"pyproject.toml\", \"rb\") as handle:\n              head_version = tomllib.load(handle)[\"project\"][\"version\"]\n          bumped = base_version != head_version\n\n          with open(output_path, \"a\", encoding=\"utf-8\") as output:\n              output.write(f\"bump={'true' if bumped else 'false'}\\n\")\n              output.write(f\"base_version={base_version}\\n\")\n              output.write(f\"head_version={head_version}\\n\")\n\n      - name: Show version bump info\n        if: steps.version.outputs.bump == 'true'\n        run: |\n          echo \"version bump: ${{ steps.version.outputs.base_version }} -> ${{ steps.version.outputs.head_version }}\"\n\n      - name: Check dependency versions\n        if: steps.version.outputs.bump == 'true'\n        run: |\n          python scripts/check_kimi_dependency_versions.py \\\n            --root-pyproject pyproject.toml \\\n            --kosong-pyproject packages/kosong/pyproject.toml \\\n            --pykaos-pyproject packages/kaos/pyproject.toml\n\n      - name: Check kimi-code version alignment\n        if: steps.version.outputs.bump == 'true'\n        run: |\n          python scripts/check_version_tag.py \\\n            --pyproject packages/kimi-code/pyproject.toml \\\n            --expected-version \"${{ steps.version.outputs.head_version }}\"\n\n      - name: Check kimi-code dependency pin\n        if: steps.version.outputs.bump == 'true'\n        shell: python\n        run: |\n          import tomllib\n          from pathlib import Path\n\n          expected_version = \"${{ steps.version.outputs.head_version }}\"\n          data = tomllib.loads(Path(\"packages/kimi-code/pyproject.toml\").read_text())\n          deps = data[\"project\"][\"dependencies\"]\n          expected = f\"kimi-cli=={expected_version}\"\n          if expected not in deps:\n              raise SystemExit(\n                  \"kimi-code must depend on \"\n                  f\"{expected}, got: {deps}\"\n              )\n\n  nix-test:\n    strategy:\n      fail-fast: true\n      matrix:\n        include:\n          - runner: ubuntu-22.04\n            target: x86_64-unknown-linux-gnu\n            binary_path: dist/kimi\n          - runner: ubuntu-22.04-arm\n            target: aarch64-unknown-linux-gnu\n            binary_path: dist/kimi\n          - runner: macos-14\n            target: aarch64-apple-darwin\n            binary_path: dist/kimi\n    runs-on: ${{ matrix.runner }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install Nix\n        uses: DeterminateSystems/nix-installer-action@main\n\n      - name: Run nix package\n        run: nix run .#kimi-cli -- --version && nix run . -- --help\n"
  },
  {
    "path": ".github/workflows/ci-kimi-sdk.yml",
    "content": "name: CI (kimi-sdk)\n\non:\n  pull_request:\n    paths:\n      - \".github/workflows/ci-kimi-sdk.yml\"\n      - \"sdks/kimi-sdk/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n  push:\n    branches:\n      - main\n    paths:\n      - \".github/workflows/ci-kimi-sdk.yml\"\n      - \"sdks/kimi-sdk/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.12\", \"3.13\", \"3.14\"]\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python ${{ matrix.python-version }}\n        id: setup-python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python-version }}\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Install dependencies\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: uv sync --frozen --all-extras --project sdks/kimi-sdk\n\n      - name: Run checks\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make check-kimi-sdk\n\n      - name: Run tests\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make test-kimi-sdk\n\n  docs:\n    runs-on: ubuntu-latest\n    env:\n      FOOTER_VERSION: ${{ github.ref_name }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Install dependencies\n        run: uv sync --frozen --all-extras --project sdks/kimi-sdk\n\n      - name: Generate API documentation\n        run: |\n          uv run --project sdks/kimi-sdk pdoc kimi_sdk \\\n            --docformat google \\\n            --footer-text \"kimi-sdk ${FOOTER_VERSION}\" \\\n            -o sdks/kimi-sdk/docs\n\n      - name: Upload docs preview\n        uses: actions/upload-artifact@v4\n        with:\n          name: docs-preview\n          path: sdks/kimi-sdk/docs\n"
  },
  {
    "path": ".github/workflows/ci-kosong.yml",
    "content": "name: CI (kosong)\n\non:\n  pull_request:\n    paths:\n      - \".github/workflows/ci-kosong.yml\"\n      - \"packages/kosong/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n  push:\n    branches:\n      - main\n    paths:\n      - \".github/workflows/ci-kosong.yml\"\n      - \"packages/kosong/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.12\", \"3.13\", \"3.14\"]\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python ${{ matrix.python-version }}\n        id: setup-python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python-version }}\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Install dependencies\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: uv sync --frozen --all-extras --project packages/kosong\n\n      - name: Run checks\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make check-kosong\n\n      - name: Run tests\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make test-kosong\n\n  docs:\n    runs-on: ubuntu-latest\n    env:\n      FOOTER_VERSION: ${{ github.ref_name }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Install dependencies\n        run: uv sync --frozen --all-extras --project packages/kosong\n\n      - name: Generate API documentation\n        run: |\n          uv run --project packages/kosong pdoc kosong \\\n            --docformat google \\\n            --footer-text \"kosong ${FOOTER_VERSION}\" \\\n            -o packages/kosong/docs\n\n      - name: Upload docs preview\n        uses: actions/upload-artifact@v4\n        with:\n          name: docs-preview\n          path: packages/kosong/docs\n"
  },
  {
    "path": ".github/workflows/ci-pykaos.yml",
    "content": "name: CI (pykaos)\n\non:\n  pull_request:\n    paths:\n      - \".github/workflows/ci-pykaos.yml\"\n      - \"packages/kaos/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n  push:\n    branches:\n      - main\n    paths:\n      - \".github/workflows/ci-pykaos.yml\"\n      - \"packages/kaos/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n\njobs:\n  test:\n    name: ${{ matrix.target }} python-${{ matrix.python-version }}\n    runs-on: ${{ matrix.runner }}\n    strategy:\n      fail-fast: false\n      matrix:\n        runner: [ubuntu-22.04, macos-15-intel, macos-14, windows-2022]\n        python-version: [\"3.12\", \"3.13\", \"3.14\"]\n        include:\n          - runner: ubuntu-22.04\n            target: x86_64-unknown-linux-gnu\n          - runner: macos-15-intel\n            target: x86_64-apple-darwin\n          - runner: macos-14\n            target: aarch64-apple-darwin\n          - runner: windows-2022\n            target: x86_64-pc-windows-msvc\n    defaults:\n      run:\n        shell: bash\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install GNU Make (Windows)\n        if: runner.os == 'Windows'\n        shell: powershell\n        run: choco install make -y\n\n      - name: Set up Python ${{ matrix.python-version }}\n        id: setup-python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python-version }}\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n          enable-cache: true\n          cache-dependency-glob: uv.lock\n\n      - name: Install dependencies\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: uv sync --frozen --all-extras --project packages/kaos\n\n      - name: Configure local SSH server\n        if: matrix.runner == 'ubuntu-22.04'\n        run: |\n          set -euxo pipefail\n          sudo apt-get update\n          sudo apt-get install -y openssh-server\n          sudo mkdir -p /run/sshd\n          sudo sed -i 's/^#\\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config\n          sudo sed -i 's/^#\\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config\n          sudo sed -i 's@^#\\?AuthorizedKeysFile .*@AuthorizedKeysFile .ssh/authorized_keys@' /etc/ssh/sshd_config\n          sudo service ssh restart || sudo service ssh start\n          mkdir -p \"$HOME/.ssh\"\n          chmod 700 \"$HOME/.ssh\"\n          ssh-keygen -q -t ed25519 -f \"$HOME/.ssh/id_ci\" -N \"\"\n          cat \"$HOME/.ssh/id_ci.pub\" >> \"$HOME/.ssh/authorized_keys\"\n          chmod 600 \"$HOME/.ssh/authorized_keys\"\n          ssh-keyscan -H 127.0.0.1 >> \"$HOME/.ssh/known_hosts\"\n          ssh-keyscan -H localhost >> \"$HOME/.ssh/known_hosts\"\n          echo \"KAOS_SSH_HOST=127.0.0.1\" >> \"$GITHUB_ENV\"\n          echo \"KAOS_SSH_PORT=22\" >> \"$GITHUB_ENV\"\n          echo \"KAOS_SSH_USERNAME=$USER\" >> \"$GITHUB_ENV\"\n          echo \"KAOS_SSH_KEY_PATHS=$HOME/.ssh/id_ci\" >> \"$GITHUB_ENV\"\n\n      - name: Verify SSH connectivity\n        if: matrix.runner == 'ubuntu-22.04'\n        run: ssh -i \"$HOME/.ssh/id_ci\" -o BatchMode=yes -o StrictHostKeyChecking=yes \"$USER@127.0.0.1\" true\n\n      - name: Run checks\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make check-pykaos\n\n      - name: Run tests\n        env:\n          UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}\n        run: make test-pykaos\n"
  },
  {
    "path": ".github/workflows/docs-pages.yml",
    "content": "name: Docs (GitHub Pages)\n\non:\n  push:\n    branches:\n      - main\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\njobs:\n  deploy:\n    # Only run on the original repository, not on forks\n    if: github.repository == 'MoonshotAI/kimi-cli'\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n      url: ${{ steps.deploy.outputs.page_url }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n\n      - name: Cache docs node_modules\n        uses: actions/cache@v4\n        with:\n          path: docs/node_modules\n          key: ${{ runner.os }}-docs-node-modules-${{ hashFiles('docs/package.json') }}\n          restore-keys: |\n            ${{ runner.os }}-docs-node-modules-\n\n      - name: Configure GitHub Pages\n        id: pages\n        uses: actions/configure-pages@v5\n\n      - name: Set VitePress base\n        shell: bash\n        env:\n          BASE_PATH: ${{ steps.pages.outputs.base_path }}\n        run: |\n          set -euo pipefail\n          if [[ -z \"${BASE_PATH}\" ]]; then\n            base=\"/\"\n          else\n            base=\"${BASE_PATH%/}/\"\n          fi\n          echo \"VITEPRESS_BASE=${base}\" >> \"$GITHUB_ENV\"\n\n      - name: Install docs dependencies\n        working-directory: docs\n        run: npm install --no-package-lock\n\n      - name: Build docs\n        working-directory: docs\n        env:\n          VITEPRESS_BASE: ${{ env.VITEPRESS_BASE }}\n        run: npm run build\n\n      - name: Add .nojekyll\n        run: touch docs/.vitepress/dist/.nojekyll\n\n      - name: Upload Pages artifact\n        uses: actions/upload-pages-artifact@v3\n        with:\n          path: docs/.vitepress/dist\n\n      - name: Deploy to GitHub Pages\n        id: deploy\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/pr-title-checker.yml",
    "content": "name: PR Title Checker\n\non:\n  pull_request:\n    types: [opened, edited, labeled]\n\njobs:\n  check:\n    runs-on: ubuntu-latest\n    name: pr-title-checker\n    steps:\n      - uses: thehanimo/pr-title-checker@v1.4.3\n        with:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          configuration_path: \".github/pr-title-checker-config.json\"\n"
  },
  {
    "path": ".github/workflows/release-kimi-cli.yml",
    "content": "name: Release (kimi-cli)\n\non:\n  push:\n    tags:\n      - \"[0-9]*\"\n\npermissions:\n  contents: write\n\njobs:\n  validate:\n    name: Validate tag\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Check version tag\n        run: |\n          python scripts/check_version_tag.py \\\n            --pyproject pyproject.toml \\\n            --expected-version \"${GITHUB_REF_NAME}\"\n\n      - name: Check kimi-code version tag\n        run: |\n          python scripts/check_version_tag.py \\\n            --pyproject packages/kimi-code/pyproject.toml \\\n            --expected-version \"${GITHUB_REF_NAME}\"\n\n      - name: Check dependency versions\n        run: |\n          python scripts/check_kimi_dependency_versions.py \\\n            --root-pyproject pyproject.toml \\\n            --kosong-pyproject packages/kosong/pyproject.toml \\\n            --pykaos-pyproject packages/kaos/pyproject.toml\n\n  build:\n    name: Build binaries (${{ matrix.target }})\n    needs: validate\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - runner: ubuntu-22.04\n            target: x86_64-unknown-linux-gnu\n          - runner: ubuntu-22.04-arm\n            target: aarch64-unknown-linux-gnu\n          - runner: macos-14\n            target: aarch64-apple-darwin\n          - runner: windows-2022\n            target: x86_64-pc-windows-msvc\n          - runner: windows-11-arm\n            target: aarch64-pc-windows-msvc\n    runs-on: ${{ matrix.runner }}\n    env:\n      KIMI_WEB_STRICT_VERSION: \"1\"\n      KIMI_WEB_EXPECT_VERSION: ${{ github.ref_name }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install GNU Make (Windows)\n        if: runner.os == 'Windows'\n        run: choco install make -y\n\n      - name: Set up Rust\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n\n      - name: Set up Node.js (web build)\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n          cache-dependency-path: web/package-lock.json\n\n      - name: Prepare building environment\n        run: make prepare-build\n\n      # macOS: Setup signing certificate before build\n      - name: Setup macOS signing certificate\n        if: runner.os == 'macOS'\n        env:\n          APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          KEYCHAIN_PASSWORD: actions\n        run: |\n          set -euo pipefail\n\n          # Decode certificate\n          cert_path=\"${RUNNER_TEMP}/certificate.p12\"\n          echo \"$APPLE_CERTIFICATE_P12\" | base64 -d > \"$cert_path\"\n\n          # Create temporary keychain\n          keychain_path=\"${RUNNER_TEMP}/signing.keychain-db\"\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" \"$keychain_path\"\n          security set-keychain-settings -lut 21600 \"$keychain_path\"\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" \"$keychain_path\"\n\n          # Add to keychain search list\n          security list-keychains -d user -s \"$keychain_path\" $(security list-keychains -d user | tr -d '\"')\n          security default-keychain -s \"$keychain_path\"\n\n          # Import certificate\n          security import \"$cert_path\" -k \"$keychain_path\" -P \"$APPLE_CERTIFICATE_PASSWORD\" -T /usr/bin/codesign -T /usr/bin/security\n          security set-key-partition-list -S apple-tool:,apple: -s -k \"$KEYCHAIN_PASSWORD\" \"$keychain_path\" > /dev/null\n\n          # Find signing identity\n          IDENTITY=$(security find-identity -v -p codesigning \"$keychain_path\" | grep \"Developer ID Application\" | head -1 | sed -n 's/.*\"\\(Developer ID Application[^\"]*\\)\".*/\\1/p')\n          \n          if [[ -z \"$IDENTITY\" ]]; then\n            echo \"❌ No Developer ID Application identity found\"\n            security find-identity -v -p codesigning \"$keychain_path\"\n            exit 1\n          fi\n\n          echo \"✅ Found signing identity: $IDENTITY\"\n          echo \"APPLE_SIGNING_IDENTITY=$IDENTITY\" >> \"$GITHUB_ENV\"\n          echo \"APPLE_KEYCHAIN_PATH=$keychain_path\" >> \"$GITHUB_ENV\"\n\n          rm -f \"$cert_path\"\n\n      # Build onefile and onedir versions (all platforms)\n      - name: Build standalone binary (onefile)\n        run: make build-bin\n\n      - name: Build standalone binary (onedir)\n        env:\n          PYINSTALLER_ONEDIR: \"1\"\n        run: make build-bin-onedir\n\n      # macOS: Sign onefile binary\n      - name: Sign macOS onefile binary\n        if: runner.os == 'macOS'\n        run: |\n          set -euo pipefail\n          echo \"Signing onefile binary...\"\n          codesign --deep --force --options runtime --timestamp \\\n            --sign \"$APPLE_SIGNING_IDENTITY\" \\\n            --keychain \"$APPLE_KEYCHAIN_PATH\" \\\n            dist/onefile/kimi\n          echo \"✅ Onefile binary signed\"\n          codesign -dv --verbose=2 dist/onefile/kimi\n\n      # macOS: Sign onedir binaries (all dylibs and executables)\n      - name: Sign macOS onedir binaries\n        if: runner.os == 'macOS'\n        run: |\n          set -euo pipefail\n          echo \"Signing onedir binaries...\"\n          \n          # 1. Sign all dylibs and so files first (excluding those inside frameworks)\n          find dist/onedir/kimi -type f \\( -name \"*.dylib\" -o -name \"*.so\" \\) ! -path \"*.framework/*\" | while read -r lib; do\n            echo \"Signing: $lib\"\n            codesign --force --options runtime --timestamp \\\n              --sign \"$APPLE_SIGNING_IDENTITY\" \\\n              --keychain \"$APPLE_KEYCHAIN_PATH\" \\\n              \"$lib\"\n          done\n          \n          # 2. Sign all frameworks with --deep (important for Python.framework)\n          find dist/onedir/kimi -type d -name \"*.framework\" | while read -r framework; do\n            echo \"Signing framework: $framework\"\n            codesign --deep --force --options runtime --timestamp \\\n              --sign \"$APPLE_SIGNING_IDENTITY\" \\\n              --keychain \"$APPLE_KEYCHAIN_PATH\" \\\n              \"$framework\"\n          done\n          \n          # 3. Sign the main executable last\n          echo \"Signing main executable: dist/onedir/kimi/kimi\"\n          codesign --force --options runtime --timestamp \\\n            --sign \"$APPLE_SIGNING_IDENTITY\" \\\n            --keychain \"$APPLE_KEYCHAIN_PATH\" \\\n            dist/onedir/kimi/kimi\n          \n          echo \"✅ Onedir binaries signed\"\n          codesign -dv --verbose=2 dist/onedir/kimi/kimi\n          codesign --verify --deep --strict dist/onedir/kimi/kimi && echo \"✅ Deep verification passed\"\n\n      # macOS: Notarize onefile binary\n      - name: Notarize macOS onefile binary\n        if: runner.os == 'macOS'\n        env:\n          APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}\n          APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}\n          APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}\n        run: |\n          set -euo pipefail\n\n          # Save API key\n          key_path=\"${RUNNER_TEMP}/AuthKey.p8\"\n          echo \"$APPLE_NOTARIZATION_KEY_P8\" | base64 -d > \"$key_path\"\n\n          # Create zip for notarization (use --norsrc to avoid ._ AppleDouble files)\n          zip_path=\"${RUNNER_TEMP}/kimi-onefile.zip\"\n          ditto -c -k --norsrc --keepParent dist/onefile/kimi \"$zip_path\"\n\n          echo \"Submitting onefile for notarization...\"\n          \n          # Submit and capture output for status verification\n          xcrun notarytool submit \"$zip_path\" \\\n            --key \"$key_path\" \\\n            --key-id \"$APPLE_NOTARIZATION_KEY_ID\" \\\n            --issuer \"$APPLE_NOTARIZATION_ISSUER_ID\" \\\n            --wait \\\n            --timeout 15m \\\n            2>&1 | tee /tmp/notarize-onefile.log\n\n          # Verify notarization was accepted\n          if ! grep -q \"status: Accepted\" /tmp/notarize-onefile.log; then\n            echo \"❌ Onefile notarization failed!\"\n            cat /tmp/notarize-onefile.log\n            \n            # Get detailed error log from Apple\n            submission_id=$(grep \"id:\" /tmp/notarize-onefile.log | head -1 | awk '{print $2}')\n            if [[ -n \"$submission_id\" ]]; then\n              echo \"Fetching notarization log for submission: $submission_id\"\n              xcrun notarytool log \"$submission_id\" \\\n                --key \"$key_path\" \\\n                --key-id \"$APPLE_NOTARIZATION_KEY_ID\" \\\n                --issuer \"$APPLE_NOTARIZATION_ISSUER_ID\" 2>&1 || true\n            fi\n            exit 1\n          fi\n\n          echo \"✅ Onefile notarization completed and accepted\"\n\n          # Verify signature and notarization status\n          echo \"Verifying onefile signature...\"\n          codesign -dv --verbose=2 dist/onefile/kimi\n          \n          echo \"Verifying onefile notarization (online check)...\"\n          spctl -a -vvv -t install dist/onefile/kimi\n\n          # Cleanup\n          rm -f \"$zip_path\" /tmp/notarize-onefile.log\n\n      # macOS: Notarize onedir binaries\n      - name: Notarize macOS onedir binaries\n        if: runner.os == 'macOS'\n        env:\n          APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}\n          APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}\n          APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}\n        run: |\n          set -euo pipefail\n\n          # Save API key (might already exist from previous step)\n          key_path=\"${RUNNER_TEMP}/AuthKey.p8\"\n          if [[ ! -f \"$key_path\" ]]; then\n            echo \"$APPLE_NOTARIZATION_KEY_P8\" | base64 -d > \"$key_path\"\n          fi\n\n          # Create zip for notarization (use --norsrc to avoid ._ AppleDouble files)\n          zip_path=\"${RUNNER_TEMP}/kimi-onedir.zip\"\n          ditto -c -k --norsrc --keepParent dist/onedir/kimi \"$zip_path\"\n\n          echo \"Submitting onedir for notarization...\"\n          \n          # Submit and capture output for status verification\n          xcrun notarytool submit \"$zip_path\" \\\n            --key \"$key_path\" \\\n            --key-id \"$APPLE_NOTARIZATION_KEY_ID\" \\\n            --issuer \"$APPLE_NOTARIZATION_ISSUER_ID\" \\\n            --wait \\\n            --timeout 15m \\\n            2>&1 | tee /tmp/notarize-onedir.log\n\n          # Verify notarization was accepted\n          if ! grep -q \"status: Accepted\" /tmp/notarize-onedir.log; then\n            echo \"❌ Onedir notarization failed!\"\n            cat /tmp/notarize-onedir.log\n            \n            # Get detailed error log from Apple\n            submission_id=$(grep \"id:\" /tmp/notarize-onedir.log | head -1 | awk '{print $2}')\n            if [[ -n \"$submission_id\" ]]; then\n              echo \"Fetching notarization log for submission: $submission_id\"\n              xcrun notarytool log \"$submission_id\" \\\n                --key \"$key_path\" \\\n                --key-id \"$APPLE_NOTARIZATION_KEY_ID\" \\\n                --issuer \"$APPLE_NOTARIZATION_ISSUER_ID\" 2>&1 || true\n            fi\n            exit 1\n          fi\n\n          echo \"✅ Onedir notarization completed and accepted\"\n\n          # Verify signature and notarization status\n          echo \"Verifying onedir signature...\"\n          codesign -dv --verbose=2 dist/onedir/kimi/kimi\n          \n          echo \"Verifying onedir notarization (online check)...\"\n          spctl -a -vvv -t install dist/onedir/kimi/kimi\n\n          # Cleanup\n          rm -f \"$key_path\" \"$zip_path\" /tmp/notarize-onedir.log\n\n      # macOS: Cleanup keychain\n      - name: Cleanup macOS keychain\n        if: always() && runner.os == 'macOS'\n        run: |\n          if [[ -n \"${APPLE_KEYCHAIN_PATH:-}\" && -f \"${APPLE_KEYCHAIN_PATH}\" ]]; then\n            security delete-keychain \"$APPLE_KEYCHAIN_PATH\" || true\n          fi\n\n      # Package onefile artifact (all platforms)\n      - name: Package onefile artifact\n        shell: python\n        env:\n          TAG: ${{ github.ref_name }}\n          TARGET: ${{ matrix.target }}\n        run: |\n          import os\n          import pathlib\n          import tarfile\n          import zipfile\n\n          tag = os.environ[\"TAG\"]\n          target = os.environ[\"TARGET\"]\n\n          dist_dir = pathlib.Path(\"dist\")\n          artifacts_dir = pathlib.Path(\"artifacts\")\n          artifacts_dir.mkdir(parents=True, exist_ok=True)\n\n          is_windows = \"windows\" in target\n          is_macos = \"apple-darwin\" in target\n          binary_name = \"kimi.exe\" if is_windows else \"kimi\"\n          binary_path = dist_dir / \"onefile\" / binary_name\n          if not binary_path.exists():\n              raise SystemExit(f\"Binary not found at {binary_path}\")\n\n          # Determine archive format and name\n          # - Windows: .zip\n          # - macOS: .tar.gz\n          # - Linux: .tar.gz\n          if is_windows:\n              archive_name = f\"kimi-{tag}-{target}.zip\"\n              archive_path = artifacts_dir / archive_name\n              with zipfile.ZipFile(archive_path, \"w\", compression=zipfile.ZIP_DEFLATED) as archive_file:\n                  archive_file.write(binary_path, arcname=binary_name)\n          else:\n              archive_name = f\"kimi-{tag}-{target}.tar.gz\"\n              archive_path = artifacts_dir / archive_name\n              with tarfile.open(archive_path, \"w:gz\") as archive_file:\n                  archive_file.add(binary_path, arcname=\"kimi\")\n\n          print(f\"Built onefile artifact: {archive_path}\")\n\n      # Package onedir artifact (all platforms)\n      - name: Package onedir artifact\n        shell: python\n        env:\n          TAG: ${{ github.ref_name }}\n          TARGET: ${{ matrix.target }}\n        run: |\n          import os\n          import pathlib\n          import tarfile\n          import zipfile\n\n          tag = os.environ[\"TAG\"]\n          target = os.environ[\"TARGET\"]\n\n          dist_dir = pathlib.Path(\"dist\")\n          artifacts_dir = pathlib.Path(\"artifacts\")\n          artifacts_dir.mkdir(parents=True, exist_ok=True)\n\n          is_windows = \"windows\" in target\n\n          # Windows: onedir is dist/onedir/kimi with kimi.exe inside\n          # Others: onedir is dist/onedir/kimi with kimi inside\n          onedir_path = dist_dir / \"onedir\" / \"kimi\"\n          if not onedir_path.exists() or not onedir_path.is_dir():\n              raise SystemExit(f\"Onedir directory not found at {onedir_path}\")\n\n          if is_windows:\n              archive_name = f\"kimi-{tag}-{target}-onedir.zip\"\n              archive_path = artifacts_dir / archive_name\n              with zipfile.ZipFile(archive_path, \"w\", compression=zipfile.ZIP_DEFLATED) as archive_file:\n                  # Add the directory contents with kimi/ as the root\n                  for item in onedir_path.rglob(\"*\"):\n                      if item.is_file():\n                          arcname = f\"kimi/{item.relative_to(onedir_path)}\"\n                          archive_file.write(item, arcname=arcname)\n          else:\n              archive_name = f\"kimi-{tag}-{target}-onedir.tar.gz\"\n              archive_path = artifacts_dir / archive_name\n              with tarfile.open(archive_path, \"w:gz\") as archive_file:\n                  # Add the directory contents with kimi/ as the root\n                  for item in onedir_path.iterdir():\n                      archive_file.add(item, arcname=f\"kimi/{item.name}\")\n\n          print(f\"Built onedir artifact: {archive_path}\")\n\n      - name: Set artifact name\n        id: artifact\n        shell: python\n        run: |\n          import os\n          ref = os.environ[\"GITHUB_REF_NAME\"].replace(\"/\", \"-\")\n          target = \"${{ matrix.target }}\"\n          with open(os.environ[\"GITHUB_OUTPUT\"], \"a\") as f:\n              f.write(f\"name=kimi-{ref}-{target}\\n\")\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ steps.artifact.outputs.name }}\n          path: artifacts/*\n          if-no-files-found: error\n          retention-days: 7\n\n  release:\n    name: Publish GitHub Release\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - name: Download all build artifacts\n        uses: actions/download-artifact@v4\n        with:\n          path: ./downloads\n          merge-multiple: true\n\n      - name: Show downloaded files\n        run: ls -laR downloads\n\n      - name: Generate per-file SHA256 sums\n        shell: bash\n        run: |\n          set -euxo pipefail\n          cd downloads\n          shopt -s nullglob\n          for f in *.tar.gz *.zip; do\n            sha256sum \"$f\" > \"$f.sha256\"\n            echo \"sha256($(basename \"$f\"))=$(cut -d' ' -f1 \"$f.sha256\")\"\n          done\n          ls -la\n\n      - name: Create GitHub Release and upload assets\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: ${{ github.ref_name }}\n          name: ${{ github.ref_name }}\n          generate_release_notes: true\n          files: |\n            downloads/*.tar.gz\n            downloads/*.zip\n            downloads/*.tar.gz.sha256\n            downloads/*.zip.sha256\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  publish-python:\n    name: Publish Python package\n    needs: validate\n    runs-on: ubuntu-latest\n    env:\n      KIMI_WEB_STRICT_VERSION: \"1\"\n      KIMI_WEB_EXPECT_VERSION: ${{ github.ref_name }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n\n      - name: Set up Node.js (web build)\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n          cache-dependency-path: web/package-lock.json\n\n      - name: Build distributions\n        run: make build-kimi-cli\n\n      - name: Publish to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          user: __token__\n          password: ${{ secrets.PYPI_API_TOKEN }}\n          packages-dir: dist\n"
  },
  {
    "path": ".github/workflows/release-kimi-sdk.yml",
    "content": "name: Release (kimi-sdk)\n\non:\n  push:\n    tags:\n      - \"kimi-sdk-*\"\n\npermissions:\n  contents: read\n\njobs:\n  validate:\n    name: Validate tag\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Check version tag\n        run: |\n          python scripts/check_version_tag.py \\\n            --pyproject sdks/kimi-sdk/pyproject.toml \\\n            --expected-version \"${GITHUB_REF_NAME#kimi-sdk-}\"\n\n  publish:\n    runs-on: ubuntu-latest\n    needs: validate\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n\n      - name: Build distributions\n        run: make build-kimi-sdk\n\n      - name: Publish to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          user: __token__\n          password: ${{ secrets.PYPI_API_TOKEN }}\n          packages-dir: dist/kimi-sdk\n"
  },
  {
    "path": ".github/workflows/release-kosong.yml",
    "content": "name: Release (kosong)\n\non:\n  push:\n    tags:\n      - \"kosong-*\"\n\npermissions:\n  contents: read\n\njobs:\n  validate:\n    name: Validate tag\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Check version tag\n        run: |\n          python scripts/check_version_tag.py \\\n            --pyproject packages/kosong/pyproject.toml \\\n            --expected-version \"${GITHUB_REF_NAME#kosong-}\"\n\n  publish:\n    runs-on: ubuntu-latest\n    needs: validate\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n\n      - name: Build distributions\n        run: make build-kosong\n\n      - name: Publish to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          user: __token__\n          password: ${{ secrets.PYPI_API_TOKEN }}\n          packages-dir: dist/kosong\n\n  docs:\n    runs-on: ubuntu-latest\n    needs: validate\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n\n      - name: Install dependencies\n        run: uv sync --frozen --all-extras --project packages/kosong\n\n      - name: Generate API documentation\n        run: |\n          VERSION=\"${GITHUB_REF_NAME#kosong-}\"\n          uv run --project packages/kosong pdoc kosong \\\n            --docformat google \\\n            --footer-text \"kosong ${VERSION}\" \\\n            -o packages/kosong/docs\n\n      - name: Disable Jekyll processing\n        run: touch packages/kosong/docs/.nojekyll\n\n      - name: Publish docs to gh-pages\n        env:\n          KOSONG_PAGES_TOKEN: ${{ secrets.KOSONG_PAGES_TOKEN }}\n        run: |\n          set -euo pipefail\n          VERSION=\"${GITHUB_REF_NAME#kosong-}\"\n          PAGES_REPO=\"https://x-access-token:${KOSONG_PAGES_TOKEN}@github.com/MoonshotAI/kosong.git\"\n          PAGES_DIR=\"${RUNNER_TEMP}/kosong-gh-pages\"\n\n          git config --global user.name \"github-actions[bot]\"\n          git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n\n          rm -rf \"$PAGES_DIR\"\n          git clone --depth 1 --branch gh-pages \"$PAGES_REPO\" \"$PAGES_DIR\"\n          rsync -a --delete --exclude '.git' \"packages/kosong/docs/\" \"$PAGES_DIR/\"\n\n          git -C \"$PAGES_DIR\" add -A\n          if git -C \"$PAGES_DIR\" diff --cached --quiet; then\n            echo \"No documentation changes to publish.\"\n            exit 0\n          fi\n\n          git -C \"$PAGES_DIR\" commit -m \"docs: update for ${VERSION}\"\n          git -C \"$PAGES_DIR\" push origin gh-pages\n"
  },
  {
    "path": ".github/workflows/release-pykaos.yml",
    "content": "name: Release (pykaos)\n\non:\n  push:\n    tags:\n      - \"pykaos-*\"\n\npermissions:\n  contents: read\n\njobs:\n  validate:\n    name: Validate tag\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Check version tag\n        run: |\n          python scripts/check_version_tag.py \\\n            --pyproject packages/kaos/pyproject.toml \\\n            --expected-version \"${GITHUB_REF_NAME#pykaos-}\"\n\n  publish:\n    runs-on: ubuntu-latest\n    needs: validate\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python 3.14\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.14\"\n          allow-prereleases: true\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v1\n        with:\n          version: \"0.8.5\"\n\n      - name: Build distributions\n        run: make build-pykaos\n\n      - name: Publish to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          user: __token__\n          password: ${{ secrets.PYPI_API_TOKEN }}\n          packages-dir: dist/pykaos\n"
  },
  {
    "path": ".github/workflows/translator.yml",
    "content": "name: \"Translator\"\non:\n  issues:\n    types: [opened, edited]\n  issue_comment:\n    types: [created, edited]\n  discussion:\n    types: [created, edited]\n  discussion_comment:\n    types: [created, edited]\n  pull_request_target:\n    types: [opened, edited]\n  pull_request_review_comment:\n    types: [created, edited]\n\njobs:\n  translate:\n    permissions:\n      issues: write\n      discussions: write\n      pull-requests: write\n    runs-on: ubuntu-latest\n    steps:\n      - uses: lizheming/github-translate-action@1.1.2\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          IS_MODIFY_TITLE: true\n          APPEND_TRANSLATION: true\n"
  },
  {
    "path": ".github/workflows/typos.yml",
    "content": "name: Typo checker\non: [pull_request]\n\njobs:\n  run:\n    name: Spell Check with Typos\n    runs-on: ubuntu-latest\n    steps:\n    - name: Checkout Actions Repository\n      uses: actions/checkout@v4\n\n    - name: Check spelling of the entire repository\n      uses: crate-ci/typos@v1.38.1\n"
  },
  {
    "path": ".gitignore",
    "content": "# Python-generated files\n__pycache__/\n*.py[oc]\nbuild/\ndist/\nwheels/\n*.egg-info\n\n# Virtual environments\n.venv\n\n# Project files\n.vscode\n.env\n.env.local\n/tests_local\nuv.toml\n.idea/*\n\n# Build dependencies\nsrc/kimi_cli/deps/bin\nsrc/kimi_cli/deps/tmp\n\n# Web build artifacts\nsrc/kimi_cli/web/static/assets/\n\n# Vis build artifacts\nsrc/kimi_cli/vis/static/\n\n# Generated reports\ntests_ai/report.json\n\n# nix build result\nresult\nresult-*\n\n# macOS files\n.DS_Store\n\n# Rust files\ntarget/\n\nnode_modules/\nstatic/\n.memo/\n.entire\n.claude"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "default_install_hook_types:\n  - pre-commit\n\nrepos:\n  - repo: local\n    hooks:\n      - id: make-format-kimi-cli\n        name: make format-kimi-cli\n        entry: make format-kimi-cli\n        language: system\n        pass_filenames: false\n      - id: make-check-kimi-cli\n        name: make check-kimi-cli\n        entry: make check-kimi-cli\n        language: system\n        pass_filenames: false\n"
  },
  {
    "path": ".python-version",
    "content": "3.14\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Kimi Code CLI\n\n## Quick commands (use uv)\n\n- `make prepare` (sync deps for all workspace packages and install git hooks)\n- `make format`\n- `make check`\n- `make test`\n- `make ai-test`\n- `make build` / `make build-bin`\n\nIf running tools directly, use `uv run ...`.\n\n## Project overview\n\nKimi Code CLI is a Python CLI agent for software engineering workflows. It supports an interactive\nshell UI, ACP server mode for IDE integrations, and MCP tool loading.\n\n## Tech stack\n\n- Python 3.12+ (tooling configured for 3.14)\n- CLI framework: Typer\n- Async runtime: asyncio\n- LLM framework: kosong\n- MCP integration: fastmcp\n- Logging: loguru\n- Package management/build: uv + uv_build; PyInstaller for binaries\n- Tests: pytest + pytest-asyncio; lint/format: ruff; types: pyright + ty\n\n## Architecture overview\n\n- **CLI entry**: `src/kimi_cli/cli.py` (Typer) parses flags (UI mode, agent spec, config, MCP)\n  and routes into `KimiCLI` in `src/kimi_cli/app.py`.\n- **App/runtime setup**: `KimiCLI.create` loads config (`src/kimi_cli/config.py`), chooses a\n  model/provider (`src/kimi_cli/llm.py`), builds a `Runtime` (`src/kimi_cli/soul/agent.py`),\n  loads an agent spec, restores `Context`, then constructs `KimiSoul`.\n- **Agent specs**: YAML under `src/kimi_cli/agents/` loaded by `src/kimi_cli/agentspec.py`.\n  Specs can `extend` base agents, select tools by import path, and define fixed subagents.\n  System prompts live alongside specs; builtin args include `KIMI_NOW`, `KIMI_WORK_DIR`,\n  `KIMI_WORK_DIR_LS`, `KIMI_AGENTS_MD`, `KIMI_SKILLS` (this file is injected via\n  `KIMI_AGENTS_MD`).\n- **Tooling**: `src/kimi_cli/soul/toolset.py` loads tools by import path, injects dependencies,\n  and runs tool calls. Built-in tools live in `src/kimi_cli/tools/` (shell, file, web, todo,\n  multiagent, dmail, think). MCP tools are loaded via `fastmcp`; CLI management is in\n  `src/kimi_cli/mcp.py` and stored in the share dir.\n- **Subagents**: `LaborMarket` in `src/kimi_cli/soul/agent.py` manages fixed and dynamic\n  subagents. The Task tool (`src/kimi_cli/tools/multiagent/`) spawns them.\n- **Core loop**: `src/kimi_cli/soul/kimisoul.py` is the main agent loop. It accepts user input,\n  handles slash commands (`src/kimi_cli/soul/slash.py`), appends to `Context`\n  (`src/kimi_cli/soul/context.py`), calls the LLM (kosong), runs tools, and performs compaction\n  (`src/kimi_cli/soul/compaction.py`) when needed.\n- **Approvals**: `src/kimi_cli/soul/approval.py` mediates user approvals for tool actions; the\n  soul forwards approval requests over `Wire` for UI handling.\n- **UI/Wire**: `src/kimi_cli/soul/run_soul` connects `KimiSoul` to a `Wire`\n  (`src/kimi_cli/wire/`) so UI loops can stream events. UIs live in `src/kimi_cli/ui/`\n  (shell/print/acp/wire).\n- **Shell UI**: `src/kimi_cli/ui/shell/` handles interactive TUI input, shell command mode,\n  and slash command autocomplete; it is the default interactive experience.\n- **Slash commands**: Soul-level commands live in `src/kimi_cli/soul/slash.py`; shell-level\n  commands live in `src/kimi_cli/ui/shell/slash.py`. The shell UI exposes both and dispatches\n  based on the registry. Standard skills register `/skill:<skill-name>` and load `SKILL.md`\n  as a user prompt; flow skills register `/flow:<skill-name>` and execute the embedded flow.\n\n## Major modules and interfaces\n\n- `src/kimi_cli/app.py`: `KimiCLI.create(...)` and `KimiCLI.run(...)` are the main programmatic\n  entrypoints; this is what UI layers use.\n- `src/kimi_cli/soul/agent.py`: `Runtime` (config, session, builtins), `Agent` (system prompt +\n  toolset), and `LaborMarket` (subagent registry).\n- `src/kimi_cli/soul/kimisoul.py`: `KimiSoul.run(...)` is the loop boundary; it emits Wire\n  messages and executes tools via `KimiToolset`.\n- `src/kimi_cli/soul/context.py`: conversation history + checkpoints; used by DMail for\n  checkpointed replies.\n- `src/kimi_cli/soul/toolset.py`: load tools, run tool calls, bridge to MCP tools.\n- `src/kimi_cli/ui/*`: shell/print/acp frontends; they consume `Wire` messages.\n- `src/kimi_cli/wire/*`: event types and transport used between soul and UI.\n\n## Repo map\n\n- `src/kimi_cli/agents/`: built-in agent YAML specs and prompts\n- `src/kimi_cli/prompts/`: shared prompt templates\n- `src/kimi_cli/soul/`: core runtime/loop, context, compaction, approvals\n- `src/kimi_cli/tools/`: built-in tools\n- `src/kimi_cli/ui/`: UI frontends (shell/print/acp/wire)\n- `src/kimi_cli/acp/`: ACP server components\n- `packages/kosong/`, `packages/kaos/`: workspace deps\n  + Kosong is an LLM abstraction layer designed for modern AI agent applications.\n    It unifies message structures, asynchronous tool orchestration, and pluggable\n    chat providers so you can build agents with ease and avoid vendor lock-in.\n  + PyKAOS is a lightweight Python library providing an abstraction layer for agents\n    to interact with operating systems. File operations and command executions via KAOS\n    can be easily switched between local environment and remote systems over SSH.\n- `tests/`, `tests_ai/`: test suites\n- `klips`: Kimi Code CLI Improvement Proposals\n\n## Conventions and quality\n\n- Python >=3.12 (ty config uses 3.14); line length 100.\n- Ruff handles lint + format (rules: E, F, UP, B, SIM, I); pyright + ty for type checks.\n- Tests use pytest + pytest-asyncio; files are `tests/test_*.py`.\n- CLI entry points: `kimi` / `kimi-cli` -> `src/kimi_cli/cli.py`.\n- User config: `~/.kimi/config.toml`; logs, sessions, and MCP config live in `~/.kimi/`.\n\n## Git commit messages\n\nConventional Commits format:\n\n```\n<type>(<scope>): <subject>\n```\n\nAllowed types:\n`feat`, `fix`, `test`, `refactor`, `chore`, `style`, `docs`, `perf`, `build`, `ci`, `revert`.\n\n## Versioning\n\nThe project follows a **minor-bump-only** versioning scheme (`MAJOR.MINOR.PATCH`):\n\n- **Patch** version is always `0`. Never bump it.\n- **Minor** version is bumped for any change: new features, improvements, bug fixes, etc.\n- **Major** version is only changed by explicit manual decision; it stays unchanged during\n  normal development.\n\nExamples: `0.68.0` → `0.69.0` → `0.70.0`; never `0.68.1`.\n\nThis rule applies to all packages in the repo (root, `packages/*`, `sdks/*`) as well as release\nand skill workflows.\n\n## Release workflow\n\n1. Ensure `main` is up to date (pull latest).\n2. Create a release branch, e.g. `bump-0.68` or `bump-pykaos-0.5.3`.\n3. Update `CHANGELOG.md`: rename `[Unreleased]` to `[0.68] - YYYY-MM-DD`.\n4. Update `pyproject.toml` version.\n5. Run `uv sync` to align `uv.lock`.\n6. Commit the branch and open a PR.\n7. Merge the PR, then switch back to `main` and pull latest.\n8. Tag and push:\n   - `git tag 0.68` or `git tag pykaos-0.5.3`\n   - `git push --tags`\n9. GitHub Actions handles the release after tags are pushed.\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n<!--\nRelease notes will be parsed and available as /release-notes\nThe parser extracts for each version:\n  - a short description (first paragraph after the version header)\n  - bullet entries beginning with \"- \" under that version (across any subsections)\nInternal builds may append content to the Unreleased section.\nOnly write entries that are worth mentioning to users.\n-->\n\n## Unreleased\n\n- Shell: Show the current working directory, git branch, dirty state, and ahead/behind sync status directly in the prompt toolbar\n- Shell: Surface active background bash task counts in the toolbar, rotate shortcut tips on a timer, and gracefully truncate the toolbar on narrow terminals to avoid overflow\n- Web: Fix tool execution status synchronization on cancel and approval — tools now correctly transition to `output-denied` state when generation is stopped, and show the loading spinner (instead of checkmark) while executing after approval\n- Web: Dismiss stale approval and question dialogs on session replay — when replaying a session or when the backend reports idle/stopped/error status, any pending approval/question dialogs are now properly dismissed to prevent orphaned interactive elements\n- Web: Enable inline math formula rendering — single-dollar inline math (`$...$`) is now supported in addition to block math (`$$...$$`)\n- Web: Improve Switch toggle proportions and alignment — the toggle track is now larger (36×20) with a consistent 16px thumb and smoother 16px travel animation\n\n## 1.24.0 (2026-03-18)\n\n- Shell: Increase pasted text placeholder thresholds to 1000 characters or 15 lines (previously 300 characters or 3 lines), making voice/typeless workflows less disruptive\n- Core: Plan mode now supports multiple selectable approach options — when the agent's plan contains distinct alternative paths, `ExitPlanMode` can present 2–3 labeled choices for the user to pick which approach to execute; the chosen option is returned to the agent as the selected approach\n- Core: Persist plan session ID and file path across process restarts — the plan session identifier and file slug are saved to `SessionState`, so restarting Kimi Code mid-plan resumes the same plan file in `~/.kimi/plans/` instead of creating a new one\n- Core: Plan mode now supports incremental plan edits — the agent can use `StrReplaceFile` to surgically update sections of the plan file instead of rewriting the entire file with `WriteFile`, and non-plan file edits are now hard-blocked rather than requiring approval\n- Core: Defer MCP startup and surface loading progress — MCP servers now initialize asynchronously after the shell UI starts, with live progress indicators showing connection status; Shell displays connecting and ready states in the status area, Web shows server connection status\n- Core: Optimize lightweight startup paths — implement lazy-loading for CLI subcommands and version metadata, significantly reducing startup time for common commands like `--version` and `--help`\n- Build: Fix Nix `FileCollisionError` for `bin/kimi` — remove duplicate entry point from `kimi-code` package so `kimi-cli` owns `bin/kimi` exclusively\n- Shell: Preserve unsubmitted input across agent turns — text typed in the prompt while the agent is running is no longer lost when the turn ends; the user can press Enter to submit the draft as the next message\n- Shell: Fix Ctrl-C and Ctrl-D not working correctly after an agent run completes — keyboard interrupts and EOF were silently swallowed instead of showing the tip or exiting the shell\n\n## 1.23.0 (2026-03-17)\n\n- Shell: Add background bash — the `Shell` tool now accepts `run_in_background=true` to launch long-running commands (builds, tests, servers) as background tasks, freeing the agent to continue working; new `TaskList`, `TaskOutput`, and `TaskStop` tools manage task lifecycle, and the system automatically notifies the agent when tasks reach a terminal state\n- Shell: Add `/task` slash command with interactive task browser — a three-column TUI to view, monitor, and manage background tasks with real-time refresh, output preview, and keyboard-driven stopping\n- Web: Fix global config not refreshing on other tabs when model is changed — when the model is changed in one tab, other tabs now detect the config update and automatically refresh their global config\n\n## 1.22.0 (2026-03-13)\n\n- Shell: Collapse long pasted text into `[Pasted text #n]` placeholders — text pasted via `Ctrl-V` or bracketed paste that exceeds 300 characters or 3 lines is displayed as a compact placeholder token in the prompt buffer while the full content is sent to the model; the external editor (`Ctrl-O`) expands placeholders for editing and re-folds them on save\n- Shell: Cache pasted images as attachment placeholders — images pasted from the clipboard are stored on disk and shown as `[image:…]` tokens in the prompt, keeping the input buffer readable\n- Shell: Fix UTF-16 surrogate characters in pasted text causing serialization errors — lone surrogates from Windows clipboard data are now sanitized before storage, preventing `UnicodeEncodeError` in history writes and JSON serialization\n- Shell: Redesign slash command completion menu — replace the default completion popup with a full-width custom menu that shows command names and multi-line descriptions, with highlight and scroll support\n- Shell: Fix cancelled shell commands not properly terminating child processes — when a running command is cancelled, the subprocess is now explicitly killed to prevent orphaned processes\n\n## 1.21.0 (2026-03-12)\n\n- Shell: Add inline running prompt with steer input — agent output is now rendered inside the prompt area while the model is running, and users can type and send follow-up messages (steers) without waiting for the turn to finish; approval requests and question panels are handled inline with keyboard navigation\n- Core: Change steer injection from synthetic tool calls to regular user messages — steer content is now appended as a standard user message instead of a fake `_steer` tool-call/tool-result pair, improving compatibility with context serialization and visualization\n- Wire: Add `SteerInput` event — a new Wire protocol event emitted when the user sends a follow-up steer message during a running turn\n- Shell: Echo user input after submission in agent mode — the prompt symbol and entered text are printed back to the terminal for a clearer conversation transcript\n- Shell: Improve session replay with steer inputs — replay now correctly reconstructs and displays steer messages alongside regular turns, and filters out internal system-reminder messages\n- Shell: Fix upgrade command in toast notifications — the upgrade command text is now sourced from a single `UPGRADE_COMMAND` constant for consistency\n- Core: Persist system prompt in `context.jsonl` — the system prompt is now written as the first record of the context file and frozen per session, so visualization tools can read the full conversation context and session restores reuse the original prompt instead of regenerating it\n- Vis: Add session directory shortcuts in `kimi vis` — open the current session folder directly from the session page, copy the raw session directory path with `Copy DIR`, and support opening directories on both macOS and Windows\n- Shell: Improve API key login UX — show a spinner during key verification, display a helpful hint when a 401 error suggests the wrong platform was selected, show a setup summary on success, and default thinking mode to \"on\"\n\n## 1.20.0 (2026-03-11)\n\n- Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method\n- Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes\n- Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state\n- Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending\n- Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set\n- Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with \"Imported\" filter toggle, `kimi export <session_id>` CLI command, and delete support for imported sessions with AlertDialog confirmation\n- Core: Fix context compaction failing when conversation contains media parts (images, audio, video) — switch from blacklist filtering (exclude `ThinkPart`) to whitelist filtering (only keep `TextPart`) to prevent unsupported content types from being sent to the compaction API\n- Web: Fix `@` file mention index not refreshing after switching sessions or when workspace files change — reset index on session switch, auto-refresh after 30s staleness, and support path-prefix search beyond the 500-file limit\n\n## 1.19.0 (2026-03-10)\n\n- Core: Add plan mode — the agent can enter a planning phase (`EnterPlanMode`) where only read-only tools (Glob, Grep, ReadFile) are available, write a structured plan to a file, and present it for user approval (`ExitPlanMode`) before executing; toggle manually via `/plan` slash command or `Shift-Tab` keyboard shortcut\n- Vis: Add `kimi vis` command for launching an interactive visualization dashboard to inspect session traces — includes wire event timeline, context viewer, session explorer, and usage statistics\n- Web: Fix session stream state management — guard against null reference errors during state resets and preserve slash commands across session switches to avoid a brief empty gap\n\n## 1.18.0 (2026-03-09)\n\n- ACP: Support embedded resource content in ACP mode so that Zed's `@` file references correctly include file contents\n- Core: Use `parameters_json_schema` instead of `parameters` in Google GenAI provider to bypass Pydantic validation that rejects standard JSON Schema metadata fields in MCP tools\n- Shell: Enhance `Ctrl-V` clipboard paste to support video files in addition to images — video file paths are inserted as text, and a crash when clipboard data is `None` is fixed\n- Core: Pass session ID as `user_id` metadata to Anthropic API\n- Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization\n\n## 1.17.0 (2026-03-03)\n\n- Core: Add `/export` command to export current session context (messages, metadata) to a Markdown file, and `/import` command to import context from a file or another session ID into the current session\n- Shell: Show token counts (used/total) alongside context usage percentage in the status bar (e.g., `context: 42.0% (4.2k/10.0k)`)\n- Shell: Rotate keyboard shortcut tips in the toolbar — tips cycle through available shortcuts on each prompt submission to save horizontal space\n- MCP: Add loading indicators for MCP server connections — Shell displays a \"Connecting to MCP servers...\" spinner and Web shows a status message while MCP tools are being loaded\n- Web: Fix scrollable file list overflow in the toolbar changes panel\n- Core: Add `compaction_trigger_ratio` config option (default `0.85`) to control when auto-compaction triggers — compaction now fires when context usage reaches the configured ratio or when remaining space falls below `reserved_context_size`, whichever comes first\n- Core: Support custom instructions in `/compact` command (e.g., `/compact keep database discussions`) to guide what the compaction preserves\n- Web: Add URL action parameters (`?action=create` to open create-session dialog, `?action=create-in-dir&workDir=xxx` to create a session directly) for external integrations, and support Cmd/Ctrl+Click on new-session buttons to open session creation in a new browser tab\n- Web: Add todo list display in prompt toolbar — shows task progress with expandable panel when the `SetTodoList` tool is active\n- ACP: Add authentication check for session operations with `AUTH_REQUIRED` error responses for terminal-based login flow\n\n## 1.16.0 (2026-02-27)\n\n- Web: Update ASCII logo banner to a new styled design\n- Core: Add `--add-dir` CLI option and `/add-dir` slash command to expand the workspace scope with additional directories — added directories are accessible to all file tools (read, write, glob, replace), persisted across sessions, and shown in the system prompt\n- Shell: Add `Ctrl-O` keyboard shortcut to open the current input in an external editor (`$VISUAL`/`$EDITOR`), with auto-detection fallback to VS Code, Vim, Vi, or Nano\n- Shell: Add `/editor` slash command to configure and switch the default external editor, with interactive selection and persistent config storage\n- Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI\n- Wire: Auto-hide `AskUserQuestion` tool when the client does not support the `supports_question` capability, preventing the LLM from invoking unsupported interactions\n- Core: Estimate context token count after compaction so context usage percentage is not reported as 0%\n- Web: Show context usage percentage with one decimal place for better precision\n\n## 1.15.0 (2026-02-27)\n\n- Shell: Simplify input prompt by removing username prefix for a cleaner appearance\n- Shell: Add horizontal separator line and expanded keyboard shortcut hints to the toolbar\n- Shell: Add number key shortcuts (1–5) for quick option selection in question and approval panels, with redesigned bordered panel UI and keyboard hints\n- Shell: Add tab-style navigation for multi-question panels — use Left/Right arrows or Tab to switch between questions, with visual indicators for answered, current, and pending states, and automatic state restoration when revisiting a question\n- Shell: Allow Space key to submit single-select questions in the question panel\n- Web: Add tab-style navigation for multi-question dialogs with clickable tab bar, keyboard navigation, and state restoration when revisiting a question\n- Core: Set process title to \"Kimi Code\" (visible in `ps` / Activity Monitor / terminal tab title) and label web worker subprocesses as \"kimi-code-worker\"\n\n## 1.14.0 (2026-02-26)\n\n- Shell: Make FetchURL tool's URL parameter a clickable hyperlink in the terminal\n- Tool: Add `AskUserQuestion` tool for presenting structured questions with predefined options during execution, supporting single-select, multi-select, and custom text input\n- Wire: Add `QuestionRequest` / `QuestionResponse` message types and capability negotiation for structured question interactions\n- Shell: Add interactive question panel for `AskUserQuestion` with keyboard-driven option selection\n- Web: Add `QuestionDialog` component for answering structured questions inline, replacing the prompt composer when a question is pending\n- Core: Persist session state across sessions — approval decisions (YOLO mode, auto-approved actions) and dynamic subagents are now saved and restored when resuming a session\n- Core: Use atomic JSON writes for metadata and session state files to prevent data corruption on crash\n- Wire: Add `steer` request to inject user messages into an active agent turn (protocol version 1.4)\n- Web: Allow Cmd/Ctrl+Click on FetchURL tool's URL parameter to open the link in a new browser tab, with platform-appropriate tooltip hint\n\n## 1.13.0 (2026-02-24)\n\n- Core: Add automatic connection recovery that recreates the HTTP client on connection and timeout errors before retrying, improving resilience against transient network failures\n\n## 1.12.0 (2026-02-11)\n\n- Web: Add subagent activity rendering to display subagent steps (thinking, tool calls, text) inside Task tool messages\n- Web: Add Think tool rendering as a lightweight reasoning-style block\n- Web: Replace emoji status indicators with Lucide icons for tool states and add category-specific icons for tool names\n- Web: Enhance Reasoning component with improved thinking labels and status icons\n- Web: Enhance Todo component with status icons and improved styling\n- Web: Implement WebSocket reconnection with automatic request resending and stale connection watchdog\n- Web: Enhance session creation dialog with command value handling\n- Web: Support tilde (`~`) expansion in session work directory paths\n- Web: Fix assistant message content overflow clipping\n- Wire: Fix deadlock when multiple subagents run concurrently by not blocking the UI loop on approval and tool-call requests\n- Wire: Clean up stale pending requests after agent turn ends\n- Web: Show placeholder text in prompt input with hints for slash commands and file mentions\n- Web: Fix Ctrl+C not working in uvicorn web server by restoring default SIGINT handler and terminal state after shell mode exits\n- Web: Improve session stop handling with proper async cleanup and timeout\n- ACP: Add protocol version negotiation framework for client-server compatibility\n- ACP: Add session resume method to restore session state (experimental)\n\n## 1.11.0 (2026-02-10)\n\n- Web: Move context usage indicator from workspace header to prompt toolbar with a hover card showing detailed token usage breakdown\n- Web: Add folder indicator with work directory path to the bottom of the file changes panel\n- Web: Fix stderr not being restored when switching to web mode, which could suppress web server error output\n- Web: Fix port availability check by setting SO_REUSEADDR on the test socket\n\n## 1.10.0 (2026-02-09)\n\n- Web: Add copy and fork action buttons to assistant messages for quick content copying and session forking\n- Web: Add keyboard shortcuts for approval actions — press `1` to approve, `2` to approve for session, `3` to decline\n- Web: Add message queueing — queue follow-up messages while the AI is processing; queued messages are sent automatically when the response completes\n- Web: Replace Git diff status bar with unified prompt toolbar showing activity status, message queue, and file changes in collapsible tabs\n- Web: Load global MCP configuration in web worker so web sessions can use MCP tools\n- Web: Improve mobile prompt input UX — reduce textarea min-height, add `autoComplete=\"off\"`, and disable focus ring on small screens\n- Web: Handle models that stream text before thinking by ensuring thinking messages always appear before text in the message list\n- Web: Show more specific status messages during session connection (\"Loading history...\", \"Starting environment...\" instead of generic \"Connecting...\")\n- Web: Send error status when session environment initialization fails instead of leaving UI in a waiting state\n- Web: Auto-reconnect when no session status received within 15 seconds after history replay completes\n- Web: Use non-blocking file I/O in session streaming to avoid blocking the event loop during history replay\n\n## 1.9.0 (2026-02-06)\n\n- Config: Add `default_yolo` config option to enable YOLO (auto-approve) mode by default\n- Config: Accept both `max_steps_per_turn` and `max_steps_per_run` as aliases for the loop control setting\n- Wire: Add `replay` request to stream recorded Wire events (protocol version 1.3)\n- Web: Add session fork feature to branch off a new session from any assistant response\n- Web: Add session archive feature with auto-archive for sessions older than 15 days\n- Web: Add multi-select mode for bulk archive, unarchive, and delete operations\n- Web: Add media preview for tool results (images/videos from ReadMediaFile) with clickable thumbnails\n- Web: Add shell command and todo list display components for tool outputs\n- Web: Add activity status indicator showing agent state (processing, waiting for approval, etc.)\n- Web: Add error fallback UI when images fail to load\n- Web: Redesign tool input UI with expandable parameters and syntax highlighting for long values\n- Web: Show compaction indicator when context is being compacted\n- Web: Improve auto-scroll behavior in chat for smoother following of new content\n- Web: Update `last_session_id` for work directory when session stream starts\n- Shell: Remove `Ctrl-/` keyboard shortcut that triggered `/help` command\n- Rust: Move the Rust implementation to `MoonshotAI/kimi-agent-rs` with independent releases; binary renamed to `kimi-agent`\n- Core: Preserve session id when reloading configuration so the session resumes correctly\n- Shell: Fix session replay showing messages that were cleared by `/clear` or `/reset`\n- Web: Fix approval request states not updating when session is interrupted or cancelled\n- Web: Fix IME composition issue when selecting slash commands\n- Web: Fix UI not clearing messages after `/clear`, `/reset`, or `/compact` commands\n\n## 1.8.0 (2026-02-05)\n\n- CLI: Fix startup errors (e.g. invalid config files) being silently swallowed instead of displayed\n\n## 1.7.0 (2026-02-05)\n\n- Rust: Add `kagent`, the Rust implementation of Kimi agent kernel with wire-mode support (experimental)\n- Auth: Fix OAuth token refresh conflicts when running multiple sessions simultaneously\n- Web: Add file mention menu (`@`) to reference uploaded attachments and workspace files with autocomplete\n- Web: Add slash command menu in chat input with autocomplete, keyboard navigation, and alias support\n- Web: Prompt to create directory when specified path doesn't exist during session creation\n- Web: Fix authentication token persistence by switching from sessionStorage to localStorage with 24-hour expiry\n- Web: Add server-side pagination for session list with virtualized scrolling for better performance\n- Web: Improve session and work directories loading with smarter caching and invalidation\n- Web: Fix WebSocket errors during history replay by checking connection state before sending\n- Web: Git diff status bar now shows untracked files (new files not yet added to git)\n- Web: Restrict sensitive APIs only in public mode; update origin enforcement logic\n\n## 1.6 (2026-02-03)\n\n- Web: Add token-based authentication and access control for network mode (`--network`, `--lan-only`, `--public`)\n- Web: Add security options: `--auth-token`, `--allowed-origins`, `--restrict-sensitive-apis`, `--dangerously-omit-auth`\n- Web: Change `--host` option to bind to specific IP address; add automatic network address detection\n- Web: Fix WebSocket disconnect when creating new sessions\n- Web: Increase maximum image dimension from 1024 to 4096 pixels\n- Web: Improve UI responsiveness with enhanced hover effects and better layout handling\n- Wire: Add `TurnEnd` event to signal the completion of an agent turn (protocol version 1.2)\n- Core: Fix custom agent prompt files containing `$` causing silent startup failure\n\n## 1.5 (2026-01-30)\n\n- Web: Add Git diff status bar showing uncommitted changes in session working directory\n- Web: Add \"Open in\" menu for opening files/directories in Terminal, VS Code, Cursor, or other local applications\n- Web: Add search functionality to filter sessions by title or working directory\n- Web: Improve session title display with proper overflow handling\n\n## 1.4 (2026-01-30)\n\n- Shell: Merge `/login` and `/setup` commands; `/setup` is now an alias for `/login`\n- Shell: `/usage` now shows remaining quota percentage; add `/status` alias\n- Config: Add `KIMI_SHARE_DIR` environment variable to customize the share directory path (default: `~/.kimi`)\n- Web: Add new Web UI for browser-based interaction\n- CLI: Add `kimi web` subcommand to launch the Web UI server\n- Auth: Fix encoding error when device name or OS version contains non-ASCII characters\n- Auth: OAuth credentials are now stored in files instead of keyring; existing tokens are automatically migrated on startup\n- Auth: Fix authorization failure after the system sleeps or hibernates\n\n## 1.3 (2026-01-28)\n\n- Auth: Fix authentication issue during agent turns\n- Tool: Wrap media content with descriptive tags in `ReadMediaFile` for better path traceability\n\n## 1.2 (2026-01-27)\n\n- UI: Show description for `kimi-for-coding` model\n\n## 1.1 (2026-01-27)\n\n- LLM: Fix `kimi-for-coding` model's capabilities\n\n## 1.0 (2026-01-27)\n\n- Shell: Add `/login` and `/logout` slash commands for login and logout\n- CLI: Add `kimi login` and `kimi logout` subcommands\n- Core: Fix subagent approval request handling\n\n## 0.88 (2026-01-26)\n\n- MCP: Remove `Mcp-Session-Id` header when connecting to MCP servers to fix compatibility\n\n## 0.87 (2026-01-25)\n\n- Shell: Fix Markdown rendering error when HTML blocks appear outside any element\n- Skills: Add more user-level and project-level skills directory candidates\n- Core: Improve system prompt guidance for media file generation and processing tasks\n- Shell: Fix image pasting from clipboard on macOS\n\n## 0.86 (2026-01-24)\n\n- Build: Fix binary builds\n\n## 0.85 (2026-01-24)\n\n- Shell: Cache pasted images to disk for persistence across sessions\n- Shell: Deduplicate cached attachments based on content hash\n- Shell: Fix display of image/audio/video attachments in message history\n- Tool: Use file path as media identifier in `ReadMediaFile` for better traceability\n- Tool: Fix some MP4 files not being recognized as videos\n- Shell: Handle Ctrl-C during slash command execution\n- Shell: Fix shlex parsing error in shell mode when input contains invalid shell syntax\n- Shell: Fix stderr output from MCP servers and third-party libraries polluting shell UI\n- Wire: Graceful shutdown with proper cleanup of pending requests when connection closes or Ctrl-C is received\n\n## 0.84 (2026-01-22)\n\n- Build: Add cross-platform standalone binary builds for Windows, macOS (with code signing and notarization), and Linux (x86_64 and ARM64)\n- Shell: Fix slash command autocomplete showing suggestions for exact command/alias matches\n- Tool: Treat SVG files as text instead of images\n- Flow: Support D2 markdown block strings (`|md` syntax) for multiline node labels in flow skills\n- Core: Fix possible \"event loop is closed\" error after running `/reload`, `/setup`, or `/clear`\n- Core: Fix panic when `/clear` is used in a continued session\n\n## 0.83 (2026-01-21)\n\n- Tool: Add `ReadMediaFile` tool for reading image/video files; `ReadFile` now focuses on text files only\n- Skills: Flow skills now also register as `/skill:<skill-name>` commands (in addition to `/flow:<skill-name>`)\n\n## 0.82 (2026-01-21)\n\n- Tool: Allow `WriteFile` and `StrReplaceFile` tools to edit/write files outside the working directory when using absolute paths\n- Tool: Upload videos to Kimi files API when using Kimi provider, replacing inline data URLs with `ms://` references\n- Config: Add `reserved_context_size` setting to customize auto-compaction trigger threshold (default: 50000 tokens)\n\n## 0.81 (2026-01-21)\n\n- Skills: Add flow skill type with embedded Agent Flow (Mermaid/D2) in SKILL.md, invoked via `/flow:<skill-name>` commands\n- CLI: Remove `--prompt-flow` option; use flow skills instead\n- Core: Replace `/begin` command with `/flow:<skill-name>` commands for flow skills\n\n## 0.80 (2026-01-20)\n\n- Wire: Add `initialize` method for exchanging client/server info, external tools registration and slash commands advertisement\n- Wire: Support external tool calls via Wire protocol\n- Wire: Rename `ApprovalRequestResolved` to `ApprovalResponse` (backwards-compatible)\n\n## 0.79 (2026-01-19)\n\n- Skills: Add project-level skills support, discovered from `.agents/skills/` (or `.kimi/skills/`, `.claude/skills/`)\n- Skills: Unified skills discovery with layered loading (builtin → user → project); user-level skills now prefer `~/.config/agents/skills/`\n- Shell: Support fuzzy matching for slash command autocomplete\n- Shell: Enhanced approval request preview with shell command and diff content display, use `Ctrl-E` to expand full content\n- Wire: Add `ShellDisplayBlock` type for shell command display in approval requests\n- Shell: Reorder `/help` to show keyboard shortcuts before slash commands\n- Wire: Return proper JSON-RPC 2.0 error responses for invalid requests\n\n## 0.78 (2026-01-16)\n\n- CLI: Add D2 flowchart format support for Prompt Flow (`.d2` extension)\n\n## 0.77 (2026-01-15)\n\n- Shell: Fix line breaking in `/help` and `/changelog` fullscreen pager display\n- Shell: Use `/model` to toggle thinking mode instead of Tab key\n- Config: Add `default_thinking` config option (need to run `/model` to select thinking mode after upgrade)\n- LLM: Add `always_thinking` capability for models that always use thinking mode\n- CLI: Rename `--command`/`-c` to `--prompt`/`-p`, keep `--command`/`-c` as alias, remove `--query`/`-q`\n- Wire: Fix approval requests not responding properly in Wire mode\n- CLI: Add `--prompt-flow` option to load a Mermaid flowchart file as a Prompt Flow\n- Core: Add `/begin` slash command if a Prompt Flow is loaded to start the flow\n- Core: Replace Ralph Loop with Prompt Flow-based implementation\n\n## 0.76 (2026-01-12)\n\n- Tool: Make `ReadFile` tool description reflect model capabilities for image/video support\n- Tool: Fix TypeScript files (`.ts`, `.tsx`, `.mts`, `.cts`) being misidentified as video files\n- Shell: Allow slash commands (`/help`, `/exit`, `/version`, `/changelog`, `/feedback`) in shell mode\n- Shell: Improve `/help` with fullscreen pager, showing slash commands, skills, and keyboard shortcuts\n- Shell: Improve `/changelog` and `/mcp` display with consistent bullet-style formatting\n- Shell: Show current model name in the bottom status bar\n- Shell: Add `Ctrl-/` shortcut to show help\n\n## 0.75 (2026-01-09)\n\n- Tool: Improve `ReadFile` tool description\n- Skills: Add built-in `kimi-cli-help` skill to answer Kimi Code CLI usage and configuration questions\n\n## 0.74 (2026-01-09)\n\n- ACP: Allow ACP clients to select and switch models (with thinking variants)\n- ACP: Add `terminal-auth` authentication method for setup flow\n- CLI: Deprecate `--acp` option in favor of `kimi acp` subcommand\n- Tool: Support reading image and video files in `ReadFile` tool\n\n## 0.73 (2026-01-09)\n\n- Skills: Add built-in skill-creator skill shipped with the package\n- Tool: Expand `~` to the home directory in `ReadFile` paths\n- MCP: Ensure MCP tools finish loading before starting the agent loop\n- Wire: Fix Wire mode failing to accept valid `cancel` requests\n- Setup: Allow `/model` to switch between all available models for the selected provider\n- Lib: Re-export all Wire message types from `kimi_cli.wire.types`, as a replacement of `kimi_cli.wire.message`\n- Loop: Add `max_ralph_iterations` loop control config to limit extra Ralph iterations\n- Config: Rename `max_steps_per_run` to `max_steps_per_turn` in loop control config (backward-compatible)\n- CLI: Add `--max-steps-per-turn`, `--max-retries-per-step` and `--max-ralph-iterations` options to override loop control config\n- SlashCmd: Make `/yolo` toggle auto-approve mode\n- UI: Show a YOLO badge in the shell prompt\n\n## 0.72 (2026-01-04)\n\n- Python: Fix installation on Python 3.14.\n\n## 0.71 (2026-01-04)\n\n- ACP: Route file reads/writes and shell commands through ACP clients for synced edits/output\n- Shell: Add `/model` slash command to switch default models and reload when using the default config\n- Skills: Add `/skill:<name>` slash commands to load `SKILL.md` instructions on demand\n- CLI: Add `kimi info` subcommand for version/protocol details (supports `--json`)\n- CLI: Add `kimi term` to launch the Toad terminal UI\n- Python: Bump the default tooling/CI version to 3.14\n\n## 0.70 (2025-12-31)\n\n- CLI: Add `--final-message-only` (and `--quiet` alias) to only output the final assistant message in print UI\n- LLM: Add `video_in` model capability and support video inputs\n\n## 0.69 (2025-12-29)\n\n- Core: Support discovering skills in `~/.kimi/skills` or `~/.claude/skills`\n- Python: Lower the minimum required Python version to 3.12\n- Nix: Add flake packaging; install with `nix profile install .#kimi-cli` or run `nix run .#kimi-cli`\n- CLI: Add `kimi-cli` script alias for invoking the CLI; can be run via `uvx kimi-cli`\n- Lib: Move LLM config validation into `create_llm` and return `None` when missing config\n\n## 0.68 (2025-12-24)\n\n- CLI: Add `--config` and `--config-file` options to pass in config JSON/TOML\n- Core: Allow `Config` in addition to `Path` for the `config` parameter of `KimiCLI.create`\n- Tool: Include diff display blocks in `WriteFile` and `StrReplaceFile` approvals/results\n- Wire: Add display blocks to approval requests (including diffs) with backward-compatible defaults\n- ACP: Show file diff previews in tool results and approval prompts\n- ACP: Connect to MCP servers managed by ACP clients\n- ACP: Run shell commands in ACP client terminal if supported\n- Lib: Add `KimiToolset.find` method to find tools by class or name\n- Lib: Add `ToolResultBuilder.display` method to append display blocks to tool results\n- MCP: Add `kimi mcp auth` and related subcommands to manage MCP authorization\n\n## 0.67 (2025-12-22)\n\n- ACP: Advertise slash commands in single-session ACP mode (`kimi --acp`)\n- MCP: Add `mcp.client` config section to configure MCP tool call timeout and other future options\n- Core: Improve default system prompt and `ReadFile` tool\n- UI: Fix Ctrl-C not working in some rare cases\n\n## 0.66 (2025-12-19)\n\n- Lib: Provide `token_usage` and `message_id` in `StatusUpdate` Wire message\n- Lib: Add `KimiToolset.load_tools` method to load tools with dependency injection\n- Lib: Add `KimiToolset.load_mcp_tools` method to load MCP tools\n- Lib: Move `MCPTool` from `kimi_cli.tools.mcp` to `kimi_cli.soul.toolset`\n- Lib: Add `InvalidToolError`, `MCPConfigError` and `MCPRuntimeError`\n- Lib: Make the detailed Kimi Code CLI exception classes extend `ValueError` or `RuntimeError`\n- Lib: Allow passing validated `list[fastmcp.mcp_config.MCPConfig]` as `mcp_configs` for `KimiCLI.create` and `load_agent`\n- Lib: Fix exception raising for `KimiCLI.create`, `load_agent`, `KimiToolset.load_tools` and `KimiToolset.load_mcp_tools`\n- LLM: Add provider type `vertexai` to support Vertex AI\n- LLM: Rename Gemini Developer API provider type from `google_genai` to `gemini`\n- Config: Migrate config file from JSON to TOML\n- MCP: Connect to MCP servers in background and parallel to reduce startup time\n- MCP: Add `mcp-session-id` HTTP header when connecting to MCP servers\n- Lib: Split slash commands (prev \"meta commands\") into two groups: Shell-level and KimiSoul-level\n- Lib: Add `available_slash_commands` property to `Soul` protocol\n- ACP: Advertise slash commands `/init`, `/compact` and `/yolo` to ACP clients\n- SlashCmd: Add `/mcp` slash command to display MCP server and tool status\n\n## 0.65 (2025-12-16)\n\n- Lib: Support creating named sessions via `Session.create(work_dir, session_id)`\n- CLI: Automatically create new session when specified session ID is not found\n- CLI: Delete empty sessions on exit and ignore sessions whose context file is empty when listing\n- UI: Improve session replaying\n- Lib: Add `model_config: LLMModel | None` and `provider_config: LLMProvider | None` properties to `LLM` class\n- MetaCmd: Add `/usage` meta command to show API usage for Kimi Code users\n\n## 0.64 (2025-12-15)\n\n- UI: Fix UTF-16 surrogate characters input on Windows\n- Core: Add `/sessions` meta command to list existing sessions and switch to a selected one\n- CLI: Add `--session/-S` option to specify session ID to resume\n- MCP: Add `kimi mcp` subcommand group to manage global MCP config file `~/.kimi/mcp.json`\n\n## 0.63 (2025-12-12)\n\n- Tool: Fix `FetchURL` tool incorrect output when fetching via service fails\n- Tool: Use `bash` instead of `sh` in `Shell` tool for better compatibility\n- Tool: Fix `Grep` tool unicode decoding error on Windows\n- ACP: Support ACP session continuation (list/load sessions) with `kimi acp` subcommand\n- Lib: Add `Session.find` and `Session.list` static methods to find and list sessions\n- ACP: Update agent plans on the client side when `SetTodoList` tool is called\n- UI: Prevent normal messages starting with `/` from being treated as meta commands\n\n## 0.62 (2025-12-08)\n\n- ACP: Fix tool results (including Shell tool output) not being displayed in ACP clients like Zed\n- ACP: Fix compatibility with the latest version of Zed IDE (0.215.3)\n- Tool: Use PowerShell instead of CMD on Windows for better usability\n- Core: Fix startup crash when there is broken symbolic link in the working directory\n- Core: Add builtin `okabe` agent file with `SendDMail` tool enabled\n- CLI: Add `--agent` option to specify builtin agents like `default` and `okabe`\n- Core: Improve compaction logic to better preserve relevant information\n\n## 0.61 (2025-12-04)\n\n- Lib: Fix logging when used as a library\n- Tool: Harden file path check to protect against shared-prefix escape\n- LLM: Improve compatibility with some third-party OpenAI Responses and Anthropic API providers\n\n## 0.60 (2025-12-01)\n\n- LLM: Fix interleaved thinking for Kimi and OpenAI-compatible providers\n\n## 0.59 (2025-11-28)\n\n- Core: Move context file location to `.kimi/sessions/{workdir_md5}/{session_id}/context.jsonl`\n- Lib: Move `WireMessage` type alias to `kimi_cli.wire.message`\n- Lib: Add `kimi_cli.wire.message.Request` type alias request messages (which currently only includes `ApprovalRequest`)\n- Lib: Add `kimi_cli.wire.message.is_event`, `is_request` and `is_wire_message` utility functions to check the type of wire messages\n- Lib: Add `kimi_cli.wire.serde` module for serialization and deserialization of wire messages\n- Lib: Change `StatusUpdate` Wire message to not using `kimi_cli.soul.StatusSnapshot`\n- Core: Record Wire messages to a JSONL file in session directory\n- Core: Introduce `TurnBegin` Wire message to mark the beginning of each agent turn\n- UI: Print user input again with a panel in shell mode\n- Lib: Add `Session.dir` property to get the session directory path\n- UI: Improve \"Approve for session\" experience when there are multiple parallel subagents\n- Wire: Reimplement Wire server mode (which is enabled with `--wire` option)\n- Lib: Rename `ShellApp` to `Shell`, `PrintApp` to `Print`, `ACPServer` to `ACP` and `WireServer` to `WireOverStdio` for better consistency\n- Lib: Rename `KimiCLI.run_shell_mode` to `run_shell`, `run_print_mode` to `run_print`, `run_acp_server` to `run_acp`, and `run_wire_server` to `run_wire_stdio` for better consistency\n- Lib: Add `KimiCLI.run` method to run a turn with given user input and yield Wire messages\n- Print: Fix stream-json print mode not flushing output properly\n- LLM: Improve compatibility with some OpenAI and Anthropic API providers\n- Core: Fix chat provider error after compaction when using Anthropic API\n\n## 0.58 (2025-11-21)\n\n- Core: Fix field inheritance of agent spec files when using `extend`\n- Core: Support using MCP tools in subagents\n- Tool: Add `CreateSubagent` tool to create subagents dynamically (not enabled in default agent)\n- Tool: Use MoonshotFetch service in `FetchURL` tool for Kimi Code plan\n- Tool: Truncate Grep tool output to avoid exceeding token limit\n\n## 0.57 (2025-11-20)\n\n- LLM: Fix Google GenAI provider when thinking toggle is not on\n- UI: Improve approval request wordings\n- Tool: Remove `PatchFile` tool\n- Tool: Rename `Bash`/`CMD` tool to `Shell` tool\n- Tool: Move `Task` tool to `kimi_cli.tools.multiagent` module\n\n## 0.56 (2025-11-19)\n\n- LLM: Add support for Google GenAI provider\n\n## 0.55 (2025-11-18)\n\n- Lib: Add `kimi_cli.app.enable_logging` function to enable logging when directly using `KimiCLI` class\n- Core: Fix relative path resolution in agent spec files\n- Core: Prevent from panic when LLM API connection failed\n- Tool: Optimize `FetchURL` tool for better content extraction\n- Tool: Increase MCP tool call timeout to 60 seconds\n- Tool: Provide better error message in `Glob` tool when pattern is `**`\n- ACP: Fix thinking content not displayed properly\n- UI: Minor UI improvements in shell mode\n\n## 0.54 (2025-11-13)\n\n- Lib: Move `WireMessage` from `kimi_cli.wire.message` to `kimi_cli.wire`\n- Print: Fix `stream-json` output format missing the last assistant message\n- UI: Add warning when API key is overridden by `KIMI_API_KEY` environment variable\n- UI: Make a bell sound when there's an approval request\n- Core: Fix context compaction and clearing on Windows\n\n## 0.53 (2025-11-12)\n\n- UI: Remove unnecessary trailing spaces in console output\n- Core: Throw error when there are unsupported message parts\n- MetaCmd: Add `/yolo` meta command to enable YOLO mode after startup\n- Tool: Add approval request for MCP tools\n- Tool: Disable `Think` tool in default agent\n- CLI: Restore thinking mode from last time when `--thinking` is not specified\n- CLI: Fix `/reload` not working in binary packed by PyInstaller\n\n## 0.52 (2025-11-10)\n\n- CLI: Remove `--ui` option in favor of `--print`, `--acp`, and `--wire` flags (shell is still the default)\n- CLI: More intuitive session continuation behavior\n- Core: Add retry for LLM empty responses\n- Tool: Change `Bash` tool to `CMD` tool on Windows\n- UI: Fix completion after backspacing\n- UI: Fix code block rendering issues on light background colors\n\n## 0.51 (2025-11-08)\n\n- Lib: Rename `Soul.model` to `Soul.model_name`\n- Lib: Rename `LLMModelCapability` to `ModelCapability` and move to `kimi_cli.llm`\n- Lib: Add `\"thinking\"` to `ModelCapability`\n- Lib: Remove `LLM.supports_image_in` property\n- Lib: Add required `Soul.model_capabilities` property\n- Lib: Rename `KimiSoul.set_thinking_mode` to `KimiSoul.set_thinking`\n- Lib: Add `KimiSoul.thinking` property\n- UI: Better checks and notices for LLM model capabilities\n- UI: Clear the screen for `/clear` meta command\n- Tool: Support auto-downloading ripgrep on Windows\n- CLI: Add `--thinking` option to start in thinking mode\n- ACP: Support thinking content in ACP mode\n\n## 0.50 (2025-11-07)\n\n### Changed\n\n- Improve UI look and feel\n- Improve Task tool observability\n\n## 0.49 (2025-11-06)\n\n### Fixed\n\n- Minor UX improvements\n\n## 0.48 (2025-11-06)\n\n### Added\n\n- Support Kimi K2 thinking mode\n\n## 0.47 (2025-11-05)\n\n### Fixed\n\n- Fix Ctrl-W not working in some environments\n- Do not load SearchWeb tool when the search service is not configured\n\n## 0.46 (2025-11-03)\n\n### Added\n\n- Introduce Wire over stdio for local IPC (experimental, subject to change)\n- Support Anthropic provider type\n\n### Fixed\n\n- Fix binary packed by PyInstaller not working due to wrong entrypoint\n\n## 0.45 (2025-10-31)\n\n### Added\n\n- Allow `KIMI_MODEL_CAPABILITIES` environment variable to override model capabilities\n- Add `--no-markdown` option to disable markdown rendering\n- Support `openai_responses` LLM provider type\n\n### Fixed\n\n- Fix crash when continuing a session\n\n## 0.44 (2025-10-30)\n\n### Changed\n\n- Improve startup time\n\n### Fixed\n\n- Fix potential invalid bytes in user input\n\n## 0.43 (2025-10-30)\n\n### Added\n\n- Basic Windows support (experimental)\n- Display warnings when base URL or API key is overridden in environment variables\n- Support image input if the LLM model supports it\n- Replay recent context history when continuing a session\n\n### Fixed\n\n- Ensure new line after executing shell commands\n\n## 0.42 (2025-10-28)\n\n### Added\n\n- Support Ctrl-J or Alt-Enter to insert a new line\n\n### Changed\n\n- Change mode switch shortcut from Ctrl-K to Ctrl-X\n- Improve overall robustness\n\n### Fixed\n\n- Fix ACP server `no attribute` error\n\n## 0.41 (2025-10-26)\n\n### Fixed\n\n- Fix a bug for Glob tool when no matching files are found\n- Ensure reading files with UTF-8 encoding\n\n### Changed\n\n- Disable reading command/query from stdin in shell mode\n- Clarify the API platform selection in `/setup` meta command\n\n## 0.40 (2025-10-24)\n\n### Added\n\n- Support `ESC` key to interrupt the agent loop\n\n### Fixed\n\n- Fix SSL certificate verification error in some rare cases\n- Fix possible decoding error in Bash tool\n\n## 0.39 (2025-10-24)\n\n### Fixed\n\n- Fix context compaction threshold check\n- Fix panic when SOCKS proxy is set in the shell session\n\n## 0.38 (2025-10-24)\n\n- Minor UX improvements\n\n## 0.37 (2025-10-24)\n\n### Fixed\n\n- Fix update checking\n\n## 0.36 (2025-10-24)\n\n### Added\n\n- Add `/debug` meta command to debug the context\n- Add auto context compaction\n- Add approval request mechanism\n- Add `--yolo` option to automatically approve all actions\n- Render markdown content for better readability\n\n### Fixed\n\n- Fix \"unknown error\" message when interrupting a meta command\n\n## 0.35 (2025-10-22)\n\n### Changed\n\n- Minor UI improvements\n- Auto download ripgrep if not found in the system\n- Always approve tool calls in `--print` mode\n- Add `/feedback` meta command\n\n## 0.34 (2025-10-21)\n\n### Added\n\n- Add `/update` meta command to check for updates and auto-update in background\n- Support running interactive shell commands in raw shell mode\n- Add `/setup` meta command to setup LLM provider and model\n- Add `/reload` meta command to reload configuration\n\n## 0.33 (2025-10-18)\n\n### Added\n\n- Add `/version` meta command\n- Add raw shell mode, which can be switched to by Ctrl-K\n- Show shortcuts in bottom status line\n\n### Fixed\n\n- Fix logging redirection\n- Merge duplicated input histories\n\n## 0.32 (2025-10-16)\n\n### Added\n\n- Add bottom status line\n- Support file path auto-completion (`@filepath`)\n\n### Fixed\n\n- Do not auto-complete meta command in the middle of user input\n\n## 0.31 (2025-10-14)\n\n### Fixed\n\n- Fix step interrupting by Ctrl-C, for real\n\n## 0.30 (2025-10-14)\n\n### Added\n\n- Add `/compact` meta command to allow manually compacting context\n\n### Fixed\n\n- Fix `/clear` meta command when context is empty\n\n## 0.29 (2025-10-14)\n\n### Added\n\n- Support Enter key to accept completion in shell mode\n- Remember user input history across sessions in shell mode\n- Add `/reset` meta command as an alias for `/clear`\n\n### Fixed\n\n- Fix step interrupting by Ctrl-C\n\n### Changed\n\n- Disable `SendDMail` tool in Kimi Koder agent\n\n## 0.28 (2025-10-13)\n\n### Added\n\n- Add `/init` meta command to analyze the codebase and generate an `AGENTS.md` file\n- Add `/clear` meta command to clear the context\n\n### Fixed\n\n- Fix `ReadFile` output\n\n## 0.27 (2025-10-11)\n\n### Added\n\n- Add `--mcp-config-file` and `--mcp-config` options to load MCP configs\n\n### Changed\n\n- Rename `--agent` option to `--agent-file`\n\n## 0.26 (2025-10-11)\n\n### Fixed\n\n- Fix possible encoding error in `--output-format stream-json` mode\n\n## 0.25 (2025-10-11)\n\n### Changed\n\n- Rename package name `ensoul` to `kimi-cli`\n- Rename `ENSOUL_*` builtin system prompt arguments to `KIMI_*`\n- Further decouple `App` with `Soul`\n- Split `Soul` protocol and `KimiSoul` implementation for better modularity\n\n## 0.24 (2025-10-10)\n\n### Fixed\n\n- Fix ACP `cancel` method\n\n## 0.23 (2025-10-09)\n\n### Added\n\n- Add `extend` field to agent file to support agent file extension\n- Add `exclude_tools` field to agent file to support excluding tools\n- Add `subagents` field to agent file to support defining subagents\n\n## 0.22 (2025-10-09)\n\n### Changed\n\n- Improve `SearchWeb` and `FetchURL` tool call visualization\n- Improve search result output format\n\n## 0.21 (2025-10-09)\n\n### Added\n\n- Add `--print` option as a shortcut for `--ui print`, `--acp` option as a shortcut for `--ui acp`\n- Support `--output-format stream-json` to print output in JSON format\n- Add `SearchWeb` tool with `services.moonshot_search` configuration. You need to configure it with `\"services\": {\"moonshot_search\": {\"api_key\": \"your-search-api-key\"}}` in your config file.\n- Add `FetchURL` tool\n- Add `Think` tool\n- Add `PatchFile` tool, not enabled in Kimi Koder agent\n- Enable `SendDMail` and `Task` tool in Kimi Koder agent with better tool prompts\n- Add `ENSOUL_NOW` builtin system prompt argument\n\n### Changed\n\n- Better-looking `/release-notes`\n- Improve tool descriptions\n- Improve tool output truncation\n\n## 0.20 (2025-09-30)\n\n### Added\n\n- Add `--ui acp` option to start Agent Client Protocol (ACP) server\n\n## 0.19 (2025-09-29)\n\n### Added\n\n- Support piped stdin for print UI\n- Support `--input-format=stream-json` for piped JSON input\n\n### Fixed\n\n- Do not include `CHECKPOINT` messages in the context when `SendDMail` is not enabled\n\n## 0.18 (2025-09-29)\n\n### Added\n\n- Support `max_context_size` in LLM model configurations to configure the maximum context size (in tokens)\n\n### Improved\n\n- Improve `ReadFile` tool description\n\n## 0.17 (2025-09-29)\n\n### Fixed\n\n- Fix step count in error message when exceeded max steps\n- Fix history file assertion error in `kimi_run`\n- Fix error handling in print mode and single command shell mode\n- Add retry for LLM API connection errors and timeout errors\n\n### Changed\n\n- Increase default max-steps-per-run to 100\n\n## 0.16.0 (2025-09-26)\n\n### Tools\n\n- Add `SendDMail` tool (disabled in Kimi Koder, can be enabled in custom agent)\n\n### SDK\n\n- Session history file can be specified via `_history_file` parameter when creating a new session\n\n## 0.15.0 (2025-09-26)\n\n- Improve tool robustness\n\n## 0.14.0 (2025-09-25)\n\n### Added\n\n- Add `StrReplaceFile` tool\n\n### Improved\n\n- Emphasize the use of the same language as the user\n\n## 0.13.0 (2025-09-25)\n\n### Added\n\n- Add `SetTodoList` tool\n- Add `User-Agent` in LLM API calls\n\n### Improved\n\n- Better system prompt and tool description\n- Better error messages for LLM\n\n## 0.12.0 (2025-09-24)\n\n### Added\n\n- Add `print` UI mode, which can be used via `--ui print` option\n- Add logging and `--debug` option\n\n### Changed\n\n- Catch EOF error for better experience\n\n## 0.11.1 (2025-09-22)\n\n### Changed\n\n- Rename `max_retry_per_step` to `max_retries_per_step`\n\n## 0.11.0 (2025-09-22)\n\n### Added\n\n- Add `/release-notes` command\n- Add retry for LLM API errors\n- Add loop control configuration, e.g. `{\"loop_control\": {\"max_steps_per_run\": 50, \"max_retry_per_step\": 3}}`\n\n### Changed\n\n- Better extreme cases handling in `read_file` tool\n- Prevent Ctrl-C from exiting the CLI, force the use of Ctrl-D or `exit` instead\n\n## 0.10.1 (2025-09-18)\n\n- Make slash commands look slightly better\n- Improve `glob` tool\n\n## 0.10.0 (2025-09-17)\n\n### Added\n\n- Add `read_file` tool\n- Add `write_file` tool\n- Add `glob` tool\n- Add `task` tool\n\n### Changed\n\n- Improve tool call visualization\n- Improve session management\n- Restore context usage when `--continue` a session\n\n## 0.9.0 (2025-09-15)\n\n- Remove `--session` and `--continue` options\n\n## 0.8.1 (2025-09-14)\n\n- Fix config model dumping\n\n## 0.8.0 (2025-09-14)\n\n- Add `shell` tool and basic system prompt\n- Add tool call visualization\n- Add context usage count\n- Support interrupting the agent loop\n- Support project-level `AGENTS.md`\n- Support custom agent defined with YAML\n- Support oneshot task via `kimi -c`\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Kimi Code CLI\n\nThank you for being interested in contributing to Kimi Code CLI!\n\nWe welcome all kinds of contributions, including bug fixes, features, document improvements, typo fixes, etc. To maintain a high-quality codebase and user experience, we provide the following guidelines for contributions:\n\n1. We only merge pull requests that aligns with our roadmap. For any pull request that introduces changes larger than 100 lines of code, we highly recommend discussing with us by [raising an issue](https://github.com/MoonshotAI/kimi-cli/issues) or in an existing issue before you start working on it. Otherwise your pull request may be closed or ignored without review.\n2. We insist on high code quality. Please ensure your code is as good as, if not better than, the code written by frontier coding agents. Changes may be requested before your pull request can be merged.\n\n## Prek hooks\n\nWe use [prek](https://github.com/j178/prek) to run formatting and checks via git hooks.\n\nRecommended setup:\n1. Run `make prepare` to sync dependencies and install the prek hooks.\n2. Optionally run on all files before sending a PR: `prek run --all-files`.\n\nManual setup (if you do not want to use `make prepare`):\n1. Install prek (pick one): `uv tool install prek`, `pipx install prek`, or `pip install prek`.\n2. Install the hooks in this repo: `prek install`.\n\nAfter installation, the hooks run on every commit. The repo uses prek workspace mode, so only the\nprojects with changed files run their hooks. You can skip them for an intermediate commit with\n`git commit --no-verify`, or run them manually with `prek run --all-files`.\n\nThe hooks execute the relevant `make format-*` and `make check-*` targets, so ensure dependencies\nare installed (`make prepare` or `uv sync`).\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "Makefile",
    "content": ".DEFAULT_GOAL := prepare\n\n.PHONY: help\nhelp: ## Show available make targets.\n\t@echo \"Available make targets:\"\n\t@awk 'BEGIN { FS = \":.*## \" } /^[A-Za-z0-9_.-]+:.*## / { printf \"  %-20s %s\\n\", $$1, $$2 }' $(MAKEFILE_LIST)\n\n.PHONY: install-prek\ninstall-prek: ## Install prek and repo git hooks.\n\t@echo \"==> Installing prek\"\n\t@uv tool install prek\n\t@echo \"==> Installing git hooks with prek\"\n\t@uv tool run prek install\n\n.PHONY: prepare\nprepare: download-deps install-prek ## Sync dependencies for all workspace packages and install prek hooks.\n\t@echo \"==> Syncing dependencies for all workspace packages\"\n\t@uv sync --frozen --all-extras --all-packages\n\n.PHONY: prepare-build\nprepare-build: download-deps ## Sync dependencies for releases without workspace sources.\n\t@echo \"==> Syncing dependencies for release builds (no sources)\"\n\t@uv sync --all-extras --all-packages --no-sources\n\n# for kimi web development\n.PHONY: web-back web-front\nweb-back: ## Start web backend with uvicorn (reload enabled).\n\t@LOG_LEVEL=DEBUG uv run uvicorn kimi_cli.web.app:create_app --factory --reload --port 5494\nweb-front: ## Start web frontend (vite dev server).\n\t@npm --prefix web run dev\n\n# for kimi vis development\n.PHONY: vis-back vis-front\nvis-back: ## Start vis backend with uvicorn (reload enabled).\n\t@LOG_LEVEL=DEBUG uv run uvicorn kimi_cli.vis.app:create_app --factory --reload --port 5495\nvis-front: ## Start vis frontend (vite dev server).\n\t@npm --prefix vis run dev\n\n.PHONY: format format-kimi-cli format-kosong format-pykaos format-kimi-sdk format-web\nformat: format-kimi-cli format-kosong format-pykaos format-kimi-sdk format-web ## Auto-format all workspace packages.\nformat-kimi-cli: ## Auto-format Kimi Code CLI sources with ruff.\n\t@echo \"==> Formatting Kimi Code CLI sources\"\n\t@uv run ruff check --fix\n\t@uv run ruff format\nformat-kosong: ## Auto-format kosong sources with ruff.\n\t@echo \"==> Formatting kosong sources\"\n\t@uv run --project packages/kosong --directory packages/kosong ruff check --fix\n\t@uv run --project packages/kosong --directory packages/kosong ruff format\nformat-pykaos: ## Auto-format pykaos sources with ruff.\n\t@echo \"==> Formatting pykaos sources\"\n\t@uv run --project packages/kaos --directory packages/kaos ruff check --fix\n\t@uv run --project packages/kaos --directory packages/kaos ruff format\nformat-kimi-sdk: ## Auto-format kimi-sdk sources with ruff.\n\t@echo \"==> Formatting kimi-sdk sources\"\n\t@uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff check --fix\n\t@uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff format\nformat-web: ## Auto-format web sources with npm run format.\n\t@echo \"==> Formatting web sources\"\n\t@if command -v npm >/dev/null 2>&1; then \\\n\t\tnpm --prefix web run format; \\\n\telse \\\n\t\techo \"npm not found. Install Node.js (npm) to run web formatting.\"; \\\n\t\texit 1; \\\n\tfi\n.PHONY: check check-kimi-cli check-kosong check-pykaos check-kimi-sdk check-web\ncheck: check-kimi-cli check-kosong check-pykaos check-kimi-sdk check-web ## Run linting and type checks for all packages.\ncheck-kimi-cli: ## Run linting and type checks for Kimi Code CLI.\n\t@echo \"==> Checking Kimi Code CLI (ruff + pyright + ty; ty is non-blocking)\"\n\t@uv run ruff check\n\t@uv run ruff format --check\n\t@uv run pyright\n\t@uv run ty check || true\ncheck-kosong: ## Run linting and type checks for kosong.\n\t@echo \"==> Checking kosong (ruff + pyright + ty; ty is non-blocking)\"\n\t@uv run --project packages/kosong --directory packages/kosong ruff check\n\t@uv run --project packages/kosong --directory packages/kosong ruff format --check\n\t@uv run --project packages/kosong --directory packages/kosong pyright\n\t@uv run --project packages/kosong --directory packages/kosong ty check || true\ncheck-pykaos: ## Run linting and type checks for pykaos.\n\t@echo \"==> Checking pykaos (ruff + pyright + ty; ty is non-blocking)\"\n\t@uv run --project packages/kaos --directory packages/kaos ruff check\n\t@uv run --project packages/kaos --directory packages/kaos ruff format --check\n\t@uv run --project packages/kaos --directory packages/kaos pyright\n\t@uv run --project packages/kaos --directory packages/kaos ty check || true\ncheck-kimi-sdk: ## Run linting and type checks for kimi-sdk.\n\t@echo \"==> Checking kimi-sdk (ruff + pyright + ty; ty is non-blocking)\"\n\t@uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff check\n\t@uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ruff format --check\n\t@uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk pyright\n\t@uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk ty check || true\ncheck-web: ## Run linting and type checks for web.\n\t@echo \"==> Checking web (biome + tsc)\"\n\t@if command -v npm >/dev/null 2>&1; then \\\n\t\tnpm --prefix web run lint && npm --prefix web run typecheck; \\\n\telse \\\n\t\techo \"npm not found. Install Node.js (npm) to run web checks.\"; \\\n\t\texit 1; \\\n\tfi\n.PHONY: test test-kimi-cli test-kosong test-pykaos test-kimi-sdk\ntest: test-kimi-cli test-kosong test-pykaos test-kimi-sdk ## Run all test suites.\ntest-kimi-cli: ## Run Kimi Code CLI tests.\n\t@echo \"==> Running Kimi Code CLI tests\"\n\t@uv run pytest tests -vv\n\t@uv run pytest tests_e2e -vv\ntest-kosong: ## Run kosong tests (including doctests).\n\t@echo \"==> Running kosong tests\"\n\t@uv run --project packages/kosong --directory packages/kosong pytest --doctest-modules -vv\ntest-pykaos: ## Run pykaos tests.\n\t@echo \"==> Running pykaos tests\"\n\t@uv run --project packages/kaos --directory packages/kaos pytest tests -vv\ntest-kimi-sdk: ## Run kimi-sdk tests.\n\t@echo \"==> Running kimi-sdk tests\"\n\t@uv run --project sdks/kimi-sdk --directory sdks/kimi-sdk pytest tests -vv\n.PHONY: build build-kimi-cli build-kosong build-pykaos build-kimi-sdk build-bin build-bin-onedir\nbuild: build-web build-vis build-kimi-cli build-kosong build-pykaos build-kimi-sdk ## Build Python packages for release.\nbuild-kimi-cli: build-web build-vis ## Build the kimi-cli and kimi-code sdists and wheels.\n\t@echo \"==> Building kimi-cli distributions\"\n\t@uv build --package kimi-cli --no-sources --out-dir dist\n\t@echo \"==> Building kimi-code distributions\"\n\t@uv build --package kimi-code --no-sources --out-dir dist\nbuild-kosong: ## Build the kosong sdist and wheel.\n\t@echo \"==> Building kosong distributions\"\n\t@uv build --package kosong --no-sources --out-dir dist/kosong\nbuild-pykaos: ## Build the pykaos sdist and wheel.\n\t@echo \"==> Building pykaos distributions\"\n\t@uv build --package pykaos --no-sources --out-dir dist/pykaos\nbuild-kimi-sdk: ## Build the kimi-sdk sdist and wheel.\n\t@echo \"==> Building kimi-sdk distributions\"\n\t@uv build --package kimi-sdk --no-sources --out-dir dist/kimi-sdk\nbuild-web: ## Build web UI and sync into kimi-cli package.\n\t@echo \"==> Building web UI\"\n\t@uv run scripts/build_web.py\nbuild-vis: ## Build vis UI and sync into kimi-cli package.\n\t@echo \"==> Building vis UI\"\n\t@uv run scripts/build_vis.py\nbuild-bin: build-web build-vis ## Build the standalone executable with PyInstaller (one-file mode).\n\t@echo \"==> Building PyInstaller binary (one-file)\"\n\t@uv run pyinstaller kimi.spec\n\t@mkdir -p dist/onefile\n\t@if [ -f dist/kimi.exe ]; then mv dist/kimi.exe dist/onefile/; elif [ -f dist/kimi ]; then mv dist/kimi dist/onefile/; fi\nbuild-bin-onedir: build-web build-vis ## Build the standalone executable with PyInstaller (one-dir mode).\n\t@echo \"==> Building PyInstaller binary (one-dir)\"\n\t@rm -rf dist/onedir dist/kimi\n\t@uv run pyinstaller kimi.spec\n\t@if [ -f dist/kimi/kimi-exe.exe ]; then mv dist/kimi/kimi-exe.exe dist/kimi/kimi.exe; elif [ -f dist/kimi/kimi-exe ]; then mv dist/kimi/kimi-exe dist/kimi/kimi; fi\n\t@mkdir -p dist/onedir && mv dist/kimi dist/onedir/\n.PHONY: ai-test\nai-test: ## Run the test suite with Kimi Code CLI.\n\t@echo \"==> Running AI test suite\"\n\t@uv run tests_ai/scripts/run.py tests_ai\n\n.PHONY: gen-changelog gen-docs\ngen-changelog: ## Generate changelog with Kimi Code CLI.\n\t@echo \"==> Generating changelog\"\n\t@uv run kimi --yolo --prompt /skill:gen-changelog\ngen-docs: ## Generate user docs with Kimi Code CLI.\n\t@echo \"==> Generating user docs\"\n\t@uv run kimi --yolo --prompt /skill:gen-docs\n\ninclude src/kimi_cli/deps/Makefile\n"
  },
  {
    "path": "NOTICE",
    "content": "Kimi Code CLI\nCopyright 2025 Moonshot AI\n\nThis product includes software developed at\nMoonshot AI (https://www.moonshot.ai/).\n\nThe Kimi Code CLI project (formerly Kimi CLI) contains or reuses code\nthat is licensed under the Apache 2.0 license from the following projects:\n- OpenAI Codex (https://github.com/openai/codex)\n\n  See: src/kimi_cli/skills/skill-creator/SKILL.md\n\nOpenAI Codex\nCopyright 2025 OpenAI\n"
  },
  {
    "path": "README.md",
    "content": "# Kimi Code CLI\n\n[![Commit Activity](https://img.shields.io/github/commit-activity/w/MoonshotAI/kimi-cli)](https://github.com/MoonshotAI/kimi-cli/graphs/commit-activity)\n[![Checks](https://img.shields.io/github/check-runs/MoonshotAI/kimi-cli/main)](https://github.com/MoonshotAI/kimi-cli/actions)\n[![Version](https://img.shields.io/pypi/v/kimi-cli)](https://pypi.org/project/kimi-cli/)\n[![Downloads](https://img.shields.io/pypi/dw/kimi-cli)](https://pypistats.org/packages/kimi-cli)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/MoonshotAI/kimi-cli)\n\n[Kimi Code](https://www.kimi.com/code/) | [Documentation](https://moonshotai.github.io/kimi-cli/en/) | [文档](https://moonshotai.github.io/kimi-cli/zh/)\n\nKimi Code CLI is an AI agent that runs in the terminal, helping you complete software development tasks and terminal operations. It can read and edit code, execute shell commands, search and fetch web pages, and autonomously plan and adjust actions during execution.\n\n## Getting Started\n\nSee [Getting Started](https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html) for how to install and start using Kimi Code CLI.\n\n## Key Features\n\n### Shell command mode\n\nKimi Code CLI is not only a coding agent, but also a shell. You can switch the shell command mode by pressing `Ctrl-X`. In this mode, you can directly run shell commands without leaving Kimi Code CLI.\n\n![](./docs/media/shell-mode.gif)\n\n> [!NOTE]\n> Built-in shell commands like `cd` are not supported yet.\n\n### VS Code extension\n\nKimi Code CLI can be integrated with [Visual Studio Code](https://code.visualstudio.com/) via the [Kimi Code VS Code Extension](https://marketplace.visualstudio.com/items?itemName=moonshot-ai.kimi-code).\n\n![VS Code Extension](./docs/media/vscode.png)\n\n### IDE integration via ACP\n\nKimi Code CLI supports [Agent Client Protocol] out of the box. You can use it together with any ACP-compatible editor or IDE.\n\n[Agent Client Protocol]: https://github.com/agentclientprotocol/agent-client-protocol\n\nTo use Kimi Code CLI with ACP clients, make sure to run Kimi Code CLI in the terminal and send `/login` to complete the login first. Then, you can configure your ACP client to start Kimi Code CLI as an ACP agent server with command `kimi acp`.\n\nFor example, to use Kimi Code CLI with [Zed](https://zed.dev/) or [JetBrains](https://blog.jetbrains.com/ai/2025/12/bring-your-own-ai-agent-to-jetbrains-ides/), add the following configuration to your `~/.config/zed/settings.json` or `~/.jetbrains/acp.json` file:\n\n```json\n{\n  \"agent_servers\": {\n    \"Kimi Code CLI\": {\n      \"command\": \"kimi\",\n      \"args\": [\"acp\"],\n      \"env\": {}\n    }\n  }\n}\n```\n\nThen you can create Kimi Code CLI threads in IDE's agent panel.\n\n![](./docs/media/acp-integration.gif)\n\n### Zsh integration\n\nYou can use Kimi Code CLI together with Zsh, to empower your shell experience with AI agent capabilities.\n\nInstall the [zsh-kimi-cli](https://github.com/MoonshotAI/zsh-kimi-cli) plugin via:\n\n```sh\ngit clone https://github.com/MoonshotAI/zsh-kimi-cli.git \\\n  ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/kimi-cli\n```\n\n> [!NOTE]\n> If you are using a plugin manager other than Oh My Zsh, you may need to refer to the plugin's README for installation instructions.\n\nThen add `kimi-cli` to your Zsh plugin list in `~/.zshrc`:\n\n```sh\nplugins=(... kimi-cli)\n```\n\nAfter restarting Zsh, you can switch to agent mode by pressing `Ctrl-X`.\n\n### MCP support\n\nKimi Code CLI supports MCP (Model Context Protocol) tools.\n\n**`kimi mcp` sub-command group**\n\nYou can manage MCP servers with `kimi mcp` sub-command group. For example:\n\n```sh\n# Add streamable HTTP server:\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \"CONTEXT7_API_KEY: ctx7sk-your-key\"\n\n# Add streamable HTTP server with OAuth authorization:\nkimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp\n\n# Add stdio server:\nkimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest\n\n# List added MCP servers:\nkimi mcp list\n\n# Remove an MCP server:\nkimi mcp remove chrome-devtools\n\n# Authorize an MCP server:\nkimi mcp auth linear\n```\n\n**Ad-hoc MCP configuration**\n\nKimi Code CLI also supports ad-hoc MCP server configuration via CLI option.\n\nGiven an MCP config file in the well-known MCP config format like the following:\n\n```json\n{\n  \"mcpServers\": {\n    \"context7\": {\n      \"url\": \"https://mcp.context7.com/mcp\",\n      \"headers\": {\n        \"CONTEXT7_API_KEY\": \"YOUR_API_KEY\"\n      }\n    },\n    \"chrome-devtools\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"chrome-devtools-mcp@latest\"]\n    }\n  }\n}\n```\n\nRun `kimi` with `--mcp-config-file` option to connect to the specified MCP servers:\n\n```sh\nkimi --mcp-config-file /path/to/mcp.json\n```\n\n### More\n\nSee more features in the [Documentation](https://moonshotai.github.io/kimi-cli/en/).\n\n## Development\n\nTo develop Kimi Code CLI, run:\n\n```sh\ngit clone https://github.com/MoonshotAI/kimi-cli.git\ncd kimi-cli\n\nmake prepare  # prepare the development environment\n```\n\nThen you can start working on Kimi Code CLI.\n\nRefer to the following commands after you make changes:\n\n```sh\nuv run kimi  # run Kimi Code CLI\n\nmake format  # format code\nmake check  # run linting and type checking\nmake test  # run tests\nmake test-kimi-cli  # run Kimi Code CLI tests only\nmake test-kosong  # run kosong tests only\nmake test-pykaos  # run pykaos tests only\nmake build-web  # build the web UI and sync it into the package (requires Node.js/npm)\nmake build  # build python packages\nmake build-bin  # build standalone binary\nmake help  # show all make targets\n```\n\nNote: `make build` and `make build-bin` automatically run `make build-web` to embed the web UI.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nCurrently, Kimi CLI only provides security support for the latest version.\n\n## Reporting a Vulnerability\n\nPlease report a vulnerability via the [MoonshotAI/kimi-cli - Security](https://github.com/MoonshotAI/kimi-cli/security) page, or open an [issue](https://github.com/MoonshotAI/kimi-cli/issues) if it can be published publicly.\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "node_modules/\n.vitepress/cache/\n.vitepress/dist/\n"
  },
  {
    "path": "docs/.pre-commit-config.yaml",
    "content": "orphan: true\n\n# Docs changes do not need pre-commit hooks.\nrepos: []\n"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "content": "import { defineConfig } from 'vitepress'\nimport { withMermaid } from 'vitepress-plugin-mermaid'\nimport llmstxt from 'vitepress-plugin-llms'\n\nconst rawBase = process.env.VITEPRESS_BASE\nconst base = rawBase\n  ? rawBase.startsWith('/')\n    ? rawBase.endsWith('/') ? rawBase : `${rawBase}/`\n    : `/${rawBase}/`\n  : '/'\n\nexport default withMermaid(defineConfig({\n  base,\n  title: 'Kimi Code CLI Docs',\n  description: 'Kimi Code CLI Documentation',\n\n  locales: {\n    zh: {\n      label: '简体中文',\n      lang: 'zh-CN',\n      link: '/zh/',\n      title: 'Kimi Code CLI 文档',\n      description: 'Kimi Code CLI 用户文档',\n      themeConfig: {\n        nav: [\n          { text: '指南', link: '/zh/guides/getting-started', activeMatch: '/zh/guides/' },\n          { text: '定制化', link: '/zh/customization/mcp', activeMatch: '/zh/customization/' },\n          { text: '配置', link: '/zh/configuration/config-files', activeMatch: '/zh/configuration/' },\n          { text: '参考手册', link: '/zh/reference/kimi-command', activeMatch: '/zh/reference/' },\n          { text: '常见问题', link: '/zh/faq' },\n          { text: '发布说明', link: '/zh/release-notes/changelog', activeMatch: '/zh/release-notes/' },\n        ],\n        sidebar: {\n          '/zh/guides/': [\n            {\n              text: '指南',\n              items: [\n                { text: '开始使用', link: '/zh/guides/getting-started' },\n                { text: '常见使用案例', link: '/zh/guides/use-cases' },\n                { text: '交互与输入', link: '/zh/guides/interaction' },\n                { text: '会话与上下文', link: '/zh/guides/sessions' },\n                { text: '在 IDE 中使用', link: '/zh/guides/ides' },\n                { text: '集成到工具', link: '/zh/guides/integrations' },\n              ],\n            },\n          ],\n          '/zh/customization/': [\n            {\n              text: '定制化',\n              items: [\n                { text: 'Model Context Protocol', link: '/zh/customization/mcp' },\n                { text: 'Agent Skills', link: '/zh/customization/skills' },\n                { text: 'Agent 与子 Agent', link: '/zh/customization/agents' },\n                { text: 'Print 模式', link: '/zh/customization/print-mode' },\n                { text: 'Wire 模式', link: '/zh/customization/wire-mode' },\n              ],\n            },\n          ],\n          '/zh/configuration/': [\n            {\n              text: '配置',\n              items: [\n                { text: '配置文件', link: '/zh/configuration/config-files' },\n                { text: '平台与模型', link: '/zh/configuration/providers' },\n                { text: '配置覆盖', link: '/zh/configuration/overrides' },\n                { text: '环境变量', link: '/zh/configuration/env-vars' },\n                { text: '数据路径', link: '/zh/configuration/data-locations' },\n              ],\n            },\n          ],\n          '/zh/reference/': [\n            {\n              text: '参考手册',\n              items: [\n                { text: 'kimi 命令', link: '/zh/reference/kimi-command' },\n                { text: 'kimi info 子命令', link: '/zh/reference/kimi-info' },\n                { text: 'kimi acp 子命令', link: '/zh/reference/kimi-acp' },\n                { text: 'kimi mcp 子命令', link: '/zh/reference/kimi-mcp' },\n                { text: 'kimi term 子命令', link: '/zh/reference/kimi-term' },\n                { text: 'kimi vis 子命令', link: '/zh/reference/kimi-vis' },\n                { text: 'kimi web 子命令', link: '/zh/reference/kimi-web' },\n                { text: '斜杠命令', link: '/zh/reference/slash-commands' },\n                { text: '键盘快捷键', link: '/zh/reference/keyboard' },\n              ],\n            },\n          ],\n          '/zh/release-notes/': [\n            {\n              text: '发布说明',\n              items: [\n                { text: '变更记录', link: '/zh/release-notes/changelog' },\n                { text: '破坏性变更与迁移说明', link: '/zh/release-notes/breaking-changes' },\n              ],\n            },\n          ],\n        },\n      },\n    },\n    en: {\n      label: 'English',\n      lang: 'en-US',\n      link: '/en/',\n      title: 'Kimi Code CLI Docs',\n      description: 'Kimi Code CLI User Documentation',\n      themeConfig: {\n        nav: [\n          { text: 'Guides', link: '/en/guides/getting-started', activeMatch: '/en/guides/' },\n          { text: 'Customization', link: '/en/customization/mcp', activeMatch: '/en/customization/' },\n          { text: 'Configuration', link: '/en/configuration/config-files', activeMatch: '/en/configuration/' },\n          { text: 'Reference', link: '/en/reference/kimi-command', activeMatch: '/en/reference/' },\n          { text: 'FAQ', link: '/en/faq' },\n          { text: 'Release Notes', link: '/en/release-notes/changelog', activeMatch: '/en/release-notes/' },\n        ],\n        sidebar: {\n          '/en/guides/': [\n            {\n              text: 'Guides',\n              items: [\n                { text: 'Getting Started', link: '/en/guides/getting-started' },\n                { text: 'Common Use Cases', link: '/en/guides/use-cases' },\n                { text: 'Interaction and Input', link: '/en/guides/interaction' },\n                { text: 'Sessions and Context', link: '/en/guides/sessions' },\n                { text: 'Using in IDEs', link: '/en/guides/ides' },\n                { text: 'Integrations with Tools', link: '/en/guides/integrations' },\n              ],\n            },\n          ],\n          '/en/customization/': [\n            {\n              text: 'Customization',\n              items: [\n                { text: 'Model Context Protocol', link: '/en/customization/mcp' },\n                { text: 'Agent Skills', link: '/en/customization/skills' },\n                { text: 'Agents and Subagents', link: '/en/customization/agents' },\n                { text: 'Print Mode', link: '/en/customization/print-mode' },\n                { text: 'Wire Mode', link: '/en/customization/wire-mode' },\n              ],\n            },\n          ],\n          '/en/configuration/': [\n            {\n              text: 'Configuration',\n              items: [\n                { text: 'Config Files', link: '/en/configuration/config-files' },\n                { text: 'Providers and Models', link: '/en/configuration/providers' },\n                { text: 'Config Overrides', link: '/en/configuration/overrides' },\n                { text: 'Environment Variables', link: '/en/configuration/env-vars' },\n                { text: 'Data Locations', link: '/en/configuration/data-locations' },\n              ],\n            },\n          ],\n          '/en/reference/': [\n            {\n              text: 'Reference',\n              items: [\n                { text: 'kimi Command', link: '/en/reference/kimi-command' },\n                { text: 'kimi info Subcommand', link: '/en/reference/kimi-info' },\n                { text: 'kimi acp Subcommand', link: '/en/reference/kimi-acp' },\n                { text: 'kimi mcp Subcommand', link: '/en/reference/kimi-mcp' },\n                { text: 'kimi term Subcommand', link: '/en/reference/kimi-term' },\n                { text: 'kimi vis Subcommand', link: '/en/reference/kimi-vis' },\n                { text: 'kimi web Subcommand', link: '/en/reference/kimi-web' },\n                { text: 'Slash Commands', link: '/en/reference/slash-commands' },\n                { text: 'Keyboard Shortcuts', link: '/en/reference/keyboard' },\n              ],\n            },\n          ],\n          '/en/release-notes/': [\n            {\n              text: 'Release Notes',\n              items: [\n                { text: 'Changelog', link: '/en/release-notes/changelog' },\n                { text: 'Breaking Changes and Migration', link: '/en/release-notes/breaking-changes' },\n              ],\n            },\n          ],\n        },\n      },\n    },\n  },\n\n  themeConfig: {\n    outline: [2, 3],\n    search: { provider: 'local' },\n    socialLinks: [\n      { icon: 'github', link: 'https://github.com/MoonshotAI/kimi-cli' },\n    ],\n  },\n\n  vite: {\n    plugins: [llmstxt()],\n  },\n}))\n"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import DefaultTheme from 'vitepress/theme'\nimport './style.css'\n\nexport default DefaultTheme\n"
  },
  {
    "path": "docs/.vitepress/theme/style.css",
    "content": ":root {\n  --vp-c-brand-1: rgb(52, 118, 246);\n  --vp-c-brand-2: rgb(72, 138, 255);\n  --vp-c-brand-3: rgb(92, 158, 255);\n  --vp-c-brand-soft: rgba(52, 118, 246, 0.14);\n}\n\n.dark {\n  --vp-c-brand-1: rgb(72, 138, 255);\n  --vp-c-brand-2: rgb(52, 118, 246);\n  --vp-c-brand-3: rgb(92, 158, 255);\n  --vp-c-brand-soft: rgba(52, 118, 246, 0.16);\n}\n"
  },
  {
    "path": "docs/AGENTS.md",
    "content": "# Documentation Agent Guide\n\nThis repository uses VitePress for the documentation site. The current docs are structural scaffolds only; everything beyond the headings is placeholder guidance. The `Reference Code` blocks are there to guide future writing and should be removed once the docs are complete.\n\n## Structure\n\n- Locales live under `docs/en/` and `docs/zh/` with mirrored paths and filenames.\n- Main sections (nav + sidebar) are:\n  - Guides: getting-started, use-cases, interaction, sessions, ides, integrations\n  - Customization: mcp, skills, agents, print-mode, wire-mode\n  - Configuration: config-files, providers, overrides, env-vars, data-locations\n  - Reference: kimi-command, kimi-acp, kimi-mcp, slash-commands, keyboard, tools, exit-codes\n  - FAQ: setup, interaction, acp, mcp, print-wire, updates\n  - Release notes: changelog, breaking-changes\n- Navigation and sidebar are defined in `docs/.vitepress/config.ts`. Any new or renamed page must be wired there for both locales.\n\n## Source of truth\n\n- **Changelog page**: The English version (`docs/en/release-notes/changelog.md`) is the source of truth. It is auto-synced from the root `CHANGELOG.md` via script. The Chinese changelog should be translated from the English version.\n- **All other pages**: The Chinese version (`docs/zh/`) is the source of truth. English translations should be based on the Chinese docs.\n\nOnly the Chinese documentation and the English changelog are manually reviewed. Other translations (e.g., English versions of non-changelog pages) may be auto-generated by AI agents.\n\n## Authoring workflow\n\n- Each page is a scaffold: expand the bullets into prose while keeping the section ordering, and keep the `::: info Reference Code` blocks aligned with the relevant section.\n- For changelog: edit the root `CHANGELOG.md`, then run `npm run sync` to update the English docs.\n- For other pages: edit the Chinese version first, then translate to English.\n\n## Naming conventions\n\n- Filenames are kebab-case and mirror across locales (same slug in `docs/en/` and `docs/zh/`).\n- Use consistent section labels that match the sidebar titles.\n- Use backticks for flags, commands, subcommands, command arguments, file paths, code identifiers, type names, field names, field values, and keyboard shortcuts.\n\n## Wording conventions\n\n- Do not change H1 titles or nav/sidebar labels.\n- English H2+ headings use sentence case (only the first word capitalized unless it is a proper noun). Treat \"Wire\" as a proper noun; do not treat \"agent\", \"shell mode\", or \"print mode\" as proper nouns.\n- Chinese H2+ headings keep English words in sentence case; preserve proper nouns listed in the term table below.\n- Use `API key` in English and `API 密钥` in Chinese; keep `JSON`, `JSONL`, `OAuth`, `macOS`, and `uv` as-is.\n- Use straight double quotes with spaces for quoted content: `\"被引内容\"` (not curly quotes). Add a space before and after the quoted text when adjacent to CJK characters. Use corner brackets `「」` for special terms (e.g., `「工具」`, `「会话」`).\n- Prefer \"终端\" over \"命令行\" in Chinese when both are applicable (e.g., \"运行在终端中\", \"终端界面\", \"终端操作\").\n- Use \"工具调用\" / \"tool call\", not \"工具使用\" / \"tool use\".\n- Use inline code for tool names (e.g., `Task`, `ReadFile`, `Shell`).\n\nTerm mapping (Chinese <-> English, and proper noun handling):\n\n| Chinese | English | Proper noun (zh) | Proper noun (en) |\n| --- | --- | --- | --- |\n| Agent | agent | yes | no |\n| Shell | shell | yes | no |\n| Shell 模式 | shell mode | yes | no |\n| Print 模式 | print mode | yes | no |\n| Wire 模式 | Wire mode | yes | yes (Wire) |\n| Thinking 模式 | thinking mode | yes | no |\n| MCP | MCP | yes | yes |\n| ACP | ACP | yes | yes |\n| Kimi Code CLI | Kimi Code CLI | yes | yes |\n| Agent Skills | Agent Skills | yes | yes |\n| Skill | skill | yes | no |\n| 系统提示词 | system prompt | no | no |\n| 提示词 | prompt | no | no |\n| 会话 | session | no | no |\n| 上下文 | context | no | no |\n| 子 Agent | subagent | yes (Agent) | no |\n| API 密钥 | API key | yes | no |\n| JSON | JSON | yes | yes |\n| JSONL | JSONL | yes | yes |\n| OAuth | OAuth | yes | yes |\n| macOS | macOS | yes | yes |\n| uv | uv | yes | yes |\n| 审批请求 | approval request | no | no |\n| 斜杠命令 | slash command | no | no |\n| 工具调用 | tool call | no | no |\n| Frontmatter | frontmatter | yes | no |\n| User 消息 | user message | yes (User) | no |\n| Assistant 消息 | assistant message | yes (Assistant) | no |\n| Tool 消息 | tool message | yes (Tool) | no |\n| 轮次 | turn | no | no |\n| 供应商 | provider | no | no |\n| Prompt Flow | Prompt Flow | yes | yes |\n| Ralph 循环 | Ralph Loop | yes | yes |\n| Diff | diff | yes | no |\n\nJetBrains IDE terminology (Chinese UI translations):\n\n| English | Chinese |\n| --- | --- |\n| AI Chat | AI 聊天 |\n| Registry | 注册表 |\n| Configure ACP agents | (未翻译) |\n\n## Typography\n\n- **Spacing around mixed content**: Add a space between Chinese characters and English words, numbers, inline code, or links. Exception: no space before full-width punctuation.\n  - ✓ 在 Python 中使用 \\`class\\` 关键字\n  - ✗ 在Python中使用\\`class\\`关键字\n  - ✓ 详见 \\[配置文件\\](./config.md)。\n  - ✗ 详见\\[配置文件\\](./config.md)。\n- **Full-width punctuation**: Use full-width punctuation in Chinese text: `，。；：？！（）` not `, . ; : ? ! ( )`.\n- **Code block language**: Always specify language for fenced code blocks (e.g., ` ```sh `, ` ```toml `, ` ```json `). Exception: natural language examples (user prompts) may omit the language.\n- **Callout titles**: Use short category titles for callout blocks (`::: tip`, `::: warning`, `::: info`, `::: danger`). Put the detailed description in the block content, not the title.\n  - Chinese: use `提示` for tip, `注意` for warning, `说明` for info, `警告` for danger.\n  - English: use no title or short words like `Note` for warning.\n  - ✓ `::: tip 提示` + content starting with the key point\n  - ✓ `::: warning 注意` + content `\\`KIMI_SHARE_DIR\\` 不影响 Skills 的搜索路径。...`\n  - ✗ `::: warning 不影响 Skills` (title too long, should be in content)\n  - ✗ `::: tip Skills 路径独立于 KIMI_SHARE_DIR` (title too long)\n- **Version info blocks**: For version change callouts, use `::: info` with a category title (Added/Changed/Removed in English; 新增/变更/移除 in Chinese). The content should be a complete sentence.\n  - ✓ `::: info 新增` + content `新增于 Wire 1.2。`\n  - ✗ `::: info 新增于 Wire 1.2` (title too long)\n  - ✓ `::: info Changed` + content `Renamed in Wire 1.1. ...`\n  - ✗ `::: info Renamed in Wire 1.1` (title too long)\n\n## Writing style\n\n- **Natural narrative**: Organize content like writing an article, guiding readers smoothly through the material.\n- **Avoid fragmentation**: Don't turn every point into a subheading; use paragraph transitions instead.\n- **Global perspective**: \"Getting Started\" introduces core concepts only; detailed usage belongs in later pages.\n- **Progressive depth**: Guides → Customization → Configuration → Reference, information deepens gradually.\n- **No \"next steps\"**: VitePress already provides prev/next navigation; don't add manual `::: tip 接下来` blocks at page end.\n\n### Example: good vs bad\n\nOutline prompt:\n\n```\n* Install and upgrade\n  * System requirements: Python 3.12+, recommend uv\n  * Install, upgrade, uninstall steps\n```\n\n**Bad** (mechanical conversion to headings):\n\n```markdown\n## Install and upgrade\n\n### System requirements\n\n- Python 3.12+\n- Recommend uv\n\n### Install\n\n...\n\n### Upgrade\n\n...\n```\n\n**Good** (natural narrative):\n\n```markdown\n## Install and upgrade\n\nKimi Code CLI requires Python 3.12+. We recommend using uv for installation and management.\n\nIf you haven't installed uv yet, please refer to the uv installation docs first. Install Kimi Code CLI:\n\n(code block)\n\nVerify the installation:\n\n(code block)\n\nUpgrade to the latest version:\n\n(code block)\n```\n\n## Build and preview\n\n- Docs are built with VitePress from `docs/`.\n- Common commands (run inside `docs/`):\n  - `npm install` (or `bun install` if you use bun)\n  - `npm run dev`\n  - `npm run build`\n  - `npm run preview`\n- The build output is `docs/.vitepress/dist`.\n\n## Changelog syncing\n\nThe English changelog (`docs/en/release-notes/changelog.md`) is auto-generated from the root `CHANGELOG.md`. Do not edit it manually.\n\n- The sync script is `docs/scripts/sync-changelog.mjs`.\n- It runs automatically before `npm run dev` and `npm run build`.\n- To run manually: `npm run sync` (from the `docs/` directory).\n- The script converts title format (`## [0.69] - 2025-12-29` → `## 0.69 (2025-12-29)`) and removes HTML comments.\n"
  },
  {
    "path": "docs/en/configuration/config-files.md",
    "content": "# Config Files\n\nKimi Code CLI uses configuration files to manage API providers, models, services, and runtime parameters, supporting both TOML and JSON formats.\n\n## Config file location\n\nThe default configuration file is located at `~/.kimi/config.toml`. On first run, if the configuration file doesn't exist, Kimi Code CLI will automatically create a default configuration file.\n\nYou can specify a different configuration file (TOML or JSON format) with the `--config-file` flag:\n\n```sh\nkimi --config-file /path/to/config.toml\n```\n\nWhen calling Kimi Code CLI programmatically, you can also pass the complete configuration content directly via the `--config` flag:\n\n```sh\nkimi --config '{\"default_model\": \"kimi-for-coding\", \"providers\": {...}, \"models\": {...}}'\n```\n\n## Config items\n\nThe configuration file contains the following top-level configuration items:\n\n| Item | Type | Description |\n| --- | --- | --- |\n| `default_model` | `string` | Default model name, must be a model defined in `models` |\n| `default_thinking` | `boolean` | Whether to enable thinking mode by default (defaults to `false`) |\n| `default_yolo` | `boolean` | Whether to enable YOLO (auto-approve) mode by default (defaults to `false`) |\n| `default_editor` | `string` | Default external editor command (e.g. `\"vim\"`, `\"code --wait\"`), auto-detects when empty |\n| `providers` | `table` | API provider configuration |\n| `models` | `table` | Model configuration |\n| `loop_control` | `table` | Agent loop control parameters |\n| `background` | `table` | Background task runtime parameters |\n| `services` | `table` | External service configuration (search, fetch) |\n| `mcp` | `table` | MCP client configuration |\n\n### Complete configuration example\n\n```toml\ndefault_model = \"kimi-for-coding\"\ndefault_thinking = false\ndefault_yolo = false\ndefault_editor = \"\"\n\n[providers.kimi-for-coding]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"sk-xxx\"\n\n[models.kimi-for-coding]\nprovider = \"kimi-for-coding\"\nmodel = \"kimi-for-coding\"\nmax_context_size = 262144\n\n[loop_control]\nmax_steps_per_turn = 100\nmax_retries_per_step = 3\nmax_ralph_iterations = 0\nreserved_context_size = 50000\ncompaction_trigger_ratio = 0.85\n\n[background]\nmax_running_tasks = 4\nkeep_alive_on_exit = false\n\n[services.moonshot_search]\nbase_url = \"https://api.kimi.com/coding/v1/search\"\napi_key = \"sk-xxx\"\n\n[services.moonshot_fetch]\nbase_url = \"https://api.kimi.com/coding/v1/fetch\"\napi_key = \"sk-xxx\"\n\n[mcp.client]\ntool_call_timeout_ms = 60000\n```\n\n### `providers`\n\n`providers` defines API provider connection information. Each provider uses a unique name as key.\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `type` | `string` | Yes | Provider type, see [Providers](./providers.md) for details |\n| `base_url` | `string` | Yes | API base URL |\n| `api_key` | `string` | Yes | API key |\n| `env` | `table` | No | Environment variables to set before creating provider instance |\n| `custom_headers` | `table` | No | Custom HTTP headers to attach to requests |\n\nExample:\n\n```toml\n[providers.moonshot-cn]\ntype = \"kimi\"\nbase_url = \"https://api.moonshot.cn/v1\"\napi_key = \"sk-xxx\"\ncustom_headers = { \"X-Custom-Header\" = \"value\" }\n```\n\n### `models`\n\n`models` defines available models. Each model uses a unique name as key.\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `provider` | `string` | Yes | Provider name to use, must be defined in `providers` |\n| `model` | `string` | Yes | Model identifier (model name used in API) |\n| `max_context_size` | `integer` | Yes | Maximum context length (in tokens) |\n| `capabilities` | `array` | No | Model capability list, see [Providers](./providers.md#model-capabilities) for details |\n\nExample:\n\n```toml\n[models.kimi-k2-thinking-turbo]\nprovider = \"moonshot-cn\"\nmodel = \"kimi-k2-thinking-turbo\"\nmax_context_size = 262144\ncapabilities = [\"thinking\", \"image_in\"]\n```\n\n### `loop_control`\n\n`loop_control` controls agent execution loop behavior.\n\n| Field | Type | Default | Description |\n| --- | --- | --- | --- |\n| `max_steps_per_turn` | `integer` | `100` | Maximum steps per turn (alias: `max_steps_per_run`) |\n| `max_retries_per_step` | `integer` | `3` | Maximum retries per step |\n| `max_ralph_iterations` | `integer` | `0` | Extra iterations after each user message; `0` disables; `-1` is unlimited |\n| `reserved_context_size` | `integer` | `50000` | Reserved token count for LLM response generation; auto-compaction triggers when `context_tokens + reserved_context_size >= max_context_size` |\n| `compaction_trigger_ratio` | `float` | `0.85` | Context usage ratio threshold for auto-compaction (0.5–0.99); auto-compaction triggers when `context_tokens >= max_context_size * compaction_trigger_ratio`, whichever condition is met first with `reserved_context_size` |\n\n### `background`\n\n`background` controls background task runtime behavior. Background tasks are launched via the `Shell` tool with `run_in_background=true`.\n\n| Field | Type | Default | Description |\n| --- | --- | --- | --- |\n| `max_running_tasks` | `integer` | `4` | Maximum number of concurrent background tasks |\n| `keep_alive_on_exit` | `boolean` | `false` | Whether to keep background tasks running when CLI exits; default is to terminate all background tasks on exit |\n\n### `services`\n\n`services` configures external services used by Kimi Code CLI.\n\n#### `moonshot_search`\n\nConfigures web search service. When enabled, the `SearchWeb` tool becomes available.\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `base_url` | `string` | Yes | Search service API URL |\n| `api_key` | `string` | Yes | API key |\n| `custom_headers` | `table` | No | Custom HTTP headers to attach to requests |\n\n#### `moonshot_fetch`\n\nConfigures web fetch service. When enabled, the `FetchURL` tool prioritizes using this service to fetch webpage content.\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `base_url` | `string` | Yes | Fetch service API URL |\n| `api_key` | `string` | Yes | API key |\n| `custom_headers` | `table` | No | Custom HTTP headers to attach to requests |\n\n::: tip\nWhen configuring the Kimi Code platform using the `/login` command, search and fetch services are automatically configured.\n:::\n\n### `mcp`\n\n`mcp` configures MCP client behavior.\n\n| Field | Type | Default | Description |\n| --- | --- | --- | --- |\n| `client.tool_call_timeout_ms` | `integer` | `60000` | MCP tool call timeout (milliseconds) |\n\n## JSON configuration migration\n\nIf `~/.kimi/config.toml` doesn't exist but `~/.kimi/config.json` exists, Kimi Code CLI will automatically migrate the JSON configuration to TOML format and backup the original file as `config.json.bak`.\n\n`--config-file` specified configuration files are parsed based on file extension. `--config` passed configuration content is first attempted as JSON, then falls back to TOML if that fails.\n"
  },
  {
    "path": "docs/en/configuration/data-locations.md",
    "content": "# Data Locations\n\nKimi Code CLI stores all data in the `~/.kimi/` directory under the user's home directory. This page describes the locations and purposes of various data files.\n\n::: tip\nYou can customize the share directory path by setting the `KIMI_SHARE_DIR` environment variable. See [Environment Variables](./env-vars.md#kimi-share-dir) for details.\n\nNote: `KIMI_SHARE_DIR` only affects the storage location of the runtime data listed above, not the [Agent Skills](../customization/skills.md) search paths. Skills, as cross-tool shared capability extensions, are a different type of data from application runtime data.\n:::\n\n## Directory structure\n\n```\n~/.kimi/\n├── config.toml           # Main configuration file\n├── kimi.json             # Metadata\n├── mcp.json              # MCP server configuration\n├── credentials/          # OAuth credentials\n│   └── <provider>.json\n├── sessions/             # Session data\n│   └── <work-dir-hash>/\n│       └── <session-id>/\n│           ├── context.jsonl\n│           ├── wire.jsonl\n│           └── state.json\n├── imported_sessions/    # Imported session data (via kimi vis)\n│   └── <session-id>/\n│       ├── context.jsonl\n│       ├── wire.jsonl\n│       └── state.json\n├── plans/                # Plan mode plan files\n│   └── <slug>.md\n├── user-history/         # Input history\n│   └── <work-dir-hash>.jsonl\n└── logs/                 # Logs\n    └── kimi.log\n```\n\n## Configuration and metadata\n\n### `config.toml`\n\nMain configuration file, stores providers, models, services, and runtime parameters. See [Config Files](./config-files.md) for details.\n\nYou can specify a configuration file at a different location with the `--config-file` flag.\n\n### `kimi.json`\n\nMetadata file, stores Kimi Code CLI's runtime state, including:\n\n- `work_dirs`: List of working directories and their last used session IDs\n- `thinking`: Whether thinking mode was enabled in the last session\n\nThis file is automatically managed by Kimi Code CLI and typically doesn't need manual editing.\n\n### `mcp.json`\n\nMCP server configuration file, stores MCP servers added via the `kimi mcp add` command. See [MCP](../customization/mcp.md) for details.\n\nExample structure:\n\n```json\n{\n  \"mcpServers\": {\n    \"context7\": {\n      \"url\": \"https://mcp.context7.com/mcp\",\n      \"transport\": \"http\",\n      \"headers\": {\n        \"CONTEXT7_API_KEY\": \"ctx7sk-xxx\"\n      }\n    }\n  }\n}\n```\n\n## Credentials\n\nOAuth credentials are stored in the `~/.kimi/credentials/` directory. After logging in to your Kimi account via `/login`, OAuth tokens are saved in this directory.\n\nFiles in this directory have permissions set to read/write for the current user only (600) to protect sensitive information.\n\n## Session data\n\nSession data is grouped by working directory and stored under `~/.kimi/sessions/`. Each working directory corresponds to a subdirectory named with the path's MD5 hash, and each session corresponds to a subdirectory named with the session ID.\n\n### `context.jsonl`\n\nContext history file, stores the session's full context in JSON Lines (JSONL) format. The first line is a system prompt record (`_system_prompt`), followed by messages (user input, model response, tool calls, etc.) and internal records (checkpoints, token usage, etc.).\n\nThe system prompt is generated and frozen at session creation time, and reused on session restore instead of being regenerated.\n\nKimi Code CLI uses this file to restore session context when using `--continue` or `--session`.\n\n### `wire.jsonl`\n\nWire message log file, stores Wire events during the session in JSON Lines (JSONL) format. Used for session replay and extracting session titles.\n\n### `state.json`\n\nSession state file, stores the session's runtime state, including:\n\n- `approval`: Approval decision state (YOLO mode on/off, auto-approved operation types)\n- `plan_mode`: Plan mode on/off status\n- `plan_session_id`: Unique identifier for the current plan session, used to associate the plan file\n- `plan_slug`: The file path identifier for the plan (the slug in `~/.kimi/plans/<slug>.md`), preserved so restarts resume the same file\n- `dynamic_subagents`: Dynamically created subagent definitions\n- `additional_dirs`: Additional workspace directories added via `--add-dir` or `/add-dir`\n\nWhen resuming a session, Kimi Code CLI reads this file to restore the session state. This file uses atomic writes to prevent data corruption on crash.\n\n## Plan files\n\nPlan mode plan files are stored in the `~/.kimi/plans/` directory. Each plan session corresponds to a randomly named Markdown file (e.g. `<slug>.md`).\n\nThe `plan_slug` is saved in `state.json`, so the same plan file is resumed after a process restart. Use `/plan clear` to delete the current plan session's file.\n\n## Input history\n\nUser input history is stored in the `~/.kimi/user-history/` directory. Each working directory corresponds to a `.jsonl` file named with the path's MD5 hash.\n\nInput history is used for history browsing (up/down arrow keys) and search (Ctrl-R) in shell mode.\n\n## Logs\n\nRuntime logs are stored in `~/.kimi/logs/kimi.log`. Default log level is INFO, use the `--debug` flag to enable TRACE level.\n\nLog files are used for troubleshooting. When reporting bugs, please include relevant log content.\n\n## Cleaning data\n\nDeleting the share directory (default `~/.kimi/`, or the path specified by `KIMI_SHARE_DIR`) completely clears all Kimi Code CLI data, including configuration, sessions, and history.\n\nTo clean only specific data:\n\n| Need | Action |\n| --- | --- |\n| Reset configuration | Delete `~/.kimi/config.toml` |\n| Clear all sessions | Delete `~/.kimi/sessions/` directory |\n| Clear sessions for specific working directory | Use `/sessions` in shell mode to view and delete |\n| Clear plan files | Delete `~/.kimi/plans/` directory, or use `/plan clear` in plan mode |\n| Clear input history | Delete `~/.kimi/user-history/` directory |\n| Clear logs | Delete `~/.kimi/logs/` directory |\n| Clear MCP configuration | Delete `~/.kimi/mcp.json` or use `kimi mcp remove` |\n| Clear login credentials | Delete `~/.kimi/credentials/` directory or use `/logout` |\n"
  },
  {
    "path": "docs/en/configuration/env-vars.md",
    "content": "# Environment Variables\n\nKimi Code CLI supports overriding configuration or controlling runtime behavior through environment variables. This page lists all supported environment variables.\n\nFor detailed information on how environment variables override configuration files, see [Config Overrides](./overrides.md).\n\n## Kimi environment variables\n\nThe following environment variables take effect when using `kimi` type providers, used to override provider and model configuration.\n\n| Environment Variable | Description |\n| --- | --- |\n| `KIMI_BASE_URL` | API base URL |\n| `KIMI_API_KEY` | API key |\n| `KIMI_MODEL_NAME` | Model identifier |\n| `KIMI_MODEL_MAX_CONTEXT_SIZE` | Maximum context length (in tokens) |\n| `KIMI_MODEL_CAPABILITIES` | Model capabilities, comma-separated (e.g., `thinking,image_in`) |\n| `KIMI_MODEL_TEMPERATURE` | Generation parameter `temperature` |\n| `KIMI_MODEL_TOP_P` | Generation parameter `top_p` |\n| `KIMI_MODEL_MAX_TOKENS` | Generation parameter `max_tokens` |\n\n### `KIMI_BASE_URL`\n\nOverrides the provider's `base_url` field in the configuration file.\n\n```sh\nexport KIMI_BASE_URL=\"https://api.moonshot.cn/v1\"\n```\n\n### `KIMI_API_KEY`\n\nOverrides the provider's `api_key` field in the configuration file. Used to inject API keys without modifying the configuration file, suitable for CI/CD environments.\n\n```sh\nexport KIMI_API_KEY=\"sk-xxx\"\n```\n\n### `KIMI_MODEL_NAME`\n\nOverrides the model's `model` field in the configuration file (the model identifier used in API calls).\n\n```sh\nexport KIMI_MODEL_NAME=\"kimi-k2-thinking-turbo\"\n```\n\n### `KIMI_MODEL_MAX_CONTEXT_SIZE`\n\nOverrides the model's `max_context_size` field in the configuration file. Must be a positive integer.\n\n```sh\nexport KIMI_MODEL_MAX_CONTEXT_SIZE=\"262144\"\n```\n\n### `KIMI_MODEL_CAPABILITIES`\n\nOverrides the model's `capabilities` field in the configuration file. Multiple capabilities are comma-separated, supported values are `thinking`, `always_thinking`, `image_in`, and `video_in`.\n\n```sh\nexport KIMI_MODEL_CAPABILITIES=\"thinking,image_in\"\n```\n\n### `KIMI_MODEL_TEMPERATURE`\n\nSets the generation parameter `temperature`, controlling output randomness. Higher values produce more random output, lower values produce more deterministic output.\n\n```sh\nexport KIMI_MODEL_TEMPERATURE=\"0.7\"\n```\n\n### `KIMI_MODEL_TOP_P`\n\nSets the generation parameter `top_p` (nucleus sampling), controlling output diversity.\n\n```sh\nexport KIMI_MODEL_TOP_P=\"0.9\"\n```\n\n### `KIMI_MODEL_MAX_TOKENS`\n\nSets the generation parameter `max_tokens`, limiting the maximum tokens per response.\n\n```sh\nexport KIMI_MODEL_MAX_TOKENS=\"4096\"\n```\n\n## OpenAI-compatible environment variables\n\nThe following environment variables take effect when using `openai_legacy` or `openai_responses` type providers.\n\n| Environment Variable | Description |\n| --- | --- |\n| `OPENAI_BASE_URL` | API base URL |\n| `OPENAI_API_KEY` | API key |\n\n### `OPENAI_BASE_URL`\n\nOverrides the provider's `base_url` field in the configuration file.\n\n```sh\nexport OPENAI_BASE_URL=\"https://api.openai.com/v1\"\n```\n\n### `OPENAI_API_KEY`\n\nOverrides the provider's `api_key` field in the configuration file.\n\n```sh\nexport OPENAI_API_KEY=\"sk-xxx\"\n```\n\n## Other environment variables\n\n| Environment Variable | Description |\n| --- | --- |\n| `KIMI_SHARE_DIR` | Customize the share directory path (default: `~/.kimi`) |\n| `KIMI_CLI_NO_AUTO_UPDATE` | Disable automatic update check |\n\n### `KIMI_SHARE_DIR`\n\nCustomize the share directory path for Kimi Code CLI. The default path is `~/.kimi`, where configuration, sessions, logs, and other runtime data are stored.\n\n```sh\nexport KIMI_SHARE_DIR=\"/path/to/custom/kimi\"\n```\n\nSee [Data Locations](./data-locations.md) for details.\n\n::: warning Note\n`KIMI_SHARE_DIR` does not affect [Agent Skills](../customization/skills.md) search paths. Skills are cross-tool shared capability extensions (compatible with Claude, Codex, etc.), which is a different type of data from application runtime data. To override Skills paths, use the `--skills-dir` flag.\n:::\n\n### `KIMI_CLI_NO_AUTO_UPDATE`\n\nWhen set to `1`, `true`, `t`, `yes`, or `y` (case-insensitive), disables background auto-update check in shell mode.\n\n```sh\nexport KIMI_CLI_NO_AUTO_UPDATE=\"1\"\n```\n\n::: tip\nIf you installed Kimi Code CLI via Nix or other package managers, this environment variable is typically set automatically since updates are handled by the package manager.\n:::\n"
  },
  {
    "path": "docs/en/configuration/overrides.md",
    "content": "# Config Overrides\n\nKimi Code CLI configuration can be set through multiple methods, with different sources overriding each other by priority.\n\n## Priority\n\nConfiguration priority from highest to lowest:\n\n1. **Environment variables** - Highest priority, for temporary overrides or CI/CD environments\n2. **CLI flags** - Flags specified at startup\n3. **Configuration file** - `~/.kimi/config.toml` or file specified via `--config-file`\n\n## CLI flags\n\n### Configuration file related\n\n| Flag | Description |\n| --- | --- |\n| `--config <TOML/JSON>` | Pass configuration content directly, overrides default config file |\n| `--config-file <PATH>` | Specify configuration file path, replaces default `~/.kimi/config.toml` |\n\n`--config` and `--config-file` cannot be used together.\n\n### Model related\n\n| Flag | Description |\n| --- | --- |\n| `--model, -m <NAME>` | Specify model name to use |\n\nThe model specified by `--model` must be defined in the configuration file's `models`. If not specified, uses `default_model` from the configuration file.\n\n### Behavior related\n\n| Flag | Description |\n| --- | --- |\n| `--thinking` | Enable thinking mode |\n| `--no-thinking` | Disable thinking mode |\n| `--yolo, --yes, -y` | Auto-approve all operations |\n\n`--thinking` / `--no-thinking` overrides the thinking state saved from the last session. If not specified, uses the last session's state.\n\n## Environment variable overrides\n\nEnvironment variables can override provider and model settings without modifying the configuration file. This is particularly useful in the following scenarios:\n\n- Injecting keys in CI/CD environments\n- Temporarily testing different API endpoints\n- Switching between multiple environments\n\nEnvironment variables take effect based on the current provider type:\n\n- `kimi` type providers: Use `KIMI_*` environment variables\n- `openai_legacy` or `openai_responses` type providers: Use `OPENAI_*` environment variables\n- Other provider types: Environment variable overrides not supported\n\nSee [Environment Variables](./env-vars.md) for the complete list.\n\nExample:\n\n```sh\nKIMI_API_KEY=\"sk-xxx\" KIMI_MODEL_NAME=\"kimi-k2-thinking-turbo\" kimi\n```\n\n## Configuration priority example\n\nAssume the configuration file `~/.kimi/config.toml` contains:\n\n```toml\ndefault_model = \"kimi-for-coding\"\n\n[providers.kimi-for-coding]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"sk-config\"\n\n[models.kimi-for-coding]\nprovider = \"kimi-for-coding\"\nmodel = \"kimi-for-coding\"\nmax_context_size = 262144\n```\n\nHere are the configuration sources in different scenarios:\n\n| Scenario | `base_url` | `api_key` | `model` |\n| --- | --- | --- | --- |\n| `kimi` | Config file | Config file | Config file |\n| `KIMI_API_KEY=sk-env kimi` | Config file | Environment variable | Config file |\n| `kimi --model other` | Config file | Config file | CLI flag |\n| `KIMI_MODEL_NAME=k2 kimi` | Config file | Config file | Environment variable |\n"
  },
  {
    "path": "docs/en/configuration/providers.md",
    "content": "# Providers and Models\n\nKimi Code CLI supports multiple LLM platforms, which can be configured via configuration files or the `/login` command.\n\n## Platform selection\n\nThe easiest way to configure is to run the `/login` command (alias `/setup`) in shell mode and follow the wizard to select platform and model:\n\n1. Select an API platform\n2. Enter your API key\n3. Select a model from the available list\n\nAfter configuration, Kimi Code CLI will automatically save settings to `~/.kimi/config.toml` and reload.\n\n`/login` currently supports the following platforms:\n\n| Platform | Description |\n| --- | --- |\n| Kimi Code | Kimi Code platform, supports search and fetch services |\n| Moonshot AI Open Platform (moonshot.cn) | China region API endpoint |\n| Moonshot AI Open Platform (moonshot.ai) | Global region API endpoint |\n\nFor other platforms, please manually edit the configuration file.\n\n## Provider types\n\nThe `type` field in `providers` configuration specifies the API provider type. Different types use different API protocols and client implementations.\n\n| Type | Description |\n| --- | --- |\n| `kimi` | Kimi API |\n| `openai_legacy` | OpenAI Chat Completions API |\n| `openai_responses` | OpenAI Responses API |\n| `anthropic` | Anthropic Claude API |\n| `gemini` | Google Gemini API |\n| `vertexai` | Google Vertex AI |\n\n### `kimi`\n\nFor connecting to Kimi API, including Kimi Code and Moonshot AI Open Platform.\n\n```toml\n[providers.kimi-for-coding]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"sk-xxx\"\n```\n\n### `openai_legacy`\n\nFor platforms compatible with OpenAI Chat Completions API, including the official OpenAI API and various compatible services.\n\n```toml\n[providers.openai]\ntype = \"openai_legacy\"\nbase_url = \"https://api.openai.com/v1\"\napi_key = \"sk-xxx\"\n```\n\n### `openai_responses`\n\nFor OpenAI Responses API (newer API format).\n\n```toml\n[providers.openai-responses]\ntype = \"openai_responses\"\nbase_url = \"https://api.openai.com/v1\"\napi_key = \"sk-xxx\"\n```\n\n### `anthropic`\n\nFor connecting to Anthropic Claude API.\n\n```toml\n[providers.anthropic]\ntype = \"anthropic\"\nbase_url = \"https://api.anthropic.com\"\napi_key = \"sk-ant-xxx\"\n```\n\n### `gemini`\n\nFor connecting to Google Gemini API.\n\n```toml\n[providers.gemini]\ntype = \"gemini\"\nbase_url = \"https://generativelanguage.googleapis.com\"\napi_key = \"xxx\"\n```\n\n### `vertexai`\n\nFor connecting to Google Vertex AI. Requires setting necessary environment variables via the `env` field.\n\n```toml\n[providers.vertexai]\ntype = \"vertexai\"\nbase_url = \"https://xxx-aiplatform.googleapis.com\"\napi_key = \"\"\nenv = { GOOGLE_CLOUD_PROJECT = \"your-project-id\" }\n```\n\n## Model capabilities\n\nThe `capabilities` field in model configuration declares the capabilities supported by the model. This affects feature availability in Kimi Code CLI.\n\n| Capability | Description |\n| --- | --- |\n| `thinking` | Supports thinking mode (deep reasoning), can be toggled |\n| `always_thinking` | Always uses thinking mode (cannot be disabled) |\n| `image_in` | Supports image input |\n| `video_in` | Supports video input |\n\n```toml\n[models.gemini-3-pro-preview]\nprovider = \"gemini\"\nmodel = \"gemini-3-pro-preview\"\nmax_context_size = 262144\ncapabilities = [\"thinking\", \"image_in\"]\n```\n\n### `thinking`\n\nDeclares that the model supports thinking mode. When enabled, the model performs deeper reasoning before answering, suitable for complex problems. In shell mode, you can use the `/model` command to switch models and thinking mode, or control it at startup with `--thinking` / `--no-thinking` flags.\n\n### `always_thinking`\n\nIndicates the model always uses thinking mode and cannot be disabled. For example, models with \"thinking\" in their name like `kimi-k2-thinking-turbo` typically have this capability. When using such models, the `/model` command won't prompt for thinking mode toggle.\n\n### `image_in`\n\nWhen image input capability is enabled, you can paste images in conversations (`Ctrl-V`).\n\n### `video_in`\n\nWhen video input capability is enabled, you can send video content in conversations.\n\n## Search and fetch services\n\nThe `SearchWeb` and `FetchURL` tools depend on external services, currently only provided by the Kimi Code platform.\n\nWhen selecting the Kimi Code platform using `/login`, search and fetch services are automatically configured.\n\n| Service | Corresponding tool | Behavior when not configured |\n| --- | --- | --- |\n| `moonshot_search` | `SearchWeb` | Tool unavailable |\n| `moonshot_fetch` | `FetchURL` | Falls back to local fetching |\n\nWhen using other platforms, the `FetchURL` tool is still available but will fall back to local fetching.\n"
  },
  {
    "path": "docs/en/customization/agents.md",
    "content": "# Agents and Subagents\n\nAn agent defines the AI's behavior, including system prompts, available tools, and subagents. You can use built-in agents or create custom agents.\n\n## Built-in agents\n\nKimi Code CLI provides two built-in agents. You can select one at startup with the `--agent` flag:\n\n```sh\nkimi --agent okabe\n```\n\n### `default`\n\nThe default agent, suitable for general use. Enabled tools:\n\n`Task`, `AskUserQuestion`, `SetTodoList`, `Shell`, `ReadFile`, `ReadMediaFile`, `Glob`, `Grep`, `WriteFile`, `StrReplaceFile`, `SearchWeb`, `FetchURL`, `EnterPlanMode`, `ExitPlanMode`, `TaskList`, `TaskOutput`, `TaskStop`\n\n### `okabe`\n\nAn experimental agent for testing new prompts and tools. Adds `SendDMail` on top of `default`.\n\n## Custom agent files\n\nAgents are defined in YAML format. Load a custom agent with the `--agent-file` flag:\n\n```sh\nkimi --agent-file /path/to/my-agent.yaml\n```\n\n**Basic structure**\n\n```yaml\nversion: 1\nagent:\n  name: my-agent\n  system_prompt_path: ./system.md\n  tools:\n    - \"kimi_cli.tools.shell:Shell\"\n    - \"kimi_cli.tools.file:ReadFile\"\n    - \"kimi_cli.tools.file:WriteFile\"\n```\n\n**Inheritance and overrides**\n\nUse `extend` to inherit another agent's configuration and only override what you need to change:\n\n```yaml\nversion: 1\nagent:\n  extend: default  # Inherit from default agent\n  system_prompt_path: ./my-prompt.md  # Override system prompt\n  exclude_tools:  # Exclude certain tools\n    - \"kimi_cli.tools.web:SearchWeb\"\n    - \"kimi_cli.tools.web:FetchURL\"\n```\n\n`extend: default` inherits from the built-in default agent. You can also specify a relative path to inherit from another agent file.\n\n**Configuration fields**\n\n| Field | Description | Required |\n|-------|-------------|----------|\n| `extend` | Agent to inherit from, can be `default` or a relative path | No |\n| `name` | Agent name | Yes (optional when inheriting) |\n| `system_prompt_path` | System prompt file path, relative to agent file | Yes (optional when inheriting) |\n| `system_prompt_args` | Custom arguments passed to system prompt, merged when inheriting | No |\n| `tools` | Tool list, format is `module:ClassName` | Yes (optional when inheriting) |\n| `exclude_tools` | Tools to exclude | No |\n| `subagents` | Subagent definitions | No |\n\n## System prompt built-in parameters\n\nThe system prompt file is a Markdown template that can use `${VAR}` syntax to reference variables. Built-in variables include:\n\n| Variable | Description |\n|----------|-------------|\n| `${KIMI_NOW}` | Current time (ISO format) |\n| `${KIMI_WORK_DIR}` | Working directory path |\n| `${KIMI_WORK_DIR_LS}` | Working directory file list |\n| `${KIMI_AGENTS_MD}` | AGENTS.md file content (if exists) |\n| `${KIMI_SKILLS}` | Loaded skills list |\n| `${KIMI_ADDITIONAL_DIRS_INFO}` | Information about additional directories added via `--add-dir` or `/add-dir` |\n\nYou can also define custom parameters via `system_prompt_args`:\n\n```yaml\nagent:\n  system_prompt_args:\n    MY_VAR: \"custom value\"\n```\n\nThen use `${MY_VAR}` in the prompt.\n\n**System prompt example**\n\n```markdown\n# My Agent\n\nYou are a helpful assistant. Current time: ${KIMI_NOW}.\n\nWorking directory: ${KIMI_WORK_DIR}\n\n${MY_VAR}\n```\n\n## Defining subagents in agent files\n\nSubagents can handle specific types of tasks. After defining subagents in an agent file, the main agent can launch them via the `Task` tool:\n\n```yaml\nversion: 1\nagent:\n  extend: default\n  subagents:\n    coder:\n      path: ./coder-sub.yaml\n      description: \"Handle coding tasks\"\n    reviewer:\n      path: ./reviewer-sub.yaml\n      description: \"Code review expert\"\n```\n\nSubagent files are also standard agent format, typically inheriting from the main agent and excluding certain tools:\n\n```yaml\n# coder-sub.yaml\nversion: 1\nagent:\n  extend: ./agent.yaml  # Inherit from main agent\n  system_prompt_args:\n    ROLE_ADDITIONAL: |\n      You are now running as a subagent...\n  exclude_tools:\n    - \"kimi_cli.tools.multiagent:Task\"  # Exclude Task tool to avoid nesting\n```\n\n## How subagents run\n\nSubagents launched via the `Task` tool run in an isolated context and return results to the main agent when complete. Advantages of this approach:\n\n- Isolated context, avoiding pollution of main agent's conversation history\n- Multiple independent tasks can be processed in parallel\n- Subagents can have targeted system prompts\n\n## Dynamic subagent creation\n\n`CreateSubagent` is an advanced tool that allows AI to dynamically define new subagent types at runtime (not enabled by default). Dynamically created subagents are persisted with the session state and automatically restored when resuming the session. To use it, add to your agent file:\n\n```yaml\nagent:\n  tools:\n    - \"kimi_cli.tools.multiagent:CreateSubagent\"\n```\n\n## Built-in tools list\n\nThe following are all built-in tools in Kimi Code CLI.\n\n### `Task`\n\n- **Path**: `kimi_cli.tools.multiagent:Task`\n- **Description**: Dispatch a subagent to execute a task. Subagents cannot access the main agent's context; all necessary information must be provided in the prompt.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `description` | string | Short task description (3-5 words) |\n| `subagent_name` | string | Subagent name |\n| `prompt` | string | Detailed task description |\n\n### `AskUserQuestion`\n\n- **Path**: `kimi_cli.tools.ask_user:AskUserQuestion`\n- **Description**: Present structured questions and options to the user during execution, collecting preferences or decisions. Suitable for scenarios where the user needs to choose between approaches, resolve ambiguous instructions, or provide requirements. Should not be overused — only call when the user's choice genuinely affects subsequent actions.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `questions` | array | Questions list (1–4 questions) |\n| `questions[].question` | string | Question text, ending with `?` |\n| `questions[].header` | string | Short label, max 12 characters (e.g., `Auth`, `Style`) |\n| `questions[].options` | array | Available options (2–4), the system adds an \"Other\" option automatically |\n| `questions[].options[].label` | string | Option label (1–5 words), append `(Recommended)` for recommended options |\n| `questions[].options[].description` | string | Option description |\n| `questions[].multi_select` | bool | Allow multiple selections, default false |\n\n### `SetTodoList`\n\n- **Path**: `kimi_cli.tools.todo:SetTodoList`\n- **Description**: Manage todo list, track task progress\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `todos` | array | Todo list items |\n| `todos[].title` | string | Todo item title |\n| `todos[].status` | string | Status: `pending`, `in_progress`, `done` |\n\n### `Shell`\n\n- **Path**: `kimi_cli.tools.shell:Shell`\n- **Description**: Execute shell commands. Requires user approval. Uses the appropriate shell for the OS (bash/zsh on Unix, PowerShell on Windows).\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `command` | string | Command to execute |\n| `timeout` | int | Timeout in seconds, default 60, max 300 for foreground / 86400 for background |\n| `run_in_background` | bool | Whether to run as a background task, default false |\n| `description` | string | Short description for the background task, required when `run_in_background=true` |\n\nWhen `run_in_background=true`, the command is launched as a background task and the tool immediately returns a task ID, allowing the AI to continue working. The system automatically sends a notification when the task completes. Ideal for long-running builds, tests, watchers, and servers.\n\n### `ReadFile`\n\n- **Path**: `kimi_cli.tools.file:ReadFile`\n- **Description**: Read text file content. Max 1000 lines per read, max 2000 characters per line. Files outside working directory require absolute paths.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `path` | string | File path |\n| `line_offset` | int | Starting line number, default 1 |\n| `n_lines` | int | Number of lines to read, default/max 1000 |\n\n### `ReadMediaFile`\n\n- **Path**: `kimi_cli.tools.file:ReadMediaFile`\n- **Description**: Read image or video files. Max file size 100MB. Only available when the model supports image/video input. Files outside working directory require absolute paths.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `path` | string | File path |\n\n### `Glob`\n\n- **Path**: `kimi_cli.tools.file:Glob`\n- **Description**: Match files and directories by pattern. Returns max 1000 matches, patterns starting with `**` not allowed.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `pattern` | string | Glob pattern (e.g., `*.py`, `src/**/*.ts`) |\n| `directory` | string | Search directory, defaults to working directory |\n| `include_dirs` | bool | Include directories, default true |\n\n### `Grep`\n\n- **Path**: `kimi_cli.tools.file:Grep`\n- **Description**: Search file content with regular expressions, based on ripgrep\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `pattern` | string | Regular expression pattern |\n| `path` | string | Search path, defaults to current directory |\n| `glob` | string | File filter (e.g., `*.js`) |\n| `type` | string | File type (e.g., `py`, `js`, `go`) |\n| `output_mode` | string | Output mode: `files_with_matches` (default), `content`, `count_matches` |\n| `-B` | int | Show N lines before match |\n| `-A` | int | Show N lines after match |\n| `-C` | int | Show N lines before and after match |\n| `-n` | bool | Show line numbers |\n| `-i` | bool | Case insensitive |\n| `multiline` | bool | Enable multiline matching |\n| `head_limit` | int | Limit output lines |\n\n### `WriteFile`\n\n- **Path**: `kimi_cli.tools.file:WriteFile`\n- **Description**: Write files. Requires user approval. Absolute paths are required when writing files outside the working directory.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `path` | string | Absolute path |\n| `content` | string | File content |\n| `mode` | string | `overwrite` (default) or `append` |\n\n### `StrReplaceFile`\n\n- **Path**: `kimi_cli.tools.file:StrReplaceFile`\n- **Description**: Edit files using string replacement. Requires user approval. Absolute paths are required when editing files outside the working directory.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `path` | string | Absolute path |\n| `edit` | object/array | Single edit or list of edits |\n| `edit.old` | string | Original string to replace |\n| `edit.new` | string | Replacement string |\n| `edit.replace_all` | bool | Replace all matches, default false |\n\n### `SearchWeb`\n\n- **Path**: `kimi_cli.tools.web:SearchWeb`\n- **Description**: Search the web. Requires search service configuration (auto-configured on Kimi Code platform).\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `query` | string | Search keywords |\n| `limit` | int | Number of results, default 5, max 20 |\n| `include_content` | bool | Include page content, default false |\n\n### `FetchURL`\n\n- **Path**: `kimi_cli.tools.web:FetchURL`\n- **Description**: Fetch webpage content, returns extracted main text. Uses fetch service if configured, otherwise uses local HTTP request.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `url` | string | URL to fetch |\n\n### `Think`\n\n- **Path**: `kimi_cli.tools.think:Think`\n- **Description**: Let the agent record thinking process, suitable for complex reasoning scenarios\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `thought` | string | Thinking content |\n\n### `SendDMail`\n\n- **Path**: `kimi_cli.tools.dmail:SendDMail`\n- **Description**: Send delayed message (D-Mail), for checkpoint rollback scenarios\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `message` | string | Message to send |\n| `checkpoint_id` | int | Checkpoint ID to send back to (>= 0) |\n\n### `EnterPlanMode`\n\n- **Path**: `kimi_cli.tools.plan.enter:EnterPlanMode`\n- **Description**: Request to enter plan mode. After calling, an approval request is presented to the user, who can approve or reject entering plan mode. In YOLO mode, this is only used when the user explicitly requests planning or when there is significant architectural ambiguity. See [Plan mode](../guides/interaction.md#plan-mode).\n\nThis tool takes no parameters.\n\n### `ExitPlanMode`\n\n- **Path**: `kimi_cli.tools.plan:ExitPlanMode`\n- **Description**: Submit a plan for user approval while in plan mode. Before calling, the plan must be written to the plan file. This tool reads the plan file content and presents it to the user for approval. The user can select an implementation path (exit plan mode and start execution), reject (stay in plan mode and wait for feedback), or provide revision comments. See [Plan mode](../guides/interaction.md#plan-mode).\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `options` | list \\| null | When the plan contains multiple alternative implementation paths, list 2–3 options for the user to choose from. Each option has a `label` (1–8 word short name, may append \"(Recommended)\") and an optional `description` (brief summary). The labels \"Approve\", \"Reject\", and \"Revise\" are reserved and cannot be used. |\n\n### `TaskList`\n\n- **Path**: `kimi_cli.tools.background:TaskList`\n- **Description**: List background tasks in the current session. Useful for re-enumerating task IDs after context compaction or checking which tasks are still running.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `active_only` | bool | List only active tasks, default true |\n| `limit` | int | Maximum number of tasks to return (1–100), default 20 |\n\n### `TaskOutput`\n\n- **Path**: `kimi_cli.tools.background:TaskOutput`\n- **Description**: Retrieve output and status of a background task. Supports blocking wait or non-blocking query. Returns structured task metadata and an output preview; use `ReadFile` with the returned `output_path` to read the full log if output is truncated.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `task_id` | string | Task ID to query |\n| `block` | bool | Whether to wait for task completion, default true |\n| `timeout` | int | Maximum wait time in seconds when `block=true` (0–3600), default 30 |\n\n### `TaskStop`\n\n- **Path**: `kimi_cli.tools.background:TaskStop`\n- **Description**: Stop a running background task. Requires user approval. Use only when a task must be cancelled; for normal completion, wait for the automatic notification. Not available in plan mode.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `task_id` | string | Task ID to stop |\n| `reason` | string | Reason for stopping (optional), default \"Stopped by TaskStop\" |\n\n### `CreateSubagent`\n\n- **Path**: `kimi_cli.tools.multiagent:CreateSubagent`\n- **Description**: Dynamically create subagents\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `name` | string | Unique name for the subagent, used to reference in `Task` tool |\n| `system_prompt` | string | System prompt defining agent's role, capabilities, and boundaries |\n\n## Tool security boundaries\n\n**Workspace scope**\n\n- File reading and writing are typically done within the working directory (and additional directories added via `--add-dir` or `/add-dir`)\n- Absolute paths are required when reading files outside the workspace\n- Write and edit operations require user approval; absolute paths are required when operating on files outside the workspace\n\n**Approval mechanism**\n\nThe following operations require user approval:\n\n| Operation | Approval required |\n|-----------|-------------------|\n| Shell command execution | Each execution |\n| File write/edit | Each operation |\n| MCP tool calls | Each call |\n| Stop background task | Each stop |\n"
  },
  {
    "path": "docs/en/customization/mcp.md",
    "content": "# Model Context Protocol\n\n[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open protocol that allows AI models to safely interact with external tools and data sources. Kimi Code CLI supports connecting to MCP servers to extend AI capabilities.\n\n## What is MCP\n\nMCP servers provide \"tools\" for AI to use. For example, a database MCP server can provide query tools that allow AI to execute SQL queries; a browser MCP server can let AI control browsers for automation tasks.\n\nKimi Code CLI has built-in tools (file read/write, shell commands, web fetching, etc.). Through MCP, you can add more tools, such as:\n\n- Accessing specific APIs or databases\n- Controlling browsers or other applications\n- Integrating with third-party services (GitHub, Linear, Notion, etc.)\n\n## MCP server management\n\nUse the [`kimi mcp`](../reference/kimi-mcp.md) command to manage MCP servers.\n\n**Add a server**\n\nAdd an HTTP server:\n\n```sh\n# Basic usage\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp\n\n# With headers\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp \\\n  --header \"CONTEXT7_API_KEY: your-key\"\n\n# Using OAuth authentication\nkimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp\n```\n\nAdd a stdio server (local process):\n\n```sh\nkimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest\n```\n\n**List servers**\n\n```sh\nkimi mcp list\n```\n\nWhile Kimi Code CLI is running, you can also enter `/mcp` to view connected servers and loaded tools.\n\n**Remove a server**\n\n```sh\nkimi mcp remove context7\n```\n\n**OAuth authorization**\n\nFor servers using OAuth, you need to complete authorization first:\n\n```sh\nkimi mcp auth linear\n```\n\nThis will open a browser to complete the OAuth flow. After successful authorization, Kimi Code CLI will save the token for future use.\n\n**Test a server**\n\n```sh\nkimi mcp test context7\n```\n\n## MCP configuration file\n\nMCP server configuration is stored in `~/.kimi/mcp.json`, in a format compatible with other MCP clients:\n\n```json\n{\n  \"mcpServers\": {\n    \"context7\": {\n      \"url\": \"https://mcp.context7.com/mcp\",\n      \"headers\": {\n        \"CONTEXT7_API_KEY\": \"your-key\"\n      }\n    },\n    \"chrome-devtools\": {\n      \"command\": \"npx\",\n      \"args\": [\"chrome-devtools-mcp@latest\"],\n      \"env\": {\n        \"SOME_VAR\": \"value\"\n      }\n    }\n  }\n}\n```\n\n**Temporary configuration loading**\n\nUse the `--mcp-config-file` flag to load a configuration file from another location:\n\n```sh\nkimi --mcp-config-file /path/to/mcp.json\n```\n\nUse the `--mcp-config` flag to pass JSON configuration directly:\n\n```sh\nkimi --mcp-config '{\"mcpServers\": {\"test\": {\"url\": \"https://...\"}}}'\n```\n\n## Loading status\n\nMCP servers initialize asynchronously after the shell UI starts, so the interface is usable immediately. The shell status bar shows live connection progress, automatically switching to a ready state once all servers are connected. The web interface also reflects each server's connection status in real time.\n\nIf multiple MCP servers are configured, loading may take a moment. The status bar progress indicator keeps you informed while connections are being established.\n\n## Security\n\nMCP tools may access and operate external systems. Be aware of security risks.\n\n**Approval mechanism**\n\nKimi Code CLI requests user confirmation for sensitive operations (such as file modifications and command execution). MCP tools follow the same approval mechanism, with all MCP tool calls prompting for confirmation.\n\n**Prompt injection risks**\n\nContent returned by MCP tools may contain malicious instructions attempting to trick the AI into performing dangerous operations. Kimi Code CLI marks tool return content to help the AI distinguish between tool output and user instructions, but you should still:\n\n- Only use MCP servers from trusted sources\n- Check whether AI-proposed operations are reasonable\n- Keep manual approval for high-risk operations\n\n::: warning Note\nIn YOLO mode, MCP tool operations will also be automatically approved. It's recommended to only use YOLO mode when you fully trust the MCP servers.\n:::\n"
  },
  {
    "path": "docs/en/customization/print-mode.md",
    "content": "# Print Mode\n\nPrint mode lets Kimi Code CLI run non-interactively, suitable for scripting and automation scenarios.\n\n## Basic usage\n\nUse the `--print` flag to enable print mode:\n\n```sh\n# Pass instructions via -p (or -c)\nkimi --print -p \"List all Python files in the current directory\"\n\n# Pass instructions via stdin\necho \"Explain what this code does\" | kimi --print\n```\n\nPrint mode characteristics:\n\n- **Non-interactive**: Exits automatically after executing instructions\n- **Auto-approval**: Implicitly enables `--yolo` mode, all operations are auto-approved\n- **Text output**: AI responses are output to stdout\n\n<!-- TODO: Enable this example after supporting reading content from stdin and instructions from -p simultaneously\n**Pipeline examples**\n\n```sh\n# Analyze git diff and generate commit message\ngit diff --staged | kimi --print -p \"Generate a Conventional Commits compliant commit message based on this diff\"\n\n# Read file and generate documentation\ncat src/api.py | kimi --print -p \"Generate API documentation for this Python module\"\n```\n-->\n\n## Final message only\n\nUse the `--final-message-only` option to only output the final assistant message, skipping intermediate tool call processes:\n\n```sh\nkimi --print -p \"Give me a Git commit message based on the current changes\" --final-message-only\n```\n\n`--quiet` is a shortcut for `--print --output-format text --final-message-only`, suitable for scenarios where only the final result is needed:\n\n```sh\nkimi --quiet -p \"Give me a Git commit message based on the current changes\"\n```\n\n## JSON format\n\nPrint mode supports JSON format for input and output, convenient for programmatic processing. Both input and output use the [Message](#message-format) format.\n\n**JSON output**\n\nUse `--output-format=stream-json` to output in JSONL (one JSON per line) format:\n\n```sh\nkimi --print -p \"Hello\" --output-format=stream-json\n```\n\nExample output:\n\n```jsonl\n{\"role\":\"assistant\",\"content\":\"Hello! How can I help you?\"}\n```\n\nIf the AI called tools, assistant messages and tool messages are output sequentially:\n\n```jsonl\n{\"role\":\"assistant\",\"content\":\"Let me check the current directory.\",\"tool_calls\":[{\"type\":\"function\",\"id\":\"tc_1\",\"function\":{\"name\":\"Shell\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}}]}\n{\"role\":\"tool\",\"tool_call_id\":\"tc_1\",\"content\":\"file1.py\\nfile2.py\"}\n{\"role\":\"assistant\",\"content\":\"There are two Python files in the current directory.\"}\n```\n\n**JSON input**\n\nUse `--input-format=stream-json` to receive JSONL format input:\n\n```sh\necho '{\"role\":\"user\",\"content\":\"Hello\"}' | kimi --print --input-format=stream-json --output-format=stream-json\n```\n\nIn this mode, Kimi Code CLI continuously reads from stdin, processing and outputting responses for each user message received until stdin is closed.\n\n## Message format\n\nBoth input and output use a unified message format.\n\n**User message**\n\n```json\n{\"role\": \"user\", \"content\": \"Your question or instruction\"}\n```\n\nArray-form content is also supported:\n\n```json\n{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Your question\"}]}\n```\n\n**Assistant message**\n\n```json\n{\"role\": \"assistant\", \"content\": \"Response content\"}\n```\n\nAssistant message with tool calls:\n\n```json\n{\n  \"role\": \"assistant\",\n  \"content\": \"Let me execute this command.\",\n  \"tool_calls\": [\n    {\n      \"type\": \"function\",\n      \"id\": \"tc_1\",\n      \"function\": {\n        \"name\": \"Shell\",\n        \"arguments\": \"{\\\"command\\\":\\\"ls\\\"}\"\n      }\n    }\n  ]\n}\n```\n\n**Tool message**\n\n```json\n{\"role\": \"tool\", \"tool_call_id\": \"tc_1\", \"content\": \"Tool execution result\"}\n```\n\n## Use cases\n\n**CI/CD integration**\n\nAuto-generate code or perform checks in CI workflows:\n\n```sh\nkimi --print -p \"Check if there are any obvious security issues in the src/ directory, output a JSON format report\"\n```\n\n**Batch processing**\n\nCombine with shell loops for batch file processing:\n\n```sh\nfor file in src/*.py; do\n  kimi --print -p \"Add type annotations to $file\"\ndone\n```\n\n**Integration with other tools**\n\nUse as a backend for other tools, communicating via JSON format:\n\n```sh\nmy-tool | kimi --print --input-format=stream-json --output-format=stream-json | process-output\n```\n"
  },
  {
    "path": "docs/en/customization/skills.md",
    "content": "# Agent Skills\n\n[Agent Skills](https://agentskills.io/) is an open format for adding specialized knowledge and workflows to AI agents. Kimi Code CLI supports loading Agent Skills to extend AI capabilities.\n\n## What are Agent Skills\n\nA skill is a directory containing a `SKILL.md` file. When Kimi Code CLI starts, it discovers all skills and injects their names, paths, and descriptions into the system prompt. The AI will decide on its own whether to read the specific `SKILL.md` file to get detailed guidance based on the current task's needs.\n\nFor example, you can create a \"code style\" skill to tell the AI your project's naming conventions, comment styles, etc.; or create a \"security audit\" skill to have the AI focus on specific security issues when reviewing code.\n\n## Skill discovery\n\nKimi Code CLI uses a layered loading mechanism to discover skills, loading in the following priority order (later ones override skills with the same name):\n\n**Built-in skills**\n\nSkills shipped with the package, providing basic capabilities.\n\n**User-level skills**\n\nStored in the user's home directory, effective across all projects. Kimi Code CLI checks the following directories in priority order and uses the first one that exists:\n\n1. `~/.config/agents/skills/` (recommended)\n2. `~/.agents/skills/`\n3. `~/.kimi/skills/`\n4. `~/.claude/skills/`\n5. `~/.codex/skills/`\n\n**Project-level skills**\n\nStored in the project directory, only effective within that project's working directory. Kimi Code CLI checks the following directories in priority order and uses the first one that exists:\n\n1. `.agents/skills/` (recommended)\n2. `.kimi/skills/`\n3. `.claude/skills/`\n4. `.codex/skills/`\n\nYou can also specify other directories with the `--skills-dir` flag, which skips user-level and project-level skill discovery:\n\n```sh\nkimi --skills-dir /path/to/my-skills\n```\n\n::: tip\nSkills paths are independent of [`KIMI_SHARE_DIR`](../configuration/env-vars.md#kimi-share-dir). `KIMI_SHARE_DIR` customizes the storage location for configuration, sessions, logs, and other runtime data, but does not affect Skills search paths. Skills are cross-tool shared capability extensions (compatible with Kimi CLI, Claude, Codex, and others), which is a different type of data from application runtime data. To override Skills paths, use the `--skills-dir` flag.\n:::\n\n## Built-in skills\n\nKimi Code CLI includes the following built-in skills:\n\n- **kimi-cli-help**: Kimi Code CLI help. Answers questions about Kimi Code CLI installation, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, and more.\n- **skill-creator**: Guide for creating skills. When you need to create a new skill (or update an existing skill) to extend Kimi's capabilities, you can use this skill to get detailed creation guidance and best practices.\n\n## Creating a skill\n\nCreating a skill only requires two steps:\n\n1. Create a subdirectory in the skills directory\n2. Create a `SKILL.md` file in the subdirectory\n\n**Directory structure**\n\nA skill directory needs at least a `SKILL.md` file, and can also include auxiliary directories to organize more complex content:\n\n```\n~/.config/agents/skills/\n└── my-skill/\n    ├── SKILL.md          # Required: main file\n    ├── scripts/          # Optional: script files\n    ├── references/       # Optional: reference documents\n    └── assets/           # Optional: other resources\n```\n\n**`SKILL.md` format**\n\n`SKILL.md` uses YAML frontmatter to define metadata, followed by prompt content in Markdown format:\n\n```markdown\n---\nname: code-style\ndescription: My project's code style guidelines\n---\n\n## Code Style\n\nIn this project, please follow these conventions:\n\n- Use 4-space indentation\n- Variable names use camelCase\n- Function names use snake_case\n- Every function needs a docstring\n- Lines should not exceed 100 characters\n```\n\n**Frontmatter fields**\n\n| Field | Description | Required |\n|-------|-------------|----------|\n| `name` | Skill name, 1-64 characters, only lowercase letters, numbers, and hyphens allowed; defaults to directory name if omitted | No |\n| `description` | Skill description, 1-1024 characters, explaining the skill's purpose and use cases; shows \"No description provided.\" if omitted | No |\n| `license` | License name or file reference | No |\n| `compatibility` | Environment requirements, up to 500 characters | No |\n| `metadata` | Additional key-value attributes | No |\n\n**Best practices**\n\n- Keep `SKILL.md` under 500 lines, move detailed content to `scripts/`, `references/`, or `assets/` directories\n- Use relative paths in `SKILL.md` to reference other files\n- Provide clear step-by-step instructions, input/output examples, and edge case explanations\n\n## Example skills\n\n**PowerPoint creation**\n\n```markdown\n---\nname: pptx\ndescription: Create and edit PowerPoint presentations\n---\n\n## PPT Creation Workflow\n\nWhen creating presentations, follow these steps:\n\n1. Analyze content structure, plan slide outline\n2. Choose appropriate color scheme and fonts\n3. Use python-pptx library to generate .pptx files\n\n## Design Principles\n\n- Each slide focuses on one topic\n- Keep text concise, use bullet points instead of long paragraphs\n- Maintain clear visual hierarchy with distinct titles, body, and notes\n- Use consistent colors, avoid more than 3 main colors\n```\n\n**Python project standards**\n\n```markdown\n---\nname: python-project\ndescription: Python project development standards, including code style, testing, and dependency management\n---\n\n## Python Development Standards\n\n- Use Python 3.14+\n- Use ruff for code formatting and linting\n- Use pyright for type checking\n- Use pytest for testing\n- Use uv for dependency management\n\nCode style:\n- Line length limit 100 characters\n- Use type annotations\n- Public functions need docstrings\n```\n\n**Git commit conventions**\n\n```markdown\n---\nname: git-commits\ndescription: Git commit message conventions using Conventional Commits format\n---\n\n## Git Commit Conventions\n\nUse Conventional Commits format:\n\ntype(scope): description\n\nAllowed types: feat, fix, docs, style, refactor, test, chore\n\nExamples:\n- feat(auth): add OAuth login support\n- fix(api): fix user query returning null\n- docs(readme): update installation instructions\n```\n\n## Using slash commands to load a skill\n\nThe `/skill:<name>` slash command lets you save commonly used prompt templates as skills and quickly invoke them when needed. When you enter the command, Kimi Code CLI reads the corresponding `SKILL.md` file content and sends it to the Agent as a prompt.\n\nFor example:\n\n- `/skill:code-style`: Load code style guidelines\n- `/skill:pptx`: Load PPT creation workflow\n- `/skill:git-commits fix user login issue`: Load Git commit conventions with an additional task description\n\nYou can append additional text after the slash command, which will be added to the skill prompt as the user's specific request.\n\n::: tip\nFor regular conversations, the Agent will automatically decide whether to read skill content based on context, so you don't need to invoke it manually.\n:::\n\nSkills allow you to codify your team's best practices and project standards, ensuring the AI always follows consistent standards.\n\n## Flow skills\n\nFlow skills are a special skill type that embed an Agent Flow diagram in `SKILL.md`, used to define multi-step automated workflows. Unlike standard skills, flow skills are invoked via `/flow:<name>` commands and automatically execute multiple conversation turns following the flow diagram.\n\n**Creating a flow skill**\n\nTo create a flow skill, set `type: flow` in the frontmatter and include a Mermaid or D2 code block in the content:\n\n````markdown\n---\nname: code-review\ndescription: Code review workflow\ntype: flow\n---\n\n```mermaid\nflowchart TD\nA([BEGIN]) --> B[Analyze code changes, list all modified files and features]\nB --> C{Is code quality acceptable?}\nC -->|Yes| D[Generate code review report]\nC -->|No| E[List issues and propose improvements]\nE --> B\nD --> F([END])\n```\n````\n\n**Flow diagram format**\n\nBoth Mermaid and D2 formats are supported:\n\n- **Mermaid**: Use ` ```mermaid ` code block, [Mermaid Playground](https://www.mermaidchart.com/play) can be used for editing and preview\n- **D2**: Use ` ```d2 ` code block, [D2 Playground](https://play.d2lang.com) can be used for editing and preview\n\nFlow diagrams must contain one `BEGIN` node and one `END` node. Regular node text is sent to the Agent as a prompt; decision nodes require the Agent to output `<choice>branch name</choice>` in the output to select the next step.\n\n**D2 format example**\n\n```\nBEGIN -> B -> C\nB: Analyze existing code, write design doc for XXX feature\nC: Review if design doc is detailed enough\nC -> B: No\nC -> D: Yes\nD: Start implementation\nD -> END\n```\n\nFor multiline labels, you can use D2's block string syntax (`|md`):\n\n```\nBEGIN -> step -> END\nstep: |md\n  # Detailed instructions\n\n  1. Analyze code structure\n  2. Check for potential issues\n  3. Generate report\n|\n```\n\n**Executing a flow skill**\n\nFlow skills can be invoked in two ways:\n\n- `/flow:<name>`: Execute the flow. The Agent will start from the `BEGIN` node and process each node according to the flow diagram definition until reaching the `END` node\n- `/skill:<name>`: Like a standard skill, sends the `SKILL.md` content to the Agent as a prompt (does not automatically execute the flow)\n\n```sh\n# Execute the flow\n/flow:code-review\n\n# Load as a standard skill\n/skill:code-review\n```\n"
  },
  {
    "path": "docs/en/customization/wire-mode.md",
    "content": "# Wire mode\n\nWire mode is Kimi Code CLI's low-level communication protocol for structured bidirectional communication with external programs.\n\n## What is Wire\n\nWire is the message-passing layer used internally by Kimi Code CLI. When you interact via terminal, the Shell UI receives AI output through Wire and displays it; when you integrate with IDEs via ACP, the ACP server also communicates with the agent core through Wire.\n\nWire mode (`--wire`) exposes this communication protocol, allowing external programs to interact directly with Kimi Code CLI. This is suitable for building custom UIs or embedding Kimi Code CLI into other applications.\n\n```sh\nkimi --wire\n```\n\n## Use cases\n\nWire mode is mainly used for:\n\n- **Custom UI**: Build web, desktop, or mobile frontends for Kimi Code CLI\n- **Application integration**: Embed Kimi Code CLI into other applications\n- **Automated testing**: Programmatic testing of agent behavior\n\n::: tip\nIf you only need simple non-interactive input/output, [print mode](./print-mode.md) is simpler. Wire mode is for scenarios requiring full control and bidirectional communication.\n:::\n\n## Wire protocol\n\nWire uses a JSON-RPC 2.0 based protocol for bidirectional communication via stdin/stdout. The current protocol version is `1.5`. Each message is a single line of JSON conforming to the JSON-RPC 2.0 specification.\n\n### Protocol type definitions\n\n```typescript\n/** JSON-RPC 2.0 request message base structure */\ninterface JSONRPCRequest<Method extends string, Params> {\n  jsonrpc: \"2.0\"\n  method: Method\n  id: string\n  params: Params\n}\n\n/** JSON-RPC 2.0 notification message (no id, no response needed) */\ninterface JSONRPCNotification<Method extends string, Params> {\n  jsonrpc: \"2.0\"\n  method: Method\n  params: Params\n}\n\n/** JSON-RPC 2.0 success response */\ninterface JSONRPCSuccessResponse<Result> {\n  jsonrpc: \"2.0\"\n  id: string\n  result: Result\n}\n\n/** JSON-RPC 2.0 error response */\ninterface JSONRPCErrorResponse {\n  jsonrpc: \"2.0\"\n  id: string\n  error: JSONRPCError\n}\n\ninterface JSONRPCError {\n  code: number\n  message: string\n  data?: unknown\n}\n```\n\n### `initialize`\n\n::: info Added\nAdded in Wire 1.1. Legacy clients can skip this request and send `prompt` directly.\n:::\n\n- **Direction**: Client → Agent\n- **Type**: Request (requires response)\n\nOptional handshake request for negotiating protocol version, submitting external tool definitions, and retrieving the slash command list.\n\n```typescript\n/** initialize request parameters */\ninterface InitializeParams {\n  /** Protocol version */\n  protocol_version: string\n  /** Client info, optional */\n  client?: ClientInfo\n  /** External tool definitions, optional */\n  external_tools?: ExternalTool[]\n  /** Client capabilities, optional */\n  capabilities?: ClientCapabilities\n}\n\ninterface ClientCapabilities {\n  /** Whether the client can handle QuestionRequest messages */\n  supports_question?: boolean\n  /** Whether the client supports plan mode */\n  supports_plan_mode?: boolean\n}\n\ninterface ClientInfo {\n  name: string\n  version?: string\n}\n\ninterface ExternalTool {\n  /** Tool name, must not conflict with built-in tools */\n  name: string\n  /** Tool description */\n  description: string\n  /** Parameter definition in JSON Schema format */\n  parameters: JSONSchema\n}\n\n/** initialize response result */\ninterface InitializeResult {\n  /** Protocol version */\n  protocol_version: string\n  /** Server info */\n  server: ServerInfo\n  /** Available slash commands */\n  slash_commands: SlashCommandInfo[]\n  /** External tool registration result, only returned when request includes external_tools */\n  external_tools?: ExternalToolsResult\n  /** Server capabilities */\n  capabilities?: ServerCapabilities\n}\n\ninterface ServerCapabilities {\n  /** Whether the server supports sending QuestionRequest messages */\n  supports_question?: boolean\n}\n\ninterface ServerInfo {\n  name: string\n  version: string\n}\n\ninterface SlashCommandInfo {\n  name: string\n  description: string\n  aliases: string[]\n}\n\ninterface ExternalToolsResult {\n  /** Successfully registered tool names */\n  accepted: string[]\n  /** Failed tool registrations with reasons */\n  rejected: Array<{ name: string; reason: string }>\n}\n```\n\n**Request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"initialize\", \"id\": \"550e8400-e29b-41d4-a716-446655440000\", \"params\": {\"protocol_version\": \"1.5\", \"client\": {\"name\": \"my-ui\", \"version\": \"1.0.0\"}, \"capabilities\": {\"supports_question\": true}, \"external_tools\": [{\"name\": \"open_in_ide\", \"description\": \"Open file in IDE\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}]}}\n```\n\n**Success response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"550e8400-e29b-41d4-a716-446655440000\", \"result\": {\"protocol_version\": \"1.5\", \"server\": {\"name\": \"Kimi Code CLI\", \"version\": \"1.14.0\"}, \"slash_commands\": [{\"name\": \"init\", \"description\": \"Analyze the codebase ...\", \"aliases\": []}], \"capabilities\": {\"supports_question\": true}, \"external_tools\": {\"accepted\": [\"open_in_ide\"], \"rejected\": []}}}\n```\n\nIf the server does not support the `initialize` method, the client will receive a `-32601 method not found` error and should automatically fall back to no-handshake mode.\n\n### `prompt`\n\n- **Direction**: Client → Agent\n- **Type**: Request (requires response)\n\nSend user input and run an agent turn. After calling, the agent starts processing and sends `event` notifications and `request` messages during execution, returning a response only when the turn completes.\n\n```typescript\n/** prompt request parameters */\ninterface PromptParams {\n  /** User input, can be plain text or array of content parts */\n  user_input: string | ContentPart[]\n}\n\n/** prompt response result */\ninterface PromptResult {\n  /** Turn end status */\n  status: \"finished\" | \"cancelled\" | \"max_steps_reached\"\n  /** Number of steps executed when status is max_steps_reached */\n  steps?: number\n}\n```\n\n**Request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"prompt\", \"id\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"params\": {\"user_input\": \"Hello\"}}\n```\n\n**Success response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"finished\"}}\n```\n\n**Error response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32001, \"message\": \"LLM is not set\"}}\n```\n\n| code | Description |\n|------|-------------|\n| `-32000` | A turn is already in progress |\n| `-32001` | LLM not configured |\n| `-32002` | Specified LLM not supported |\n| `-32003` | LLM service error |\n\n### `replay`\n\n::: info Added\nAdded in Wire 1.3.\n:::\n\n- **Direction**: Client → Agent\n- **Type**: Request (requires response)\n\nTrigger a history replay. The server reads `wire.jsonl` from the session directory and re-sends the recorded `event` and `request` messages in order. Replay is read-only; clients should not respond to replayed `request` messages. If there is no history, the server returns `events: 0` and `requests: 0`.\n\n```typescript\n/** replay request has no parameters, params can be empty object or omitted */\ntype ReplayParams = Record<string, never>\n\n/** replay response result */\ninterface ReplayResult {\n  /** Replay end status */\n  status: \"finished\" | \"cancelled\"\n  /** Number of replayed events */\n  events: number\n  /** Number of replayed requests */\n  requests: number\n}\n```\n\n**Request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"replay\", \"id\": \"6ba7b812-9dad-11d1-80b4-00c04fd430c8\"}\n```\n\n**Success response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b812-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"finished\", \"events\": 42, \"requests\": 3}}\n```\n\n### `steer`\n\n::: info Added\nAdded in Wire 1.4.\n:::\n\n- **Direction**: Client → Agent\n- **Type**: Request (requires response)\n\nInject a user message into an active agent turn. Unlike `prompt`, `steer` does not start a new turn but injects the message into the currently running turn. The injected message is appended to the context as a standard user message after the current step finishes, allowing you to \"steer\" the AI's behavior before the next step begins. A `SteerInput` event is emitted when the message is consumed.\n\n```typescript\n/** steer request parameters */\ninterface SteerParams {\n  /** User input, can be plain text or array of content parts */\n  user_input: string | ContentPart[]\n}\n\n/** steer response result */\ninterface SteerResult {\n  /** Fixed as \"steered\" */\n  status: \"steered\"\n}\n```\n\n**Request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"steer\", \"id\": \"7ca7c810-9dad-11d1-80b4-00c04fd430c8\", \"params\": {\"user_input\": \"Use Python\"}}\n```\n\n**Success response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"7ca7c810-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"steered\"}}\n```\n\n**Error response example**\n\nIf no turn is in progress:\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"7ca7c810-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32000, \"message\": \"No agent turn is in progress\"}}\n```\n\n### `set_plan_mode`\n\n::: info Added\nAdded in Wire 1.4.\n:::\n\n- **Direction**: Client → Agent\n- **Type**: Request (requires response)\n\nSet plan mode to a specific state. After calling, the agent updates plan mode and sends a `StatusUpdate` event with the new state.\n\nThis feature requires capability negotiation: the client must declare `capabilities.supports_plan_mode: true` during `initialize` for the agent to enable plan mode tools (`EnterPlanMode`, `ExitPlanMode`). If the client does not declare support, these tools are automatically hidden from the LLM's tool list.\n\nPlan mode state is persisted to the session, so it survives process restarts and is restored when the session resumes.\n\n```typescript\n/** set_plan_mode request parameters */\ninterface SetPlanModeParams {\n  /** Whether to enable plan mode */\n  enabled: boolean\n}\n\n/** set_plan_mode response result */\ninterface SetPlanModeResult {\n  /** Fixed as \"ok\" */\n  status: \"ok\"\n  /** Plan mode state after the call */\n  plan_mode: boolean\n}\n```\n\n**Request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"set_plan_mode\", \"id\": \"8da7d810-9dad-11d1-80b4-00c04fd430c8\", \"params\": {\"enabled\": true}}\n```\n\n**Success response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"8da7d810-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"ok\", \"plan_mode\": true}}\n```\n\n**Error response example**\n\nIf plan mode is not supported in the current environment:\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"8da7d810-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32000, \"message\": \"Plan mode is not supported\"}}\n```\n\n### `cancel`\n\n- **Direction**: Client → Agent\n- **Type**: Request (requires response)\n\nCancel the currently running agent turn or replay. After calling, the in-progress `prompt` request will return `{\"status\": \"cancelled\"}`, and replay will return `{\"status\": \"cancelled\"}` with the message counts sent so far.\n\n```typescript\n/** cancel request has no parameters, params can be empty object or omitted */\ntype CancelParams = Record<string, never>\n\n/** cancel response result is empty object */\ntype CancelResult = Record<string, never>\n```\n\n**Request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"cancel\", \"id\": \"6ba7b811-9dad-11d1-80b4-00c04fd430c8\"}\n```\n\n**Success response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b811-9dad-11d1-80b4-00c04fd430c8\", \"result\": {}}\n```\n\n**Error response example**\n\nIf no turn is in progress:\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b811-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32000, \"message\": \"No agent turn is in progress\"}}\n```\n\n### `event`\n\n- **Direction**: Agent → Client\n- **Type**: Notification (no response needed)\n\nEvents emitted by the agent during a turn. No `id` field, client doesn't need to respond.\n\n```typescript\n/** event notification parameters, contains serialized Wire message */\ninterface EventParams {\n  type: string\n  payload: object\n}\n```\n\n**Example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"event\", \"params\": {\"type\": \"ContentPart\", \"payload\": {\"type\": \"text\", \"text\": \"Hello\"}}}\n```\n\n### `request`\n\n- **Direction**: Agent → Client\n- **Type**: Request (requires response)\n\nRequests from the agent to the client, used for approval confirmation or external tool calls. The client must respond before the agent can continue execution.\n\n```typescript\n/** request parameters, contains serialized Wire message */\ninterface RequestParams {\n  type: \"ApprovalRequest\" | \"ToolCallRequest\" | \"QuestionRequest\"\n  payload: ApprovalRequest | ToolCallRequest | QuestionRequest\n}\n```\n\n**Approval request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"request\", \"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"params\": {\"type\": \"ApprovalRequest\", \"payload\": {\"id\": \"approval-1\", \"tool_call_id\": \"tc-1\", \"sender\": \"Shell\", \"action\": \"run shell command\", \"description\": \"Run command `ls`\", \"display\": []}}}\n```\n\n**Approval response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"result\": {\"request_id\": \"approval-1\", \"response\": \"approve\"}}\n```\n\n**External tool call request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"request\", \"id\": \"a3bb189e-8bf9-3888-9912-ace4e6543002\", \"params\": {\"type\": \"ToolCallRequest\", \"payload\": {\"id\": \"tc-1\", \"name\": \"open_in_ide\", \"arguments\": \"{\\\"path\\\":\\\"README.md\\\"}\"}}}\n```\n\n**External tool call response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"a3bb189e-8bf9-3888-9912-ace4e6543002\", \"result\": {\"tool_call_id\": \"tc-1\", \"return_value\": {\"is_error\": false, \"output\": \"Opened\", \"message\": \"Opened README.md in IDE\", \"display\": []}}}\n```\n\n### Standard error codes\n\nAll requests may return JSON-RPC 2.0 standard errors:\n\n| code | Description |\n|------|-------------|\n| `-32700` | Invalid JSON format |\n| `-32600` | Invalid request (e.g., missing required fields) |\n| `-32601` | Method not found |\n| `-32602` | Invalid method parameters |\n| `-32603` | Internal error |\n\n## Wire message types\n\nWire messages are transmitted via `event` and `request` methods, in format `{\"type\": \"...\", \"payload\": {...}}`. The following describes all message types using TypeScript-style type definitions.\n\n```typescript\n/** Union type of all Wire messages */\ntype WireMessage = Event | Request\n\n/** Events: sent via event method, no response needed */\ntype Event =\n  | TurnBegin\n  | TurnEnd\n  | StepBegin\n  | StepInterrupted\n  | CompactionBegin\n  | CompactionEnd\n  | StatusUpdate\n  | ContentPart\n  | ToolCall\n  | ToolCallPart\n  | ToolResult\n  | ApprovalResponse\n  | SubagentEvent\n  | SteerInput\n\n/** Requests: sent via request method, require response */\ntype Request = ApprovalRequest | ToolCallRequest | QuestionRequest\n```\n\n### `TurnBegin`\n\nTurn started.\n\n```typescript\ninterface TurnBegin {\n  /** User input, can be plain text or array of content parts */\n  user_input: string | ContentPart[]\n}\n```\n\n### `TurnEnd`\n\n::: info Added\nAdded in Wire 1.2.\n:::\n\nTurn ended. This event is sent after all other events in the turn. If the turn is interrupted, this event may be omitted.\n\n```typescript\ninterface TurnEnd {\n  // No additional fields\n}\n```\n\n### `StepBegin`\n\nStep started.\n\n```typescript\ninterface StepBegin {\n  /** Step number, starting from 1 */\n  n: number\n}\n```\n\n### `StepInterrupted`\n\nStep interrupted, no additional fields.\n\n### `CompactionBegin`\n\nContext compaction started, no additional fields.\n\n### `CompactionEnd`\n\nContext compaction ended, no additional fields.\n\n### `StatusUpdate`\n\nStatus update.\n\n```typescript\ninterface StatusUpdate {\n  /** Context usage ratio, float between 0-1, may be absent in JSON */\n  context_usage?: number | null\n  /** Number of tokens currently in the context, may be absent in JSON */\n  context_tokens?: number | null\n  /** Maximum number of tokens the context can hold, may be absent in JSON */\n  max_context_tokens?: number | null\n  /** Token usage stats for current step, may be absent in JSON */\n  token_usage?: TokenUsage | null\n  /** Message ID for current step, may be absent in JSON */\n  message_id?: string | null\n  /** Whether plan mode (read-only) is active, null means no change, may be absent in JSON */\n  plan_mode?: boolean | null\n}\n\ninterface TokenUsage {\n  /** Input tokens excluding input_cache_read and input_cache_creation */\n  input_other: number\n  /** Total output tokens */\n  output: number\n  /** Cached input tokens */\n  input_cache_read: number\n  /** Input tokens used for cache creation, currently only Anthropic API supports this field */\n  input_cache_creation: number\n}\n```\n\n### `ContentPart`\n\nMessage content part. Serialized with `type` as `\"ContentPart\"`, specific type distinguished by `payload.type`.\n\n```typescript\ntype ContentPart =\n  | TextPart\n  | ThinkPart\n  | ImageURLPart\n  | AudioURLPart\n  | VideoURLPart\n\ninterface TextPart {\n  type: \"text\"\n  /** Text content */\n  text: string\n}\n\ninterface ThinkPart {\n  type: \"think\"\n  /** Thinking content */\n  think: string\n  /** Encrypted thinking content or signature, may be absent in JSON */\n  encrypted?: string | null\n}\n\ninterface ImageURLPart {\n  type: \"image_url\"\n  image_url: {\n    /** Image URL, can be data URI (e.g., data:image/png;base64,...) */\n    url: string\n    /** Image ID for distinguishing different images, may be absent in JSON */\n    id?: string | null\n  }\n}\n\ninterface AudioURLPart {\n  type: \"audio_url\"\n  audio_url: {\n    /** Audio URL, can be data URI (e.g., data:audio/aac;base64,...) */\n    url: string\n    /** Audio ID for distinguishing different audio, may be absent in JSON */\n    id?: string | null\n  }\n}\n\ninterface VideoURLPart {\n  type: \"video_url\"\n  video_url: {\n    /** Video URL, can be data URI (e.g., data:video/mp4;base64,...) */\n    url: string\n    /** Video ID for distinguishing different video, may be absent in JSON */\n    id?: string | null\n  }\n}\n```\n\n### `ToolCall`\n\nTool call.\n\n```typescript\ninterface ToolCall {\n  /** Fixed as \"function\" */\n  type: \"function\"\n  /** Tool call ID */\n  id: string\n  function: {\n    /** Tool name */\n    name: string\n    /** JSON-format argument string, may be absent in JSON */\n    arguments?: string | null\n  }\n  /** Extra info, may be absent in JSON */\n  extras?: object | null\n}\n```\n\n### `ToolCallPart`\n\nTool call argument fragment (streaming).\n\n```typescript\ninterface ToolCallPart {\n  /** Argument fragment for streaming tool call arguments, may be absent in JSON */\n  arguments_part?: string | null\n}\n```\n\n### `ToolResult`\n\nTool execution result.\n\n```typescript\ninterface ToolResult {\n  /** Corresponding tool call ID */\n  tool_call_id: string\n  return_value: ToolReturnValue\n}\n\ninterface ToolReturnValue {\n  /** Whether this is an error */\n  is_error: boolean\n  /** Output content returned to model */\n  output: string | ContentPart[]\n  /** Explanatory message for model */\n  message: string\n  /** Display blocks shown to user */\n  display: DisplayBlock[]\n  /** Extra debug info, may be absent in JSON */\n  extras?: object | null\n}\n```\n\n### `ApprovalResponse`\n\n::: info Changed\nRenamed in Wire 1.1. Formerly `ApprovalRequestResolved`. The old name is still accepted for backwards compatibility.\n:::\n\nApproval response event, indicates an approval request has been completed.\n\n```typescript\ninterface ApprovalResponse {\n  /** Approval request ID */\n  request_id: string\n  /** Approval result */\n  response: \"approve\" | \"approve_for_session\" | \"reject\"\n}\n```\n\n### `SubagentEvent`\n\nSubagent event.\n\n```typescript\ninterface SubagentEvent {\n  /** Associated Task tool call ID */\n  task_tool_call_id: string\n  /** Event from subagent, nested Wire message format */\n  event: { type: string; payload: object }\n}\n```\n\n### `SteerInput`\n\n::: info Added\nAdded in Wire 1.5.\n:::\n\nIndicates that the user appended follow-up input to the current running turn. This event is emitted after the current step finishes and the input is appended to context, before the next step begins.\n\n```typescript\ninterface SteerInput {\n  /** User input, can be plain text or array of content parts */\n  user_input: string | ContentPart[]\n}\n```\n\n### `ApprovalRequest`\n\nApproval request, sent via `request` method, client must respond before agent can continue.\n\n```typescript\ninterface ApprovalRequest {\n  /** Request ID, used when responding */\n  id: string\n  /** Associated tool call ID */\n  tool_call_id: string\n  /** Sender (tool name) */\n  sender: string\n  /** Action description */\n  action: string\n  /** Detailed description */\n  description: string\n  /** Display blocks shown to user, may be absent in JSON, defaults to [] */\n  display?: DisplayBlock[]\n}\n```\n\n**Response format**\n\nClient needs to return `ApprovalResponse` as the response result:\n\n```typescript\ninterface ApprovalResponse {\n  request_id: string\n  response: \"approve\" | \"approve_for_session\" | \"reject\"\n}\n```\n\n| response | Description |\n|----------|-------------|\n| `approve` | Approve this operation |\n| `approve_for_session` | Approve similar operations for this session |\n| `reject` | Reject operation |\n\n### `ToolCallRequest`\n\nExternal tool call request, sent via `request` method. When the agent calls an external tool registered via `initialize`, this request is sent. The client must execute the tool and return a `ToolResult`.\n\n```typescript\ninterface ToolCallRequest {\n  /** Tool call ID */\n  id: string\n  /** Tool name */\n  name: string\n  /** JSON-format argument string, may be absent in JSON */\n  arguments?: string | null\n}\n```\n\n**Response format**\n\nClient needs to return `ToolResult` as the response result:\n\n```typescript\ninterface ToolResult {\n  tool_call_id: string\n  return_value: ToolReturnValue\n}\n```\n\n### `QuestionRequest`\n\n::: info Added\nAdded in Wire 1.4.\n:::\n\nStructured question request, sent via `request` method. When the agent uses the `AskUserQuestion` tool, this request is sent. The client must respond before the agent can continue execution.\n\nThis feature requires capability negotiation: the client must declare `capabilities.supports_question: true` during `initialize` for the agent to send `QuestionRequest`. If the client does not declare support, the `AskUserQuestion` tool is automatically hidden from the LLM's tool list, preventing the LLM from invoking unsupported interactions.\n\n```typescript\ninterface QuestionRequest {\n  /** Request ID, used when responding */\n  id: string\n  /** Associated tool call ID */\n  tool_call_id: string\n  /** Questions list (1–4 questions) */\n  questions: QuestionItem[]\n}\n\ninterface QuestionItem {\n  /** Question text */\n  question: string\n  /** Short label, max 12 characters */\n  header?: string\n  /** Available options (2–4) */\n  options: QuestionOption[]\n  /** Whether multiple options can be selected */\n  multi_select?: boolean\n}\n\ninterface QuestionOption {\n  /** Option label */\n  label: string\n  /** Option description */\n  description?: string\n}\n```\n\n**Request example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"request\", \"id\": \"b1a2c3d4-e5f6-7890-abcd-ef1234567890\", \"params\": {\"type\": \"QuestionRequest\", \"payload\": {\"id\": \"q-1\", \"tool_call_id\": \"tc-1\", \"questions\": [{\"question\": \"Which language should I use?\", \"header\": \"Lang\", \"options\": [{\"label\": \"Python\", \"description\": \"Widely used, large ecosystem\"}, {\"label\": \"Rust\", \"description\": \"High performance, memory safe\"}], \"multi_select\": false}]}}}\n```\n\n**Response format**\n\nClient needs to return `QuestionResponse` as the response result:\n\n```typescript\ninterface QuestionResponse {\n  /** Corresponding request ID */\n  request_id: string\n  /** Answer mapping, key is question text, value is selected option label(s) (comma-separated for multi-select) */\n  answers: Record<string, string>\n}\n```\n\n**Response example**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"b1a2c3d4-e5f6-7890-abcd-ef1234567890\", \"result\": {\"request_id\": \"q-1\", \"answers\": {\"Which language should I use?\": \"Python\"}}}\n```\n\nIf the client does not support structured questions or the user dismisses the question panel, return empty `answers`:\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"b1a2c3d4-e5f6-7890-abcd-ef1234567890\", \"result\": {\"request_id\": \"q-1\", \"answers\": {}}}\n```\n\n### `DisplayBlock`\n\nDisplay block types used in the `display` field of `ToolResult` and `ApprovalRequest`.\n\n```typescript\ntype DisplayBlock =\n  | UnknownDisplayBlock\n  | BriefDisplayBlock\n  | DiffDisplayBlock\n  | TodoDisplayBlock\n  | ShellDisplayBlock\n\n/** Fallback for unrecognized display block types */\ninterface UnknownDisplayBlock {\n  /** Any type identifier */\n  type: string\n  /** Raw data */\n  data: object\n}\n\ninterface BriefDisplayBlock {\n  type: \"brief\"\n  /** Brief text content */\n  text: string\n}\n\ninterface DiffDisplayBlock {\n  type: \"diff\"\n  /** File path */\n  path: string\n  /** Original content */\n  old_text: string\n  /** New content */\n  new_text: string\n}\n\ninterface TodoDisplayBlock {\n  type: \"todo\"\n  /** Todo list items */\n  items: TodoDisplayItem[]\n}\n\ninterface TodoDisplayItem {\n  /** Todo item title */\n  title: string\n  /** Status */\n  status: \"pending\" | \"in_progress\" | \"done\"\n}\n\ninterface ShellDisplayBlock {\n  type: \"shell\"\n  /** Language identifier for syntax highlighting (e.g., \"sh\", \"powershell\") */\n  language: string\n  /** Shell command content */\n  command: string\n}\n```\n\n## Kimi Agent (Rust) Wire server\n\n::: warning Note\nKimi Agent is currently experimental. APIs and behavior may change in future releases.\n:::\n\nKimi Agent (Rust) is the Rust implementation of the Kimi Code CLI kernel, designed specifically for Wire mode. If you only need the Wire protocol service, Kimi Agent (Rust) offers a more lightweight alternative. The Rust implementation lives in [`MoonshotAI/kimi-agent-rs`](https://github.com/MoonshotAI/kimi-agent-rs).\n\n### Features\n\n- **Full Wire protocol compatibility**: Uses the same Wire protocol as Python's `kimi --wire`, existing clients need no modifications\n- **Smaller footprint**: Single statically-linked binary, no Python runtime required\n- **Faster startup**: Native compilation provides faster startup times\n- **Same configuration**: Uses the same config file (`~/.kimi/config.toml`) and session directories\n\n### Limitations\n\n- **Wire mode only**: No Shell/Print/ACP UI\n- **Kimi provider only**: Does not support OpenAI, Anthropic, or other providers\n- **No Kimi account login**: No `login`/`logout` subcommands or `/login`, `/logout` slash commands; requires manual API key configuration\n- **No `--prompt`/`--command`**: Wire server does not accept initial prompts\n- **Local execution only**: No SSH Kaos support\n- **Different MCP OAuth storage**: Kimi Agent stores credentials in `~/.kimi/credentials/mcp_auth.json`, while Python version uses `~/.fastmcp/oauth-mcp-client-cache/`; they are incompatible\n\n### Installation\n\nDownload pre-built binaries from [GitHub Releases](https://github.com/MoonshotAI/kimi-agent-rs/releases):\n\n```sh\n# macOS (Apple Silicon)\ncurl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-aarch64-apple-darwin.tar.gz | tar xz\nsudo mv kimi-agent /usr/local/bin/\n\n# Linux (x86_64)\ncurl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-x86_64-unknown-linux-gnu.tar.gz | tar xz\nsudo mv kimi-agent /usr/local/bin/\n```\n\n### Usage\n\nKimi Agent runs in Wire mode by default:\n\n```sh\nkimi-agent\n```\n\nCommon options are the same as the `kimi` command:\n\n```sh\n# Specify work directory\nkimi-agent --work-dir /path/to/project\n\n# Continue previous session\nkimi-agent --continue\n\n# Use specific session\nkimi-agent --session <session-id>\n\n# Use specific model\nkimi-agent --model k2\n\n# YOLO mode (skip approvals)\nkimi-agent --yolo\n```\n\nSubcommands:\n\n```sh\n# Show version and environment info\nkimi-agent info\n\n# Manage MCP servers\nkimi-agent mcp list\nkimi-agent mcp add <name> <command> [args...]\nkimi-agent mcp remove <name>\n```\n\n### Version synchronization\n\nKimi Agent is released independently from Kimi Code CLI. See `MoonshotAI/kimi-agent-rs` release notes for compatibility and sync status.\n"
  },
  {
    "path": "docs/en/faq.md",
    "content": "# FAQ\n\n## Installation and authentication\n\n### Empty model list during `/login`\n\nIf you see \"No models available for the selected platform\" error when running the `/login` (or `/setup`) command, it may be due to:\n\n- **Invalid or expired API key**: Check if your API key is correct and still valid.\n- **Network connection issues**: Confirm you can access the API service addresses (such as `api.kimi.com` or `api.moonshot.cn`).\n\n### Invalid API key\n\nPossible reasons for an invalid API key:\n\n- **Key input error**: Check for extra spaces or missing characters.\n- **Key expired or revoked**: Confirm the key status in the platform console.\n- **Environment variable override**: Check if `KIMI_API_KEY` or `OPENAI_API_KEY` environment variables are overriding the key in the config file. You can run `echo $KIMI_API_KEY` to check.\n\n### Membership expired or quota exhausted\n\nIf you're using the Kimi Code platform, you can check your current quota and membership status with the `/usage` command. If the quota is exhausted or membership expired, you need to renew or upgrade at [Kimi Code](https://kimi.com/coding).\n\n## Interaction issues\n\n### `cd` command doesn't work in shell mode\n\nExecuting the `cd` command in shell mode won't change Kimi Code CLI's working directory. This is because each shell command executes in an independent subprocess, and directory changes only take effect within that process.\n\nIf you need to change working directory:\n\n- **Exit and restart**: Run the `kimi` command again in the target directory.\n- **Use `--work-dir` flag**: Specify working directory at startup, like `kimi --work-dir /path/to/project`.\n- **Use absolute paths in commands**: Execute commands with absolute paths directly, like `ls /path/to/dir`.\n\n### Image paste fails\n\nWhen using `Ctrl-V` to paste an image, if you see \"Current model does not support image input\", it means the current model doesn't support image input.\n\nSolutions:\n\n- **Switch to an image-capable model**: Use a model that supports the `image_in` capability.\n- **Check clipboard content**: Make sure the clipboard contains actual image data, not just a file path to an image.\n\n## ACP issues\n\n### IDE cannot connect to Kimi Code CLI\n\nIf your IDE (like Zed or JetBrains IDEs) cannot connect to Kimi Code CLI, check the following:\n\n- **Confirm Kimi Code CLI is installed**: Run `kimi --version` to confirm successful installation.\n- **Check configuration path**: Ensure the Kimi Code CLI path in IDE configuration is correct. You can typically use `kimi acp` as the command.\n- **Check uv path**: If installed via uv, ensure `~/.local/bin` is in PATH. You can use an absolute path like `/Users/yourname/.local/bin/kimi acp`.\n- **Check logs**: Examine error messages in `~/.kimi/logs/kimi.log`.\n\n## MCP issues\n\n### MCP server startup fails\n\nAfter adding an MCP server, if tools aren't loaded or there are errors, it may be due to:\n\n- **Command doesn't exist**: For stdio type servers, ensure the command (like `npx`) is in PATH. You can configure with an absolute path.\n- **Configuration format error**: Check if `~/.kimi/mcp.json` is valid JSON. Run `kimi mcp list` to view current configuration.\n\nDebugging steps:\n\n```sh\n# View configured servers\nkimi mcp list\n\n# Test if server is working\nkimi mcp test <server-name>\n```\n\n### OAuth authorization fails\n\nFor MCP servers that require OAuth authorization (like Linear), if authorization fails:\n\n- **Check network connection**: Ensure you can access the authorization server.\n- **Re-authorize**: Run `kimi mcp auth <server-name>` to authorize again.\n- **Reset authorization**: If authorization info is corrupted, run `kimi mcp reset-auth <server-name>` to clear it and retry.\n\n### Header format error\n\nWhen adding HTTP type MCP servers, header format should be `KEY: VALUE` (with a space after the colon). For example:\n\n```sh\n# Correct\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \"CONTEXT7_API_KEY: your-key\"\n\n# Wrong (missing space or using equals sign)\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \"CONTEXT7_API_KEY=your-key\"\n```\n\n## Print/Wire mode issues\n\n### Invalid JSONL input format\n\nWhen using `--input-format stream-json`, input must be valid JSONL (one JSON object per line). Common issues:\n\n- **JSON format error**: Ensure each line is a complete JSON object without syntax errors.\n- **Encoding issues**: Ensure input uses UTF-8 encoding.\n- **Line ending issues**: Windows users should check if line endings are `\\n` rather than `\\r\\n`.\n\nCorrect input format example:\n\n```json\n{\"role\": \"user\", \"content\": \"Hello\"}\n```\n\n### No output in print mode\n\nIf there's no output in `--print` mode, it may be:\n\n- **No input provided**: You need to provide input via `--prompt` (or `--command`) or stdin. For example: `kimi --print --prompt \"Hello\"`.\n- **Output is buffered**: Try using `--output-format stream-json` for streaming output.\n- **Configuration incomplete**: Ensure API key and model are configured via `/login`.\n\n## Updates and upgrades\n\n### macOS slow first run\n\nmacOS's Gatekeeper security mechanism checks new programs on first run, causing slow startup. Solutions:\n\n- **Wait for check to complete**: Be patient on first run; subsequent launches will return to normal.\n- **Add to Developer Tools**: Add your terminal application in \"System Settings → Privacy & Security → Developer Tools\".\n\n### How to upgrade Kimi Code CLI\n\nUse uv to upgrade to the latest version:\n\n```sh\nuv tool upgrade kimi-cli --no-cache\n```\n\nAdding `--no-cache` ensures you get the latest version.\n\n### How to disable auto-update check\n\nIf you don't want Kimi Code CLI to check for updates in the background, set the environment variable:\n\n```sh\nexport KIMI_CLI_NO_AUTO_UPDATE=1\n```\n\nYou can add this line to your shell configuration file (like `~/.zshrc` or `~/.bashrc`).\n"
  },
  {
    "path": "docs/en/guides/getting-started.md",
    "content": "# Getting Started\n\n## What is Kimi Code CLI\n\nKimi Code CLI is an AI agent that runs in the terminal, helping you complete software development tasks and terminal operations. It can read and edit code, execute shell commands, search and fetch web pages, and autonomously plan and adjust actions during execution.\n\nKimi Code CLI is suited for:\n\n- **Writing and modifying code**: Implementing new features, fixing bugs, refactoring code\n- **Understanding projects**: Exploring unfamiliar codebases, answering architecture and implementation questions\n- **Automating tasks**: Batch processing files, running builds and tests, executing scripts\n\nKimi Code CLI supports the following usage modes:\n\n- **[Interactive CLI (`kimi`)](../reference/kimi-command.md)**: Chat with AI in the terminal using natural language or execute shell commands directly\n- **[Browser UI (`kimi web`)](../reference/kimi-web.md)**: Open a graphical interface in your local browser, with session management, file references, code highlighting, and more\n- **[Agent integration (`kimi acp`)](../reference/kimi-acp.md)**: Run as a service and integrate with [IDEs](./ides.md) and other local agent clients via the [Agent Client Protocol]\n\n::: info Tip\nIf you encounter issues or have suggestions, please provide feedback on [GitHub Issues](https://github.com/MoonshotAI/kimi-cli/issues).\n:::\n\n[Agent Client Protocol]: https://agentclientprotocol.com/\n\n## Installation\n\nRun the installation script to complete the installation. The script will first install [uv](https://docs.astral.sh/uv/) (a Python package manager), then install Kimi Code CLI via uv:\n\n```sh\n# Linux / macOS\ncurl -LsSf https://code.kimi.com/install.sh | bash\n```\n\n```powershell\n# Windows (PowerShell)\nInvoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression\n```\n\nVerify the installation:\n\n```sh\nkimi --version\n```\n\n::: tip\nDue to macOS security checks, the first run of the `kimi` command may take longer. You can add your terminal application in \"System Settings → Privacy & Security → Developer Tools\" to speed up subsequent launches.\n:::\n\nIf you already have uv installed, you can also run:\n\n```sh\nuv tool install --python 3.13 kimi-cli\n```\n\n::: tip\nKimi Code CLI supports Python 3.12–3.14, with Python 3.13 recommended.\n:::\n\n## Upgrade and uninstall\n\nUpgrade to the latest version:\n\n```sh\nuv tool upgrade kimi-cli --no-cache\n```\n\nUninstall Kimi Code CLI:\n\n```sh\nuv tool uninstall kimi-cli\n```\n\n## First run\n\nRun the `kimi` command in the project directory where you want to work to start Kimi Code CLI:\n\n```sh\ncd your-project\nkimi\n```\n\nOn first launch, you need to configure your API source. Enter the `/login` command to start configuration:\n\n```\n/login\n```\n\nAfter execution, first select a platform. We recommend **Kimi Code**, which automatically opens a browser for OAuth authorization; selecting other platforms requires entering an API key. After configuration, Kimi Code CLI will automatically save the settings and reload. See [Providers](../configuration/providers.md) for details.\n\nNow you can chat with Kimi Code CLI directly using natural language. Try describing a task you want to complete, for example:\n\n```\nShow me the directory structure of this project\n```\n\n::: tip\nIf the project doesn't have an `AGENTS.md` file, you can run the `/init` command to have Kimi Code CLI analyze the project and generate this file, helping the AI better understand the project structure and conventions.\n:::\n\nEnter `/help` to view all available [slash commands](../reference/slash-commands.md) and usage tips.\n"
  },
  {
    "path": "docs/en/guides/ides.md",
    "content": "# Using in IDEs\n\nKimi Code CLI supports integration with IDEs through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/), allowing you to use AI-assisted programming directly within your editor.\n\n## Prerequisites\n\nBefore configuring your IDE, make sure you have installed Kimi Code CLI and completed the `/login` configuration.\n\n## Using in Zed\n\n[Zed](https://zed.dev/) is a modern IDE that supports ACP.\n\nAdd the following to Zed's configuration file `~/.config/zed/settings.json`:\n\n```json\n{\n  \"agent_servers\": {\n    \"Kimi Code CLI\": {\n      \"type\": \"custom\",\n      \"command\": \"kimi\",\n      \"args\": [\"acp\"],\n      \"env\": {}\n    }\n  }\n}\n```\n\nConfiguration notes:\n\n- `type`: Fixed value `\"custom\"`\n- `command`: Path to the Kimi Code CLI command. If `kimi` is not in PATH, use the full path\n- `args`: Startup arguments. `acp` enables ACP mode\n- `env`: Environment variables, usually left empty\n\nAfter saving the configuration, you can create Kimi Code CLI sessions in Zed's Agent panel.\n\n## Using in JetBrains IDEs\n\nJetBrains IDEs (IntelliJ IDEA, PyCharm, WebStorm, etc.) support ACP through the AI Chat plugin.\n\nIf you don't have a JetBrains AI subscription, you can enable `llm.enable.mock.response` in the Registry to use the AI Chat feature. Press Shift twice to search for \"Registry\" to open it.\n\nIn the AI Chat panel menu, click \"Configure ACP agents\" and add the following configuration:\n\n```json\n{\n  \"agent_servers\": {\n    \"Kimi Code CLI\": {\n      \"command\": \"~/.local/bin/kimi\",\n      \"args\": [\"acp\"],\n      \"env\": {}\n    }\n  }\n}\n```\n\n`command` needs to be the full path. You can run `which kimi` in the terminal to get it. After saving, you can select Kimi Code CLI in the AI Chat Agent selector.\n"
  },
  {
    "path": "docs/en/guides/integrations.md",
    "content": "# Integrations with Tools\n\nBesides using in the terminal and IDEs, Kimi Code CLI can also be integrated with other tools.\n\n## Zsh plugin\n\n[zsh-kimi-cli](https://github.com/MoonshotAI/zsh-kimi-cli) is a Zsh plugin that lets you quickly switch to Kimi Code CLI in Zsh.\n\n**Installation**\n\nIf you use Oh My Zsh, you can install it like this:\n\n```sh\ngit clone https://github.com/MoonshotAI/zsh-kimi-cli.git \\\n  ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/kimi-cli\n```\n\nThen add the plugin in `~/.zshrc`:\n\n```sh\nplugins=(... kimi-cli)\n```\n\nReload the Zsh configuration:\n\n```sh\nsource ~/.zshrc\n```\n\n**Usage**\n\nAfter installation, press `Ctrl-X` in Zsh to quickly switch to Kimi Code CLI without manually typing the `kimi` command.\n\n::: tip\nIf you use other Zsh plugin managers (like zinit, zplug, etc.), please refer to the [zsh-kimi-cli repository](https://github.com/MoonshotAI/zsh-kimi-cli) README for installation instructions.\n:::\n"
  },
  {
    "path": "docs/en/guides/interaction.md",
    "content": "# Interaction and Input\n\nKimi Code CLI provides rich interaction features to help you collaborate efficiently with AI.\n\n## Agent and shell mode\n\nKimi Code CLI has two input modes:\n\n- **Agent mode**: The default mode, where input is sent to the AI for processing\n- **Shell mode**: Execute shell commands directly without leaving Kimi Code CLI\n\nPress `Ctrl-X` to switch between the two modes. The current mode is displayed in the bottom status bar.\n\nIn shell mode, you can execute commands just like in a regular terminal:\n\n```sh\n$ ls -la\n$ git status\n$ npm run build\n```\n\nShell mode also supports some slash commands, including `/help`, `/exit`, `/version`, `/editor`, `/changelog`, `/feedback`, `/export`, `/import`, and `/task`.\n\n::: warning Note\nIn shell mode, each command executes independently. Commands that change the environment like `cd` or `export` won't affect subsequent commands.\n:::\n\n## Plan mode\n\nPlan mode is a read-only planning mode that lets the AI design an implementation plan before writing code, preventing wasted effort in the wrong direction.\n\nIn plan mode, the AI can only use read-only tools (`Glob`, `Grep`, `ReadFile`) to explore the codebase — it cannot modify any files or execute commands. The AI writes its plan to a dedicated plan file, then submits it to you for approval. You can approve, reject, or provide revision feedback.\n\n### Entering plan mode\n\nThere are three ways to enter plan mode:\n\n- **Keyboard shortcut**: Press `Shift-Tab` to toggle plan mode\n- **Slash command**: Enter `/plan` or `/plan on`\n- **AI-initiated**: When facing complex tasks, the AI may request to enter plan mode via the `EnterPlanMode` tool — you can accept or decline\n\nWhen plan mode is active, the prompt changes to `📋` and a blue `plan` badge appears in the status bar.\n\n### Reviewing plans\n\nWhen the AI finishes its plan, it submits it for approval via `ExitPlanMode`. The approval panel shows the full plan content, and you can:\n\n- **Approve / select an approach**: If the plan contains multiple alternative implementation paths, the AI lists 2–3 labeled options (e.g. \"Option A\", \"Option B (Recommended)\") for you to choose from — selecting one exits plan mode and tells the AI which path to follow. If the plan has a single path, an **Approve** button is shown instead.\n- **Reject**: Decline the plan, stay in plan mode, and provide feedback via conversation\n- **Revise**: Enter revision notes — the AI will update the plan and resubmit\n\nPress `Ctrl-E` to view the full plan content in a fullscreen pager.\n\n### Managing plan mode\n\nUse the `/plan` command to manage plan mode:\n\n- `/plan`: Toggle plan mode\n- `/plan on`: Enable plan mode\n- `/plan off`: Disable plan mode\n- `/plan view`: View the current plan content\n- `/plan clear`: Clear the current plan file\n\n## Thinking mode\n\nThinking mode allows the AI to think more deeply before responding, suitable for handling complex problems.\n\nYou can use the `/model` command to switch models and thinking mode. After selecting a model, if the model supports thinking mode, the system will ask whether to enable it. You can also enable it at startup with the `--thinking` flag:\n\n```sh\nkimi --thinking\n```\n\n::: tip\nThinking mode requires support from the current model. Some models (like `kimi-k2-thinking-turbo`) always use thinking mode and cannot be disabled.\n:::\n\n## Sending messages while running (steer)\n\nWhile the AI is executing a task, you can type and send follow-up messages in the input box without waiting for the current turn to finish. This feature is called \"steering\" and allows you to adjust the AI's direction mid-turn.\n\nSteer messages are appended to the context after the current step completes, and the AI will see and respond to your message before the next step begins. Approval requests and question panels are also handled inline with keyboard navigation during agent execution.\n\nAny text you type in the input box during a turn but haven't yet submitted is preserved when the turn ends — it won't be lost. You can press `Enter` to send it as the next message, or continue editing.\n\n::: tip\nSteer messages do not interrupt the AI's currently executing step — they are processed between steps. To interrupt immediately, use `Ctrl-C`.\n:::\n\n## Background tasks\n\nWhen the AI needs to run long-running commands (such as building a project, running a test suite, or starting a development server), it can launch them as background tasks. Background tasks run in a separate process, allowing the AI to continue handling other requests without waiting for the command to finish.\n\nHow background tasks work:\n\n1. The AI uses the `Shell` tool with `run_in_background=true` to launch the command\n2. The tool immediately returns a task ID, and the AI continues with other work\n3. When the task completes, the system automatically notifies the AI, which will inform you of the results\n\nYou can use the `/task` slash command to open the interactive task browser, where you can view the status and output of all background tasks in real time. See [Slash commands reference](../reference/slash-commands.md#task) for details.\n\n::: tip\nBy default, up to 4 background tasks can run simultaneously. This can be adjusted in the `[background]` section of the config file. All background tasks are terminated when the CLI exits by default. See [Configuration files](../configuration/config-files.md#background).\n:::\n\n## Multi-line input\n\nSometimes you need to enter multiple lines, such as pasting a code snippet or error log. Press `Ctrl-J` or `Alt-Enter` to insert a newline instead of sending the message immediately.\n\nAfter finishing your input, press `Enter` to send the complete message.\n\n## Clipboard and media paste\n\nPress `Ctrl-V` to paste text, images, or video files from the clipboard.\n\nIn agent mode, longer pasted text (over 1000 characters or 15 lines) is automatically collapsed into a `[Pasted text #n]` placeholder in the input box to keep the interface clean. The full content is still expanded and sent to the model when submitting. When using an external editor (`Ctrl-O`), placeholders are automatically expanded to the original text; unmodified portions are re-collapsed after saving.\n\nIf the clipboard contains an image, Kimi Code CLI caches the image to disk and displays it as an `[image:…]` placeholder in the input box. After sending the message, the AI can see and analyze the image. If the clipboard contains a video file, its file path is inserted as text into the input box.\n\n::: tip\nImage input requires the model to support the `image_in` capability. Video input requires the `video_in` capability.\n:::\n\n## Slash commands\n\nSlash commands are special instructions starting with `/`, used to execute Kimi Code CLI's built-in features, such as `/help`, `/login`, `/sessions`, etc. After typing `/`, a list of available commands will automatically appear. For the complete list of slash commands, see the [slash commands reference](../reference/slash-commands.md).\n\n## @ path completion\n\nWhen you type `@` in a message, Kimi Code CLI will auto-complete file and directory paths in the working directory. This allows you to conveniently reference files in your project:\n\n```\nCheck if there are any issues with @src/components/Button.tsx\n```\n\nAfter typing `@`, start entering the filename and matching completions will appear. Press `Tab` or `Enter` to select a completion.\n\n## Structured questions\n\nDuring execution, the AI may need you to make choices to determine the next direction. In such cases, the AI will use the `AskUserQuestion` tool to present structured questions and options.\n\nThe question panel displays the question description and available options. You can select using the keyboard:\n\n- Use arrow keys (up / down) to navigate options\n- Press `Enter` to confirm selection\n- Press `Space` to toggle selection in multi-select mode\n- Select \"Other\" to enter custom text\n- Press `Esc` to skip the question\n\nEach question supports 2–4 predefined options, and the AI will set appropriate options and descriptions based on the current task context. If there are multiple questions to answer, the panel displays them as tabs — use Left/Right arrow keys or `Tab` to switch between questions. Answered questions are marked as completed, and switching back to an answered question restores the previous selection.\n\n::: tip\nThe AI only uses this tool when your choice genuinely affects subsequent actions. For decisions that can be inferred from context, the AI will decide on its own and continue execution.\n:::\n\n## Approvals and confirmations\n\nWhen the AI needs to perform operations that may have an impact (such as modifying files or running commands), Kimi Code CLI will request your confirmation.\n\nThe confirmation prompt will show operation details, including shell command and file diff previews. If the content is long and truncated, you can press `Ctrl-E` to expand and view the full content. You can choose:\n\n- **Allow**: Execute this operation\n- **Allow for this session**: Automatically approve similar operations in the current session (this decision is persisted with the session and automatically restored when resuming)\n- **Reject**: Do not execute this operation\n\nIf you trust the AI's operations, or you're running Kimi Code CLI in a safe isolated environment, you can enable \"YOLO mode\" to automatically approve all requests:\n\n```sh\n# Enable at startup\nkimi --yolo\n\n# Or toggle during runtime\n/yolo\n```\n\nYou can also set `default_yolo = true` in the config file to enable YOLO mode by default on every startup. See [Configuration files](../configuration/config-files.md).\n\nWhen YOLO mode is enabled, a yellow YOLO badge appears in the status bar at the bottom. Enter `/yolo` again to disable it.\n\n::: warning Note\nYOLO mode skips all confirmations. Make sure you understand the potential risks. It's recommended to only use this in controlled environments.\n:::\n"
  },
  {
    "path": "docs/en/guides/sessions.md",
    "content": "# Sessions and Context\n\nKimi Code CLI automatically saves your conversation history, allowing you to continue previous work at any time.\n\n## Session resuming\n\nEach time you start Kimi Code CLI, a new session is created. While running, you can also enter the `/new` command to create and switch to a new session at any time, without exiting the program.\n\nIf you want to continue a previous conversation, there are several ways:\n\n**Continue the most recent session**\n\nUse the `--continue` flag to continue the most recent session in the current working directory:\n\n```sh\nkimi --continue\n```\n\n**Switch to a specific session**\n\nUse the `--session` flag to switch to a session with a specific ID:\n\n```sh\nkimi --session abc123\n```\n\n**Switch sessions during runtime**\n\nEnter `/sessions` (or `/resume`) to view all sessions in the current working directory, and use arrow keys to select the session you want to switch to:\n\n```\n/sessions\n```\n\nThe list shows each session's title and last update time, helping you find the conversation you want to continue.\n\n**Startup replay**\n\nWhen you continue an existing session, Kimi Code CLI will replay the previous conversation history so you can quickly understand the context. During replay, previous messages and AI responses will be displayed.\n\n## Session state persistence\n\nIn addition to conversation history, Kimi Code CLI also automatically saves and restores the session's runtime state. When you resume a session, the following states are automatically restored:\n\n- **Approval decisions**: YOLO mode on/off status, operation types approved via \"allow for this session\"\n- **Plan mode**: Plan mode on/off status\n- **Dynamic subagents**: Subagent definitions created via the `CreateSubagent` tool during the session\n- **Additional directories**: Workspace directories added via `--add-dir` or `/add-dir`\n\nThis means you don't need to reconfigure these settings each time you resume a session. For example, if you approved auto-execution of certain shell commands in your previous session, those approvals remain in effect after resuming.\n\n## Export and import\n\nKimi Code CLI supports exporting session context to a file, or importing context from external files and other sessions.\n\n**Export a session**\n\nEnter `/export` to export the current session's complete conversation history as a Markdown file:\n\n```\n/export\n```\n\nThe exported file includes session metadata, a conversation overview, and the complete conversation organized by turns. You can also specify an output path:\n\n```\n/export ~/exports/my-session.md\n```\n\n**Import context**\n\nEnter `/import` to import context from a file or another session. The imported content is appended as reference information to the current session:\n\n```\n/import ./previous-session-export.md\n/import abc12345\n```\n\nCommon text-based file formats are supported (Markdown, source code, configuration files, etc.). You can also pass a session ID to import the complete conversation history from that session.\n\n::: tip\nExported files may contain sensitive information (such as code snippets, file paths, etc.). Please review before sharing.\n:::\n\n## Clear and compact\n\nAs the conversation progresses, the context grows longer. Kimi Code CLI will automatically compress the context when needed to ensure the conversation can continue.\n\nYou can also manually manage the context using slash commands:\n\n**Clear context**\n\nEnter `/clear` to clear all context in the current session and start a fresh conversation:\n\n```\n/clear\n```\n\nAfter clearing, the AI will forget all previous conversation content. You usually don't need to use this command; for new tasks, starting a new session is a better choice.\n\n**Compact context**\n\nEnter `/compact` to have the AI summarize the current conversation and replace the original context with the summary:\n\n```\n/compact\n```\n\nYou can also append custom instructions after the command to tell the AI what content to prioritize preserving during compaction:\n\n```\n/compact keep the database-related discussion\n```\n\nCompacting preserves key information while reducing token consumption. This is useful when the conversation is long but you still want to retain some context.\n\n::: tip\nThe bottom status bar displays the current context usage with token counts (e.g., `context: 42.0% (4.2k/10.0k)`), helping you understand when you need to clear or compact.\n:::\n\n::: tip\n`/clear` and `/reset` clear the conversation context but do not reset session state (such as approval decisions, dynamic subagents, and additional directories). To start completely fresh, it's recommended to create a new session.\n:::\n"
  },
  {
    "path": "docs/en/guides/use-cases.md",
    "content": "# Common Use Cases\n\nKimi Code CLI can help you complete various software development and general tasks. Here are some typical scenarios.\n\n## Implementing new features\n\nWhen you need to add new features to your project, simply describe your requirements in natural language. Kimi Code CLI will automatically read relevant code, understand the project structure, and then make modifications.\n\n```\nAdd pagination to the user list page, showing 20 records per page\n```\n\nKimi Code CLI typically works through a \"Read → Edit → Verify\" workflow:\n\n1. **Read**: Search and read relevant code, understand existing implementation\n2. **Edit**: Write or modify code, following the project's coding style\n3. **Verify**: Run tests or builds to ensure changes don't introduce issues\n\nIf you're not satisfied with the changes, you can tell Kimi Code CLI to adjust:\n\n```\nThe pagination component style doesn't match the rest of the project, reference the Button component's style\n```\n\n## Fixing bugs\n\nDescribe the problem you're encountering, and Kimi Code CLI will help you locate the cause and fix it:\n\n```\nAfter user login, when redirecting to the home page, it occasionally shows logged out status. Help me investigate\n```\n\nFor problems with clear error messages, you can paste the error log directly:\n\n```\nWhen running npm test, I get this error:\n\nTypeError: Cannot read property 'map' of undefined\n    at UserList.render (src/components/UserList.jsx:15:23)\n\nPlease fix it\n```\n\nYou can also have Kimi Code CLI run commands to reproduce and verify the issue:\n\n```\nRun the tests, and if there are any failing cases, fix them\n```\n\n## Understanding projects\n\nKimi Code CLI can help you explore and understand unfamiliar codebases:\n\n```\nWhat's the overall architecture of this project? Where is the entry file?\n```\n\n```\nHow is the user authentication flow implemented? What files are involved?\n```\n\n```\nExplain what the src/core/scheduler.py file does\n```\n\nIf you encounter parts you don't understand while reading code, you can ask anytime:\n\n```\nWhat's the difference between useCallback and useMemo? Why use useCallback here?\n```\n\n## Automating small tasks\n\nKimi Code CLI can perform various repetitive small tasks:\n\n```\nChange all var declarations to const or let in .js files under the src directory\n```\n\n```\nAdd documentation comments to all public functions without docstrings\n```\n\n```\nGenerate unit tests for this API module\n```\n\n```\nUpdate all dependencies in package.json to the latest version, then run tests to make sure there are no issues\n```\n\n## Automating general tasks\n\nBeyond code-related tasks, Kimi Code CLI can also handle some general scenarios.\n\n**Research tasks**\n\n```\nResearch Python async web frameworks for me, compare the pros and cons of FastAPI, Starlette, and Sanic\n```\n\n**Data analysis**\n\n```\nAnalyze the access logs in the logs directory, count the call frequency and average response time for each endpoint\n```\n\n**Batch file processing**\n\n```\nConvert all PNG images in the images directory to JPEG format, save to the output directory\n```\n"
  },
  {
    "path": "docs/en/index.md",
    "content": "---\nlayout: home\nhero:\n  name: Kimi Code CLI\n  text: Intelligent Command Line Assistant\n  tagline: Technical Preview\n  actions:\n    - theme: brand\n      text: Getting Started\n      link: /en/guides/getting-started\n    - theme: alt\n      text: GitHub\n      link: https://github.com/MoonshotAI/kimi-cli\n---\n"
  },
  {
    "path": "docs/en/reference/keyboard.md",
    "content": "# Keyboard Shortcuts\n\nKimi Code CLI shell mode supports the following keyboard shortcuts.\n\n## Shortcuts list\n\n| Shortcut | Function |\n|----------|----------|\n| `Ctrl-X` | Toggle agent/shell mode |\n| `Shift-Tab` | Toggle plan mode (read-only research and planning) |\n| `Ctrl-O` | Edit in external editor (`$VISUAL`/`$EDITOR`) |\n| `Ctrl-J` | Insert newline |\n| `Alt-Enter` | Insert newline (same as `Ctrl-J`) |\n| `Ctrl-V` | Paste (supports images and video files) |\n| `Ctrl-E` | Expand full approval request content |\n| `1`–`3` | Quick select approval option |\n| `1`–`5` | Select question option by number |\n| `Ctrl-D` | Exit Kimi Code CLI |\n| `Ctrl-C` | Interrupt current operation |\n\n## Mode switching\n\n### `Ctrl-X`: Toggle agent/shell mode\n\nPress `Ctrl-X` in the input box to switch between two modes:\n\n- **Agent mode**: Input is sent to AI agent for processing\n- **Shell mode**: Input is executed as local shell command\n\nThe prompt changes based on current mode:\n- Agent mode: `✨` (normal) or `💫` (thinking mode)\n- Plan mode: `📋`\n- Shell mode: `$`\n\n## Plan mode\n\n### `Shift-Tab`: Toggle plan mode\n\nPress `Shift-Tab` to enable or disable plan mode. In plan mode, the AI can only use read-only tools to explore the codebase, writing an implementation plan to a plan file and submitting it for your approval.\n\nWhen enabled, the prompt changes to `📋` and a blue `plan` badge appears in the status bar. You can also use the `/plan` slash command to manage plan mode. See [Plan mode](../guides/interaction.md#plan-mode) for details.\n\n## External editor\n\n### `Ctrl-O`: Edit in external editor\n\nPress `Ctrl-O` to open an external editor (e.g., VS Code, Vim) to edit the current input content. The editor is selected in the following priority:\n\n1. Editor configured via `/editor` command\n2. `$VISUAL` environment variable\n3. `$EDITOR` environment variable\n4. Auto-detect: `code --wait` (VS Code) → `vim` → `vi` → `nano`\n\nUse the `/editor` command to interactively switch editors, or specify directly, e.g., `/editor vim`.\n\nAfter saving and exiting the editor, the edited content replaces the current input. If you quit without saving (e.g., `:q!` in Vim), the input remains unchanged. If the input contains pasted text placeholders, the editor automatically expands them to the original text for editing; unmodified portions are re-collapsed into placeholders after saving.\n\nUseful for writing multi-line prompts, complex code snippets, etc.\n\n## Multi-line input\n\n### `Ctrl-J` / `Alt-Enter`: Insert newline\n\nBy default, pressing `Enter` submits the input. To enter multi-line content, use:\n\n- `Ctrl-J`: Insert newline at any position\n- `Alt-Enter`: Insert newline at any position\n\nUseful for entering multi-line code snippets or formatted text.\n\n## Clipboard operations\n\n### `Ctrl-V`: Paste\n\nPaste clipboard content into the input box. Supports:\n\n- **Text**: In agent mode, text longer than 1000 characters or 15 lines is automatically collapsed into a `[Pasted text #n]` placeholder to keep the input box clean; the full content is expanded and sent to the model when submitting. When using `Ctrl-O` to open an external editor, placeholders are automatically expanded to the original text, and unmodified portions are re-collapsed after saving\n- **Images**: Cached to disk and displayed as an `[image:xxx.png,WxH]` placeholder; the actual image data is sent along with the message to the model (requires model image input support)\n- **Video files**: File path is inserted as text into the input box (requires model video input support)\n\n::: tip\nImage pasting requires the model to support `image_in` capability. Video pasting requires the model to support `video_in` capability.\n:::\n\n## Approval request operations\n\n### `Ctrl-E`: Expand full content\n\nWhen approval request preview content is truncated, press `Ctrl-E` to view the full content in a fullscreen pager. When preview is truncated, a \"... (truncated, ctrl-e to expand)\" hint is displayed.\n\nUseful for viewing longer shell commands or file diff content.\n\n### Number key quick selection\n\nIn the approval panel, press `1`–`3` to directly select and submit the corresponding approval option without navigating with arrow keys first.\n\n## Structured question operations\n\nWhen the AI uses the `AskUserQuestion` tool to ask you a question, the question panel supports the following keyboard operations:\n\n| Shortcut | Function |\n|----------|----------|\n| `↑` / `↓` | Navigate options |\n| `←` / `→` / `Tab` | Switch between questions (multi-question mode) |\n| `1`–`5` | Select option by number (auto-submits for single-select, toggles for multi-select) |\n| `Space` | Submit selection in single-select mode, toggle selection in multi-select mode |\n| `Enter` | Confirm selection |\n| `Esc` | Skip question |\n\nWhen the AI asks multiple questions at once, the question panel displays them as tabs. Use `←` / `→` or `Tab` to switch between questions. Answered questions are marked as complete, and switching back to a previously answered question restores your earlier selection.\n\n## Exit and interrupt\n\n### `Ctrl-D`: Exit\n\nPress `Ctrl-D` when the input box is empty to exit Kimi Code CLI.\n\n### `Ctrl-C`: Interrupt\n\n- In input box: Clear current input\n- During agent execution: Interrupt current operation\n- During slash command execution: Interrupt command\n\n## Completion operations\n\nIn agent mode, a completion menu is automatically displayed while typing:\n\n| Trigger | Completion content |\n|---------|-------------------|\n| `/` | Slash commands |\n| `@` | File paths in working directory |\n\nCompletion operations:\n- Arrow keys to select\n- `Enter` to confirm selection\n- `Esc` to close menu\n- Continue typing to filter options\n\n## Status bar\n\nThe bottom status bar displays:\n\n- Current time\n- Current mode (agent/shell) and model name (in agent mode)\n- YOLO badge (yellow, when enabled)\n- Plan badge (blue, when enabled)\n- Shortcut hints\n- Context usage\n\nThe status bar automatically refreshes to update information.\n"
  },
  {
    "path": "docs/en/reference/kimi-acp.md",
    "content": "# `kimi acp` Subcommand\n\nThe `kimi acp` command starts a multi-session ACP (Agent Client Protocol) server.\n\n```sh\nkimi acp\n```\n\n## Description\n\nACP is a standardized protocol that allows IDEs and other clients to interact with AI agents.\n\n## Use cases\n\n- IDE plugin integration (e.g., JetBrains, Zed)\n- Custom ACP client development\n- Multi-session concurrent processing\n\nFor using Kimi Code CLI in IDEs, see [Using in IDEs](../guides/ides.md).\n\n## Authentication\n\nThe ACP server checks user authentication status before creating or loading sessions. If the user is not logged in, the server returns an `AUTH_REQUIRED` error (code `-32000`) with available authentication method details.\n\nUpon receiving this error, the client should guide the user to run the `kimi login` command in the terminal to complete login. Once logged in, subsequent ACP requests will proceed normally.\n"
  },
  {
    "path": "docs/en/reference/kimi-command.md",
    "content": "# `kimi` Command\n\n`kimi` is the main command for Kimi Code CLI, used to start interactive sessions or execute single queries.\n\n```sh\nkimi [OPTIONS] COMMAND [ARGS]\n```\n\n## Basic information\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--version` | `-V` | Show version number and exit |\n| `--help` | `-h` | Show help message and exit |\n| `--verbose` | | Output detailed runtime information |\n| `--debug` | | Log debug information (output to `~/.kimi/logs/kimi.log`) |\n\n## Agent configuration\n\n| Option | Description |\n|--------|-------------|\n| `--agent NAME` | Use built-in agent, options: `default`, `okabe` |\n| `--agent-file PATH` | Use custom agent file |\n\n`--agent` and `--agent-file` are mutually exclusive. See [Agents and Subagents](../customization/agents.md) for details.\n\n## Configuration files\n\n| Option | Description |\n|--------|-------------|\n| `--config STRING` | Load TOML/JSON configuration string |\n| `--config-file PATH` | Load configuration file (default `~/.kimi/config.toml`) |\n\n`--config` and `--config-file` are mutually exclusive. Both configuration strings and files support TOML and JSON formats. See [Config Files](../configuration/config-files.md) for details.\n\n## Model selection\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--model NAME` | `-m` | Specify LLM model, overrides default model in config file |\n\n## Working directory\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--work-dir PATH` | `-w` | Specify working directory (default current directory) |\n| `--add-dir PATH` | | Add an additional directory to the workspace scope, can be specified multiple times |\n\nThe working directory determines the root directory for file operations. Relative paths work within the working directory; absolute paths are required to access files outside it.\n\n`--add-dir` expands the workspace scope to include directories outside the working directory, making all file tools able to access files in those directories. Added directories are persisted with the session state. You can also add directories at runtime via the [`/add-dir`](./slash-commands.md#add-dir) slash command.\n\n## Session management\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--continue` | `-C` | Continue the previous session in the current working directory |\n| `--session ID` | `-S` | Resume session with specified ID, creates new session if not exists |\n\n`--continue` and `--session` are mutually exclusive.\n\n## Input and commands\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--prompt TEXT` | `-p` | Pass user prompt, doesn't enter interactive mode |\n| `--command TEXT` | `-c` | Alias for `--prompt` |\n\nWhen using `--prompt` (or `--command`), Kimi Code CLI exits after processing the query (unless `--print` is specified, results are still displayed in interactive mode).\n\n## Loop control\n\n| Option | Description |\n|--------|-------------|\n| `--max-steps-per-turn N` | Maximum steps per turn, overrides `loop_control.max_steps_per_turn` in config file |\n| `--max-retries-per-step N` | Maximum retries per step, overrides `loop_control.max_retries_per_step` in config file |\n| `--max-ralph-iterations N` | Number of iterations for Ralph Loop mode; `0` disables; `-1` is unlimited |\n\n### Ralph Loop\n\n[Ralph](https://ghuntley.com/ralph/) is a technique that puts an agent in a loop: the same prompt is fed again and again so the agent can keep iterating one big task.\n\nWhen `--max-ralph-iterations` is not `0`, Kimi Code CLI enters Ralph Loop mode and automatically loops through task execution until the agent outputs `<choice>STOP</choice>` or the iteration limit is reached.\n\n## UI modes\n\n| Option | Description |\n|--------|-------------|\n| `--print` | Run in print mode (non-interactive), implicitly enables `--yolo` |\n| `--quiet` | Shortcut for `--print --output-format text --final-message-only` |\n| `--acp` | Run in ACP server mode (deprecated, use `kimi acp` instead) |\n| `--wire` | Run in Wire server mode (experimental) |\n\nThe four options are mutually exclusive, only one can be selected. Default is shell mode. See [Print Mode](../customization/print-mode.md) and [Wire Mode](../customization/wire-mode.md) for details.\n\n## Print mode options\n\nThe following options are only effective in `--print` mode:\n\n| Option | Description |\n|--------|-------------|\n| `--input-format FORMAT` | Input format: `text` (default) or `stream-json` |\n| `--output-format FORMAT` | Output format: `text` (default) or `stream-json` |\n| `--final-message-only` | Only output the final assistant message |\n\n`stream-json` format uses JSONL (one JSON object per line) for programmatic integration.\n\n## MCP configuration\n\n| Option | Description |\n|--------|-------------|\n| `--mcp-config-file PATH` | Load MCP config file, can be specified multiple times |\n| `--mcp-config JSON` | Load MCP config JSON string, can be specified multiple times |\n\nDefault loads `~/.kimi/mcp.json` (if exists). See [Model Context Protocol](../customization/mcp.md) for details.\n\n## Approval control\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--yolo` | `-y` | Auto-approve all operations |\n| `--yes` | | Alias for `--yolo` |\n| `--auto-approve` | | Alias for `--yolo` |\n\n::: warning Note\nIn YOLO mode, all file modifications and shell commands are automatically executed. Use with caution.\n:::\n\n## Thinking mode\n\n| Option | Description |\n|--------|-------------|\n| `--thinking` | Enable thinking mode |\n| `--no-thinking` | Disable thinking mode |\n\nThinking mode requires model support. If not specified, uses the last session's setting.\n\n## Skills configuration\n\n| Option | Description |\n|--------|-------------|\n| `--skills-dir PATH` | Specify skills directory, skipping auto-discovery |\n\nWhen not specified, Kimi Code CLI automatically discovers user-level and project-level skills directories in priority order. See [Agent Skills](../customization/skills.md) for details.\n\n## Subcommands\n\n| Subcommand | Description |\n|------------|-------------|\n| [`kimi login`](#kimi-login) | Log in to your Kimi account |\n| [`kimi logout`](#kimi-logout) | Log out from your Kimi account |\n| [`kimi info`](./kimi-info.md) | Display version and protocol information |\n| [`kimi acp`](./kimi-acp.md) | Start multi-session ACP server |\n| [`kimi mcp`](./kimi-mcp.md) | Manage MCP server configuration |\n| [`kimi term`](./kimi-term.md) | Launch the Toad terminal UI |\n| [`kimi export`](#kimi-export) | Export a session as a ZIP file |\n| [`kimi vis`](./kimi-vis.md) | Launch the Agent Tracing Visualizer (Technical Preview) |\n| [`kimi web`](./kimi-web.md) | Start the Web UI server |\n\n### `kimi login`\n\nLog in to your Kimi account. This automatically opens a browser; complete account authorization and available models will be automatically configured.\n\n```sh\nkimi login\n```\n\n### `kimi logout`\n\nLog out from your Kimi account. This clears stored OAuth credentials and removes related configuration from the config file.\n\n```sh\nkimi logout\n```\n\n### `kimi export`\n\nExport the data of a specified session as a ZIP file. The ZIP contains all files in the session directory (`context.jsonl`, `wire.jsonl`, `state.json`, etc.).\n\n```sh\nkimi export <session_id> [-o <output_path>]\n```\n\n| Argument / Option | Description |\n|--------|-------------|\n| `<session_id>` | Session ID to export |\n| `--output, -o` | Output ZIP file path (defaults to `session-<id>.zip` in the current directory) |\n\n::: info Added\nAdded in version 1.20.\n:::\n\n### `kimi vis`\n\n::: warning Note\nTechnical Preview feature, may be unstable.\n:::\n\nLaunch the Agent Tracing Visualizer to view and analyze session traces in a browser.\n\n```sh\nkimi vis [OPTIONS]\n```\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--port INTEGER` | `-p` | Port number to bind to (default: `5495`) |\n| `--open / --no-open` | | Automatically open browser (default: enabled) |\n| `--reload` | | Enable auto-reload (development mode) |\n\nSee [Agent Tracing Visualizer](./kimi-vis.md) for details.\n\n### `kimi web`\n\nStart the Web UI server to access Kimi Code CLI through a browser.\n\n```sh\nkimi web [OPTIONS]\n```\n\nIf the default port is in use, the server will pick the next available port (by default `5494`–`5503`) and print a notice in the terminal.\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--host TEXT` | `-h` | Host address to bind to (default: `127.0.0.1`) |\n| `--port INTEGER` | `-p` | Port number to bind to (default: `5494`) |\n| `--reload` | | Enable auto-reload (development mode) |\n| `--open / --no-open` | | Automatically open browser (default: enabled) |\n\nExamples:\n\n```sh\n# Default startup, automatically opens browser\nkimi web\n\n# Specify port\nkimi web --port 8080\n\n# Don't automatically open browser\nkimi web --no-open\n\n# Bind to all network interfaces (allow LAN access)\nkimi web --host 0.0.0.0\n```\n\nSee [Web UI](./kimi-web.md) for details.\n"
  },
  {
    "path": "docs/en/reference/kimi-info.md",
    "content": "# `kimi info` Subcommand\n\n`kimi info` displays version and protocol information for Kimi Code CLI.\n\n```sh\nkimi info [--json]\n```\n\n## Options\n\n| Option | Description |\n|--------|-------------|\n| `--json` | Output in JSON format |\n\n## Output\n\n| Field | Description |\n|-------|-------------|\n| `kimi_cli_version` | Kimi Code CLI version number |\n| `agent_spec_versions` | List of supported agent spec versions |\n| `wire_protocol_version` | Wire protocol version |\n| `python_version` | Python runtime version |\n\n## Examples\n\n**Text output**\n\n```sh\n$ kimi info\nkimi-cli version: 1.20.0\nagent spec versions: 1\nwire protocol: 1.5\npython version: 3.13.1\n```\n\n**JSON output**\n\n```sh\n$ kimi info --json\n{\"kimi_cli_version\": \"1.20.0\", \"agent_spec_versions\": [\"1\"], \"wire_protocol_version\": \"1.5\", \"python_version\": \"3.13.1\"}\n```\n"
  },
  {
    "path": "docs/en/reference/kimi-mcp.md",
    "content": "# `kimi mcp` Subcommand\n\n`kimi mcp` is used to manage MCP (Model Context Protocol) server configurations. For concepts and usage of MCP, see [Model Context Protocol](../customization/mcp.md).\n\n```sh\nkimi mcp COMMAND [ARGS]\n```\n\n## `add`\n\nAdd an MCP server configuration.\n\n```sh\nkimi mcp add [OPTIONS] NAME [TARGET_OR_COMMAND...]\n```\n\n**Arguments**\n\n| Argument | Description |\n|----------|-------------|\n| `NAME` | Server name, used for identification and reference |\n| `TARGET_OR_COMMAND...` | URL for `http` mode; command for `stdio` mode (must start with `--`) |\n\n**Options**\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--transport TYPE` | `-t` | Transport type: `stdio` (default) or `http` |\n| `--env KEY=VALUE` | `-e` | Environment variable (`stdio` only), can be specified multiple times |\n| `--header KEY:VALUE` | `-H` | HTTP header (`http` only), can be specified multiple times |\n| `--auth TYPE` | `-a` | Authentication type (e.g., `oauth`, `http` only) |\n\n## `list`\n\nList all configured MCP servers.\n\n```sh\nkimi mcp list\n```\n\nOutput includes:\n- Configuration file path\n- Name, transport type, and target for each server\n- Authorization status for OAuth servers\n\n## `remove`\n\nRemove an MCP server configuration.\n\n```sh\nkimi mcp remove NAME\n```\n\n**Arguments**\n\n| Argument | Description |\n|----------|-------------|\n| `NAME` | Name of server to remove |\n\n## `auth`\n\nAuthorize an MCP server that uses OAuth.\n\n```sh\nkimi mcp auth NAME\n```\n\nThis will open a browser for the OAuth authorization flow. After successful authorization, the token is cached for future use.\n\n**Arguments**\n\n| Argument | Description |\n|----------|-------------|\n| `NAME` | Name of server to authorize |\n\n::: tip\nOnly servers added with `--auth oauth` require this command.\n:::\n\n## `reset-auth`\n\nClear the cached OAuth token for an MCP server.\n\n```sh\nkimi mcp reset-auth NAME\n```\n\n**Arguments**\n\n| Argument | Description |\n|----------|-------------|\n| `NAME` | Name of server to reset authorization |\n\nAfter clearing, you need to run `kimi mcp auth` again to re-authorize.\n\n## `test`\n\nTest connection to an MCP server and list available tools.\n\n```sh\nkimi mcp test NAME\n```\n\n**Arguments**\n\n| Argument | Description |\n|----------|-------------|\n| `NAME` | Name of server to test |\n\nOutput includes:\n- Connection status\n- Number of available tools\n- Tool names and descriptions\n"
  },
  {
    "path": "docs/en/reference/kimi-term.md",
    "content": "# `kimi term` Subcommand\n\nThe `kimi term` command launches the [Toad](https://github.com/batrachianai/toad) terminal UI, a modern terminal interface built with [Textual](https://textual.textualize.io/).\n\n```sh\nkimi term [OPTIONS]\n```\n\n## Description\n\n[Toad](https://github.com/batrachianai/toad) is a graphical terminal interface for Kimi Code CLI that communicates with the Kimi Code CLI backend via the ACP protocol. It provides a richer interactive experience with better output rendering and layout.\n\nWhen you run `kimi term`, it automatically starts a `kimi acp` server in the background, and Toad connects to it as an ACP client.\n\n## Options\n\nAll extra options are passed through to the internal `kimi acp` command. For example:\n\n```sh\nkimi term --work-dir /path/to/project --model kimi-k2\n```\n\nCommon options:\n\n| Option | Description |\n|--------|-------------|\n| `--work-dir PATH` | Specify working directory |\n| `--model NAME` | Specify model |\n| `--yolo` | Auto-approve all operations |\n\nFor the full list of options, see [`kimi` command](./kimi-command.md).\n\n## System requirements\n\n::: warning Note\n`kimi term` requires Python 3.14+. If you installed Kimi Code CLI with an older Python version, you need to reinstall with Python 3.14:\n\n```sh\nuv tool install --python 3.14 kimi-cli\n```\n:::\n"
  },
  {
    "path": "docs/en/reference/kimi-vis.md",
    "content": "# Agent Tracing Visualizer\n\n::: warning Note\nAgent Tracing Visualizer is currently in Technical Preview and may be unstable. Features and interface may change in future releases.\n:::\n\nAgent Tracing Visualizer is a browser-based visualization dashboard for inspecting and analyzing Kimi Code CLI session traces. It helps you understand agent behavior, view Wire event timelines, analyze context usage, and browse historical sessions.\n\n## Launch\n\nRun `kimi vis` in the terminal to start the Visualizer:\n\n```sh\nkimi vis\n```\n\nThe server automatically opens a browser after startup. The default address is `http://127.0.0.1:5495`.\n\nIf the default port is in use, the server will pick the next available port (by default `5495`–`5504`) and print the access URL in the terminal.\n\n## Command-line options\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--port INTEGER` | `-p` | Port number to bind to (default: `5495`) |\n| `--open / --no-open` | | Automatically open browser (default: `--open`) |\n| `--reload` | | Enable auto-reload (development mode) |\n\nExamples:\n\n```sh\n# Specify port\nkimi vis --port 8080\n\n# Don't automatically open browser\nkimi vis --no-open\n```\n\n## Features\n\n### Wire event timeline\n\nDisplays the complete Wire event flow as a timeline, including turn start/end, step execution, tool calls and results. Supports event filtering and detailed information viewing.\n\n### Context viewer\n\nVisualizes session context content, including user messages, assistant messages, and tool calls. Helps you understand what the agent \"sees\" at each step.\n\n### Session explorer\n\nBrowse and search all historical sessions, grouped by project. View detailed information for each session, including working directory, creation time, and message count.\n\n### Session directory shortcuts\n\nAt the top of the session detail page, you can use `Open Dir` to open the current session directory directly. On macOS this opens Finder; on Windows it opens Explorer. `Copy DIR` copies the raw session directory path so you can continue debugging in a terminal, editor, or issue report.\n\n### Session download and export\n\nYou can export session data as a ZIP file for offline analysis or sharing.\n\n- **ZIP download**: Click the download button in the session explorer or session detail page to download the session directory as a ZIP file\n- **CLI export**: Use the `kimi export <session_id>` command to export a specified session as a ZIP file\n\n### Session import\n\nSupports importing ZIP-format session data into the Visualizer for viewing. Imported sessions are stored in a dedicated `~/.kimi/imported_sessions/` directory, separate from regular sessions.\n\nIn the session explorer, you can use the \"Imported\" filter toggle to switch between viewing imported sessions. Imported sessions support deletion, with a confirmation dialog before removal.\n\n### Usage statistics\n\nDisplays token usage statistics and charts, including input/output token distribution and cache hit rates.\n"
  },
  {
    "path": "docs/en/reference/kimi-web.md",
    "content": "# Web UI\n\nWeb UI provides a browser-based interactive interface, allowing you to use all features of Kimi Code CLI in a web page. Compared to the terminal interface, Web UI offers a richer visual experience, more flexible session management, and more convenient file operations.\n\n## Starting Web UI\n\nRun the `kimi web` command in your terminal to start the Web UI server:\n\n```sh\nkimi web\n```\n\nAfter the server starts, it will automatically open your browser to access the Web UI. The default address is `http://127.0.0.1:5494`.\n\nIf the default port is occupied, the server will automatically try the next available port (default range `5494`–`5503`) and print the access address in the terminal.\n\n## Command line options\n\n### Network configuration\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--host TEXT` | `-h` | Bind to specific IP address |\n| `--network` | `-n` | Enable network access (bind to `0.0.0.0`) |\n| `--port INTEGER` | `-p` | Specify port number (default: `5494`) |\n\nBy default, Web UI only listens on the local loopback address `127.0.0.1`, allowing access only from the local machine.\n\nIf you want to access Web UI from a local network or the public internet, you can use the `--network` option or specify `--host`:\n\n```sh\n# Bind to all network interfaces, allowing LAN access\nkimi web --network\n\n# Bind to a specific IP address\nkimi web --host 192.168.1.100\n```\n\n::: warning Note\nWhen enabling network access, be sure to configure access control options (such as `--auth-token` and `--lan-only`) to ensure security. See [Access control](#access-control).\n:::\n\n### Browser control\n\n| Option | Description |\n|--------|-------------|\n| `--open / --no-open` | Automatically open browser on startup (default: `--open`) |\n\nUse `--no-open` to prevent automatically opening the browser:\n\n```sh\nkimi web --no-open\n```\n\n### Development options\n\n| Option | Description |\n|--------|-------------|\n| `--reload` | Enable auto-reload (for development) |\n\nUse `--reload` to automatically restart the server after code changes:\n\n```sh\nkimi web --reload\n```\n\n::: info Note\nThe `--reload` option is only for development purposes and is not needed for daily use.\n:::\n\n### Access control\n\nWeb UI provides multi-layer access control mechanisms to ensure service security.\n\n| Option | Description |\n|--------|-------------|\n| `--auth-token TEXT` | Set Bearer Token for API authentication |\n| `--allowed-origins TEXT` | Set allowed Origin list (comma-separated) |\n| `--lan-only / --public` | Only allow LAN access (default) or allow public access |\n| `--restrict-sensitive-apis / --no-restrict-sensitive-apis` | Restrict sensitive API access (config write, open-in, file access limits) |\n| `--dangerously-omit-auth` | Disable authentication checks (dangerous, trusted networks only) |\n\n::: info Added\nAccess control options added in version 1.6.\n:::\n\n#### Access token authentication\n\nUse `--auth-token` to set an access token. Clients need to include `Authorization: Bearer <token>` in the HTTP request header to access the API:\n\n```sh\nkimi web --network --auth-token my-secret-token\n```\n\n::: tip\nThe access token should be a randomly generated string with at least 32 characters. You can use `openssl rand -hex 32` to generate a random token.\n:::\n\n#### Origin checking\n\nUse `--allowed-origins` to restrict the origin domains that can access Web UI:\n\n```sh\nkimi web --network --allowed-origins \"https://example.com,https://app.example.com\"\n```\n\n::: tip\nWhen using `--network` or `--host` to enable network access, it is recommended to configure `--allowed-origins` to prevent Cross-Site Request Forgery (CSRF) attacks.\n:::\n\n#### Network access scope\n\nBy default, Web UI uses `--lan-only` mode, only allowing access from the local network (private IP address ranges). If you need to allow public access, use the `--public` option:\n\n```sh\nkimi web --network --public --auth-token my-secret-token\n```\n\n::: danger Warning\nUsing the `--public` option will allow access from any IP address. Be sure to configure `--auth-token` and `--allowed-origins` to ensure security.\n:::\n\n#### Restricting sensitive APIs\n\nUse `--restrict-sensitive-apis` to disable some sensitive API features:\n\n- Config file writing\n- Open-in functionality (opening local files, directories, applications)\n- File access restrictions\n\n```sh\nkimi web --network --restrict-sensitive-apis\n```\n\nIn `--public` mode, `--restrict-sensitive-apis` is enabled by default; in `--lan-only` mode (default), it is not enabled.\n\n::: tip\nWhen you need to expose Web UI to untrusted network environments, it is recommended to enable the `--restrict-sensitive-apis` option.\n:::\n\n#### Disabling authentication (not recommended)\n\nIn trusted private network environments, you can use `--dangerously-omit-auth` to skip all authentication checks:\n\n```sh\nkimi web --dangerously-omit-auth\n```\n\n::: danger Warning\nThe `--dangerously-omit-auth` option completely disables authentication and access control. It should only be used in fully trusted network environments (such as offline local development environments). Do not use this option on the public internet or untrusted local networks.\n:::\n\n## Switching from terminal to Web UI\n\nIf you are using Kimi Code CLI in shell mode in the terminal, you can enter the `/web` command to quickly switch to Web UI:\n\n```\n/web\n```\n\nAfter execution, Kimi Code CLI will automatically start the Web UI server and open the current session in the browser. You can continue the conversation in Web UI, and the session history will remain synchronized.\n\n## Web UI features\n\n### Session management\n\nWeb UI provides a convenient session management interface:\n\n- **Session list**: View all historical sessions, including session title and working directory\n- **Session search**: Quickly filter sessions by title or working directory\n- **Create session**: Create a new session with a specified working directory; if the specified path doesn't exist, you will be prompted to confirm creating the directory. Supports Cmd/Ctrl+Click on new-session buttons to open session creation in a new tab\n- **Switch session**: Switch to different sessions with one click\n- **Session fork**: Create a branching session from any assistant response, exploring different directions without affecting the original session\n- **Session archive**: Sessions older than 15 days are automatically archived. You can also archive manually. Archived sessions don't appear in the main list but can be unarchived at any time\n- **Bulk operations**: Bulk archive, unarchive, or delete sessions in multi-select mode\n\n::: info Added\nSession search feature added in version 1.5. Directory auto-creation prompt added in version 1.7. Session fork, archive, and bulk operations added in version 1.9.\n:::\n\n### Prompt toolbar\n\nWeb UI provides a unified prompt toolbar above the input box, displaying various information in collapsible tabs:\n\n- **Context usage**: Shows the current context usage percentage. Hover to view detailed token usage breakdown (including input/output tokens, cache read/write, etc.)\n- **Activity status**: Shows the current agent state (processing, waiting for approval, etc.)\n- **Message queue**: Queue follow-up messages while the AI is processing; queued messages are sent automatically when the current response completes\n- **File changes**: Detects Git repository status, showing the number of new, modified, and deleted files (including untracked files). Click to view a detailed list of changes\n- **Todo list**: When the `SetTodoList` tool is active, shows task progress with support for expanding to view the detailed list\n- **Plan mode**: Toggle plan mode on/off from the input toolbar. When plan mode is active, the composer displays a dashed blue border. Plan mode can also be set programmatically via the `set_plan_mode` Wire protocol method\n\n::: info Changed\nGit diff status bar added in version 1.5. Activity status indicator added in version 1.9. Version 1.10 unified it into the prompt toolbar. Version 1.11 moved the context usage indicator to the prompt toolbar. Plan mode toggle added in version 1.20.\n:::\n\n### Open-in functionality\n\nWeb UI supports opening files or directories in local applications:\n\n- **Open in Terminal**: Open directory in terminal\n- **Open in VS Code**: Open file or directory in VS Code\n- **Open in Cursor**: Open file or directory in Cursor\n- **Open in System**: Open with system default application\n\n::: info Added\nOpen-in functionality added in version 1.5.\n:::\n\n::: warning Note\nOpen-in functionality requires browser support for Custom Protocol Handler. This feature is disabled when using the `--restrict-sensitive-apis` option.\n:::\n\n### Slash commands\n\nWeb UI supports slash commands. Type `/` in the input box to open the command menu:\n\n- **Autocomplete**: Filter matching commands as you type\n- **Keyboard navigation**: Use up/down arrow keys to select commands, Enter to confirm\n- **Alias support**: Support command alias matching, e.g., `/h` matches `/help`\n\n### File mentions\n\nWeb UI supports file mentions. Type `@` in the input box to open the file mention menu, allowing you to reference files in your conversation:\n\n- **Uploaded attachments**: Mention files attached to the current message\n- **Workspace files**: Mention existing files in the current session's working directory\n- **Autocomplete**: Filter matching files by name or path as you type\n- **Keyboard navigation**: Use up/down arrow keys to select files, Enter or Tab to confirm, Escape to cancel\n\n### Message actions\n\nAssistant messages provide the following action buttons:\n\n- **Copy**: Copy message content to clipboard with one click\n- **Fork**: Create a branching session from the current response\n\n::: info Added\nCopy and fork buttons added in version 1.10.\n:::\n\n### Structured questions\n\nWhen the AI uses the `AskUserQuestion` tool, Web UI displays a structured question dialog in the chat area, replacing the input box at the bottom. The question dialog shows the question description and available options, supporting single-select, multi-select, and custom text input. When the AI asks multiple questions at once, the dialog shows a tab bar at the top listing all questions, with support for click navigation, keyboard navigation, and restoring previous selections when revisiting answered questions. After answering all questions, the dialog closes automatically and the AI continues execution based on your choices.\n\n::: info Added\nStructured questions added in version 1.14.\n:::\n\n### Approval keyboard shortcuts\n\nWhen the agent sends an approval request, you can use keyboard shortcuts to respond quickly:\n\n| Shortcut | Action |\n|----------|--------|\n| `1` | Approve |\n| `2` | Approve for session |\n| `3` | Decline |\n\n::: info Added\nApproval keyboard shortcuts added in version 1.10.\n:::\n\n### Tool output\n\nWeb UI provides rich display for tool call output:\n\n- **Media preview**: Images and videos read by the `ReadMediaFile` tool are displayed as clickable thumbnails\n- **Shell commands**: `Shell` tool commands and output are rendered with dedicated components\n- **Todo list**: `SetTodoList` tool items are displayed as a structured list\n- **Tool input parameters**: Redesigned tool input UI with expandable parameter details and syntax highlighting for long values\n- **Context compaction**: A compaction indicator is shown when context compaction is in progress\n- **Quick URL open**: The URL parameter of the `FetchURL` tool supports Cmd/Ctrl+Click to open the link in a new tab\n\n::: info Added\nMedia preview, shell command, and todo list display components added in version 1.9. Quick URL open added in version 1.14.\n:::\n\n### Rich media support\n\nWeb UI supports viewing and pasting various types of rich media content:\n\n- **Images**: Display images directly in the chat interface\n- **Code highlighting**: Automatic code block recognition and highlighting\n- **Markdown rendering**: Support for full Markdown syntax\n\n### Responsive layout\n\nWeb UI uses responsive design and displays well on screens of different sizes:\n\n- Desktop: Sidebar + main content area layout\n- Mobile: Collapsible drawer-style sidebar\n\n::: info Changed\nResponsive layout improved in version 1.6 with enhanced hover effects and better layout handling.\n:::\n\n### URL action parameters\n\nWeb UI supports URL parameters to trigger specific actions, making it easy to integrate from external tools or scripts:\n\n| Parameter | Description |\n|-----------|-------------|\n| `?action=create` | Open the create-session dialog |\n| `?action=create-in-dir&workDir=<path>` | Directly create a session in the specified working directory |\n\nExamples:\n\n```\nhttp://127.0.0.1:5494?action=create\nhttp://127.0.0.1:5494?action=create-in-dir&workDir=/path/to/project\n```\n\n## Examples\n\n### Local use\n\nThe simplest usage, accessible only from the local machine:\n\n```sh\nkimi web\n```\n\n### LAN sharing\n\nShare Web UI on the local network with access token protection:\n\n```sh\nkimi web --network --auth-token $(openssl rand -hex 32)\n```\n\nAfter execution, the terminal will display the access address and token. Other devices can access through that address and enter the token in the browser for authentication.\n\n### Public access\n\nDeploy Web UI in a public internet environment (requires careful security configuration):\n\n```sh\nkimi web \\\n  --host 0.0.0.0 \\\n  --public \\\n  --auth-token $(openssl rand -hex 32) \\\n  --allowed-origins \"https://yourdomain.com\" \\\n  --restrict-sensitive-apis\n```\n\n### Development\n\nEnable auto-reload for development purposes:\n\n```sh\nkimi web --reload --no-open\n```\n\n## Technical details\n\nWeb UI is built on the following technologies:\n\n- **Backend**: FastAPI + WebSocket\n- **Frontend**: React + TypeScript + Vite\n- **API protocol**: Complies with OpenAPI specification, see `web/openapi.json`\n\nWeb UI communicates with Kimi Code CLI's Wire mode via WebSocket, enabling real-time bidirectional data transmission.\n"
  },
  {
    "path": "docs/en/reference/slash-commands.md",
    "content": "# Slash Commands\n\nSlash commands are built-in commands for Kimi Code CLI, used to control sessions, configuration, and debugging. Enter a command starting with `/` in the input box to trigger.\n\n::: tip Shell mode\nSome slash commands are also available in shell mode, including `/help`, `/exit`, `/version`, `/editor`, `/changelog`, `/feedback`, `/export`, `/import`, and `/task`.\n:::\n\n## Help and info\n\n### `/help`\n\nDisplay help information. Shows keyboard shortcuts, all available slash commands, and loaded skills in a fullscreen pager. Press `q` to exit.\n\nAliases: `/h`, `/?`\n\n### `/version`\n\nDisplay Kimi Code CLI version number.\n\n### `/changelog`\n\nDisplay the changelog for recent versions.\n\nAlias: `/release-notes`\n\n### `/feedback`\n\nOpen the GitHub Issues page to submit feedback.\n\n## Account and configuration\n\n### `/login`\n\nLog in or configure an API platform. After execution, first select a platform:\n\n- **Kimi Code**: Automatically opens a browser for OAuth authorization\n- **Other platforms**: Enter an API key, then select an available model\n\nAfter configuration, settings are automatically saved to `~/.kimi/config.toml` and reloaded. See [Providers](../configuration/providers.md) for details.\n\nAlias: `/setup`\n\n::: tip\nThis command is only available when using the default configuration file. If a configuration was specified via `--config` or `--config-file`, this command cannot be used.\n:::\n\n### `/logout`\n\nLog out from the current platform. This clears stored credentials and removes related configuration from the config file. After logout, Kimi Code CLI will automatically reload the configuration.\n\n### `/model`\n\nSwitch models and thinking mode.\n\nThis command first refreshes the available models list from the API platform. When called without arguments, displays an interactive selection interface where you first select a model, then choose whether to enable thinking mode (if the model supports it).\n\nAfter selection, Kimi Code CLI will automatically update the configuration file and reload.\n\n::: tip\nThis command is only available when using the default configuration file. If a configuration was specified via `--config` or `--config-file`, this command cannot be used.\n:::\n\n### `/editor`\n\nSet the external editor. When called without arguments, displays an interactive selection interface; you can also specify the editor command directly, e.g., `/editor vim`. After configuration, pressing `Ctrl-O` will open this editor to edit the current input content. See [Keyboard shortcuts](./keyboard.md#external-editor) for details.\n\n### `/reload`\n\nReload the configuration file without exiting Kimi Code CLI.\n\n### `/debug`\n\nDisplay debug information for the current context, including:\n- Number of messages and tokens\n- Number of checkpoints\n- Complete message history\n\nDebug information is displayed in a pager, press `q` to exit.\n\n### `/usage`\n\nDisplay API usage and quota information, showing quota usage with progress bars and remaining percentages.\n\nAlias: `/status`\n\n::: tip\nThis command only works with the Kimi Code platform.\n:::\n\n### `/mcp`\n\nDisplay currently connected MCP servers and loaded tools. See [Model Context Protocol](../customization/mcp.md) for details.\n\nOutput includes:\n- Server connection status (green indicates connected)\n- List of tools provided by each server\n\n## Session management\n\n### `/new`\n\nCreate a new session and switch to it immediately, without exiting Kimi Code CLI. If the current session has no content, the empty session directory is automatically cleaned up.\n\n### `/sessions`\n\nList all sessions in the current working directory, allowing switching to other sessions.\n\nAlias: `/resume`\n\nUse arrow keys to select a session, press `Enter` to confirm switch, press `Ctrl-C` to cancel.\n\n### `/export`\n\nExport the current session context to a Markdown file for archiving or sharing.\n\nUsage:\n\n- `/export`: Export to the current working directory with an auto-generated filename (format: `kimi-export-<first 8 chars of session ID>-<timestamp>.md`)\n- `/export <path>`: Export to the specified path. If the path is a directory, the filename is auto-generated; if it is a file path, the content is written directly to that file\n\nThe exported file includes:\n- Session metadata (session ID, export time, working directory, message count, token count)\n- Conversation overview (topic, number of turns, tool call count)\n- Complete conversation history organized by turns, including user messages, AI responses, tool calls, and tool results\n\n### `/import`\n\nImport context from a file or another session into the current session. The imported content is appended as reference context, and the AI can use this information to inform subsequent interactions.\n\nUsage:\n\n- `/import <file_path>`: Import from a file. Supports common text-based formats such as Markdown, plain text, source code, and configuration files; binary files (e.g., images, PDFs, archives) are not supported\n- `/import <session_id>`: Import from the specified session ID. Cannot import the current session into itself\n\n### `/clear`\n\nClear the current session's context and start a new conversation.\n\nAlias: `/reset`\n\n### `/compact`\n\nManually compact the context to reduce token usage. You can append custom instructions after the command to tell the AI which information to prioritize preserving during compaction, e.g., `/compact preserve database-related discussions`.\n\nWhen the context is too long, Kimi Code CLI will automatically trigger compaction. This command allows manually triggering the compaction process.\n\n## Skills\n\n### `/skill:<name>`\n\nLoad a specific skill, sending the `SKILL.md` content to the Agent as a prompt. This command works for both standard skills and flow skills.\n\nFor example:\n\n- `/skill:code-style`: Load code style guidelines\n- `/skill:pptx`: Load PPT creation workflow\n- `/skill:git-commits fix user login issue`: Load the skill with an additional task description\n\nYou can append additional text after the command, which will be added to the skill prompt. See [Agent Skills](../customization/skills.md) for details.\n\n::: tip\nFlow skills can also be invoked via `/skill:<name>`, which loads the content as a standard skill without automatically executing the flow. To execute the flow, use `/flow:<name>` instead.\n:::\n\n### `/flow:<name>`\n\nExecute a specific flow skill. Flow skills embed an Agent Flow diagram in `SKILL.md`. After execution, the Agent will start from the `BEGIN` node and process each node according to the flow diagram definition until reaching the `END` node.\n\nFor example:\n\n- `/flow:code-review`: Execute code review workflow\n- `/flow:release`: Execute release workflow\n\n::: tip\nFlow skills can also be invoked via `/skill:<name>`, which loads the content as a standard skill without automatically executing the flow.\n:::\n\nSee [Agent Skills](../customization/skills.md#flow-skills) for details.\n\n## Workspace\n\n### `/add-dir`\n\nAdd an additional directory to the workspace scope. Once added, the directory is accessible to all file tools (`ReadFile`, `WriteFile`, `Glob`, `Grep`, `StrReplaceFile`, etc.) and its directory listing is shown in the system prompt. Added directories are persisted with the session state and automatically restored when resuming.\n\nUsage:\n\n- `/add-dir <path>`: Add the specified directory to the workspace\n- `/add-dir`: Without arguments, list already added additional directories\n\n::: tip\nDirectories already within the working directory do not need to be added, as they are already accessible. You can also add directories at startup via the `--add-dir` option. See [`kimi` command](./kimi-command.md#working-directory) for details.\n:::\n\n## Others\n\n### `/init`\n\nAnalyze the current project and generate an `AGENTS.md` file.\n\nThis command starts a temporary sub-session to analyze the codebase structure and generate a project description document, helping the Agent better understand the project.\n\n### `/plan`\n\nToggle plan mode. In plan mode, the AI can only use read-only tools to explore the codebase, writing an implementation plan to a plan file and submitting it for your approval. See [Plan mode](../guides/interaction.md#plan-mode) for details.\n\nUsage:\n\n- `/plan`: Toggle plan mode\n- `/plan on`: Enable plan mode\n- `/plan off`: Disable plan mode\n- `/plan view`: View the current plan content\n- `/plan clear`: Clear the current plan file\n\nWhen plan mode is enabled, the prompt changes to `📋` and a blue `plan` badge appears in the status bar.\n\n### `/task`\n\nOpen the interactive task browser to view, monitor, and manage background tasks.\n\nThe task browser is a three-column TUI:\n\n- **Left column**: Task list showing task ID, status, and description\n- **Middle column**: Detailed information for the selected task, including ID, status, description, timestamps, exit code, etc.\n- **Right column**: Output preview showing the last few lines\n\nSupported keyboard shortcuts:\n\n| Shortcut | Action |\n|----------|--------|\n| `Enter` / `O` | View the selected task's full output in a pager |\n| `S` | Request to stop the selected task (requires confirmation) |\n| `Tab` | Toggle filter mode (all / active tasks only) |\n| `R` | Refresh the task list |\n| `Q` / `Esc` | Exit the browser |\n\nThe task browser automatically refreshes every second, showing real-time task status changes.\n\n::: tip\nBackground tasks are started by the AI using the `Shell` tool with `run_in_background=true`. The system automatically notifies the AI when background tasks complete.\n:::\n\n### `/yolo`\n\nToggle YOLO mode. When enabled, all operations are automatically approved and a yellow YOLO badge appears in the status bar; enter the command again to disable.\n\n::: warning Note\nYOLO mode skips all confirmations. Make sure you understand the potential risks.\n:::\n\n### `/web`\n\nSwitch to Web UI. Kimi Code CLI will start a Web UI server and open the current session in your browser, allowing you to continue the conversation in the Web UI. See [Web UI](./kimi-web.md) for details.\n\n## Command completion\n\nAfter typing `/` in the input box, a list of available commands is automatically displayed. Continue typing to filter commands with fuzzy matching support, press Enter to select.\n\nFor example, typing `/ses` will match `/sessions`, and `/clog` will match `/changelog`. Command aliases are also supported, such as typing `/h` to match `/help`.\n"
  },
  {
    "path": "docs/en/release-notes/breaking-changes.md",
    "content": "# Breaking changes and migration\n\nThis page documents breaking changes in Kimi Code CLI releases and provides migration guidance.\n\n## Unreleased\n\n## 0.81 - Prompt Flow replaced by Flow Skills\n\n### `--prompt-flow` option removed\n\nThe `--prompt-flow` CLI option has been removed. Use flow skills instead.\n\n- **Affected**: Scripts and automation using `--prompt-flow` to load Mermaid/D2 flowcharts\n- **Migration**: Create a flow skill with embedded Agent Flow in `SKILL.md` and invoke via `/flow:<skill-name>`\n\n### `/begin` command replaced\n\nThe `/begin` slash command has been replaced with `/flow:<skill-name>` commands.\n\n- **Affected**: Users who used `/begin` to start a loaded Prompt Flow\n- **Migration**: Use `/flow:<skill-name>` to invoke flow skills directly\n\n## 0.77 - Thinking mode and CLI option changes\n\n### Thinking mode setting migration change\n\nAfter upgrading from `0.76`, the thinking mode setting is no longer automatically preserved. The previous `thinking` state stored in `~/.kimi/kimi.json` is no longer used; instead, thinking mode is now managed via the `default_thinking` configuration option in `~/.kimi/config.toml`, but values are not automatically migrated from legacy `metadata`.\n\n- **Affected**: Users who previously had thinking mode enabled\n- **Migration**: Reconfigure thinking mode after upgrading:\n  - Use the `/model` command to select model and set thinking mode (interactive)\n  - Or manually add to `~/.kimi/config.toml`:\n\n    ```toml\n    default_thinking = true  # Set to true if you want thinking mode enabled by default\n    ```\n\n### `--query` option removed\n\nThe `--query` (`-q`) option has been removed. Use `--prompt` as the primary option, with `--command` as an alias.\n\n- **Affected**: Scripts and automation using `--query` or `-q`\n- **Migration**:\n  - `--query` / `-q` → `--prompt` / `-p`\n  - Or continue using `--command` / `-c`\n\n## 0.74 - ACP command change\n\n### `--acp` option deprecated\n\nThe `--acp` option has been deprecated. Use the `kimi acp` subcommand instead.\n\n- **Affected**: Scripts and IDE configurations using `kimi --acp`\n- **Migration**: `kimi --acp` → `kimi acp`\n\n## 0.66 - Config file and provider type\n\n### Config file format migration\n\nThe config file format has been migrated from JSON to TOML.\n\n- **Affected**: Users with `~/.kimi/config.json`\n- **Migration**: Kimi Code CLI will automatically read the old JSON config, but manual migration to TOML is recommended\n- **New location**: `~/.kimi/config.toml`\n\nJSON config example:\n\n```json\n{\n  \"default_model\": \"kimi-k2-0711\",\n  \"providers\": {\n    \"kimi\": {\n      \"type\": \"kimi\",\n      \"base_url\": \"https://api.kimi.com/coding/v1\",\n      \"api_key\": \"your-key\"\n    }\n  }\n}\n```\n\nEquivalent TOML config:\n\n```toml\ndefault_model = \"kimi-k2-0711\"\n\n[providers.kimi]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"your-key\"\n```\n\n### `google_genai` provider type renamed\n\nThe provider type for Gemini Developer API has been renamed from `google_genai` to `gemini`.\n\n- **Affected**: Users with `type = \"google_genai\"` in their config\n- **Migration**: Change the `type` value to `\"gemini\"`\n- **Compatibility**: `google_genai` still works but updating is recommended\n\n## 0.57 - Tool changes\n\n### `Shell` tool\n\nThe `Bash` tool (or `CMD` on Windows) has been unified and renamed to `Shell`.\n\n- **Affected**: Agent files referencing `Bash` or `CMD` tools\n- **Migration**: Change tool references to `Shell`\n\n### `Task` tool moved to `multiagent` module\n\nThe `Task` tool has been moved from `kimi_cli.tools.task` to `kimi_cli.tools.multiagent`.\n\n- **Affected**: Custom tools importing the `Task` tool\n- **Migration**: Change import path to `from kimi_cli.tools.multiagent import Task`\n\n### `PatchFile` tool removed\n\nThe `PatchFile` tool has been removed.\n\n- **Affected**: Agent configs using the `PatchFile` tool\n- **Alternative**: Use `StrReplaceFile` tool for file modifications\n\n## 0.52 - CLI option changes\n\n### `--ui` option removed\n\nThe `--ui` option has been removed in favor of separate flags.\n\n- **Affected**: Scripts using `--ui print`, `--ui acp`, or `--ui wire`\n- **Migration**:\n  - `--ui print` → `--print`\n  - `--ui acp` → `kimi acp`\n  - `--ui wire` → `--wire`\n\n## 0.42 - Keyboard shortcut changes\n\n### Mode switch shortcut\n\nThe agent/shell mode toggle shortcut has changed from `Ctrl-K` to `Ctrl-X`.\n\n- **Affected**: Users accustomed to using `Ctrl-K` for mode switching\n- **Migration**: Use `Ctrl-X` to toggle modes\n\n## 0.27 - CLI option rename\n\n### `--agent` option renamed\n\nThe `--agent` option has been renamed to `--agent-file`.\n\n- **Affected**: Scripts using `--agent` to specify custom agent files\n- **Migration**: Change `--agent` to `--agent-file`\n- **Note**: `--agent` is now used to specify built-in agents (e.g., `default`, `okabe`)\n\n## 0.25 - Package name change\n\n### Package renamed from `ensoul` to `kimi-cli`\n\n- **Affected**: Code or scripts using the `ensoul` package name\n- **Migration**:\n  - Installation: `pip install ensoul` → `pip install kimi-cli` or `uv tool install kimi-cli`\n  - Command: `ensoul` → `kimi`\n\n### `ENSOUL_*` parameter prefix changed\n\nThe system prompt built-in parameter prefix has changed from `ENSOUL_*` to `KIMI_*`.\n\n- **Affected**: Custom agent files using `ENSOUL_*` parameters\n- **Migration**: Change parameter prefix to `KIMI_*` (e.g., `ENSOUL_NOW` → `KIMI_NOW`)\n"
  },
  {
    "path": "docs/en/release-notes/changelog.md",
    "content": "# Changelog\n\nThis page documents the changes in each Kimi Code CLI release.\n\n## Unreleased\n\n- Shell: Show the current working directory, git branch, dirty state, and ahead/behind sync status directly in the prompt toolbar\n- Shell: Surface active background bash task counts in the toolbar, rotate shortcut tips on a timer, and gracefully truncate the toolbar on narrow terminals to avoid overflow\n- Web: Fix tool execution status synchronization on cancel and approval — tools now correctly transition to `output-denied` state when generation is stopped, and show the loading spinner (instead of checkmark) while executing after approval\n- Web: Dismiss stale approval and question dialogs on session replay — when replaying a session or when the backend reports idle/stopped/error status, any pending approval/question dialogs are now properly dismissed to prevent orphaned interactive elements\n- Web: Enable inline math formula rendering — single-dollar inline math (`$...$`) is now supported in addition to block math (`$$...$$`)\n- Web: Improve Switch toggle proportions and alignment — the toggle track is now larger (36×20) with a consistent 16px thumb and smoother 16px travel animation\n\n## 1.24.0 (2026-03-18)\n\n- Shell: Increase pasted text placeholder thresholds to 1000 characters or 15 lines (previously 300 characters or 3 lines), making voice/typeless workflows less disruptive\n- Core: Plan mode now supports multiple selectable approach options — when the agent's plan contains distinct alternative paths, `ExitPlanMode` can present 2–3 labeled choices for the user to pick which approach to execute; the chosen option is returned to the agent as the selected approach\n- Core: Persist plan session ID and file path across process restarts — the plan session identifier and file slug are saved to `SessionState`, so restarting Kimi Code mid-plan resumes the same plan file in `~/.kimi/plans/` instead of creating a new one\n- Core: Plan mode now supports incremental plan edits — the agent can use `StrReplaceFile` to surgically update sections of the plan file instead of rewriting the entire file with `WriteFile`, and non-plan file edits are now hard-blocked rather than requiring approval\n- Core: Defer MCP startup and surface loading progress — MCP servers now initialize asynchronously after the shell UI starts, with live progress indicators showing connection status; Shell displays connecting and ready states in the status area, Web shows server connection status\n- Core: Optimize lightweight startup paths — implement lazy-loading for CLI subcommands and version metadata, significantly reducing startup time for common commands like `--version` and `--help`\n- Build: Fix Nix `FileCollisionError` for `bin/kimi` — remove duplicate entry point from `kimi-code` package so `kimi-cli` owns `bin/kimi` exclusively\n- Shell: Preserve unsubmitted input across agent turns — text typed in the prompt while the agent is running is no longer lost when the turn ends; the user can press Enter to submit the draft as the next message\n- Shell: Fix Ctrl-C and Ctrl-D not working correctly after an agent run completes — keyboard interrupts and EOF were silently swallowed instead of showing the tip or exiting the shell\n\n## 1.23.0 (2026-03-17)\n\n- Shell: Add background bash — the `Shell` tool now accepts `run_in_background=true` to launch long-running commands (builds, tests, servers) as background tasks, freeing the agent to continue working; new `TaskList`, `TaskOutput`, and `TaskStop` tools manage task lifecycle, and the system automatically notifies the agent when tasks reach a terminal state\n- Shell: Add `/task` slash command with interactive task browser — a three-column TUI to view, monitor, and manage background tasks with real-time refresh, output preview, and keyboard-driven stopping\n- Web: Fix global config not refreshing on other tabs when model is changed — when the model is changed in one tab, other tabs now detect the config update and automatically refresh their global config\n\n## 1.22.0 (2026-03-13)\n\n- Shell: Collapse long pasted text into `[Pasted text #n]` placeholders — text pasted via `Ctrl-V` or bracketed paste that exceeds 300 characters or 3 lines is displayed as a compact placeholder token in the prompt buffer while the full content is sent to the model; the external editor (`Ctrl-O`) expands placeholders for editing and re-folds them on save\n- Shell: Cache pasted images as attachment placeholders — images pasted from the clipboard are stored on disk and shown as `[image:…]` tokens in the prompt, keeping the input buffer readable\n- Shell: Fix UTF-16 surrogate characters in pasted text causing serialization errors — lone surrogates from Windows clipboard data are now sanitized before storage, preventing `UnicodeEncodeError` in history writes and JSON serialization\n- Shell: Redesign slash command completion menu — replace the default completion popup with a full-width custom menu that shows command names and multi-line descriptions, with highlight and scroll support\n- Shell: Fix cancelled shell commands not properly terminating child processes — when a running command is cancelled, the subprocess is now explicitly killed to prevent orphaned processes\n\n## 1.21.0 (2026-03-12)\n\n- Shell: Add inline running prompt with steer input — agent output is now rendered inside the prompt area while the model is running, and users can type and send follow-up messages (steers) without waiting for the turn to finish; approval requests and question panels are handled inline with keyboard navigation\n- Core: Change steer injection from synthetic tool calls to regular user messages — steer content is now appended as a standard user message instead of a fake `_steer` tool-call/tool-result pair, improving compatibility with context serialization and visualization\n- Wire: Add `SteerInput` event — a new Wire protocol event emitted when the user sends a follow-up steer message during a running turn\n- Shell: Echo user input after submission in agent mode — the prompt symbol and entered text are printed back to the terminal for a clearer conversation transcript\n- Shell: Improve session replay with steer inputs — replay now correctly reconstructs and displays steer messages alongside regular turns, and filters out internal system-reminder messages\n- Shell: Fix upgrade command in toast notifications — the upgrade command text is now sourced from a single `UPGRADE_COMMAND` constant for consistency\n- Core: Persist system prompt in `context.jsonl` — the system prompt is now written as the first record of the context file and frozen per session, so visualization tools can read the full conversation context and session restores reuse the original prompt instead of regenerating it\n- Vis: Add session directory shortcuts in `kimi vis` — open the current session folder directly from the session page, copy the raw session directory path with `Copy DIR`, and support opening directories on both macOS and Windows\n- Shell: Improve API key login UX — show a spinner during key verification, display a helpful hint when a 401 error suggests the wrong platform was selected, show a setup summary on success, and default thinking mode to \"on\"\n\n## 1.20.0 (2026-03-11)\n\n- Web: Add plan mode toggle in web UI — switch control in the input toolbar with a dashed blue border on the composer when plan mode is active, and support setting plan mode via the `set_plan_mode` Wire protocol method\n- Core: Persist plan mode state across session restarts — `plan_mode` is saved to `SessionState` and restored when a session resumes\n- Core: Fix StatusUpdate not reflecting plan mode changes triggered by tools — send a corrected `StatusUpdate` after `EnterPlanMode`/`ExitPlanMode` tool execution so the client sees the up-to-date state\n- Core: Fix HTTP header values containing trailing whitespace/newlines on certain Linux systems (e.g. kernel 6.8.0-101) causing connection errors — strip whitespace from ASCII header values before sending\n- Core: Fix OpenAI Responses provider sending implicit `reasoning.effort=null` which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set\n- Vis: Add session download, import, export and delete — one-click ZIP download from session explorer and detail page, ZIP import into a dedicated `~/.kimi/imported_sessions/` directory with \"Imported\" filter toggle, `kimi export <session_id>` CLI command, and delete support for imported sessions with AlertDialog confirmation\n- Core: Fix context compaction failing when conversation contains media parts (images, audio, video) — switch from blacklist filtering (exclude `ThinkPart`) to whitelist filtering (only keep `TextPart`) to prevent unsupported content types from being sent to the compaction API\n- Web: Fix `@` file mention index not refreshing after switching sessions or when workspace files change — reset index on session switch, auto-refresh after 30s staleness, and support path-prefix search beyond the 500-file limit\n\n## 1.19.0 (2026-03-10)\n\n- Core: Add plan mode — the agent can enter a planning phase (`EnterPlanMode`) where only read-only tools (Glob, Grep, ReadFile) are available, write a structured plan to a file, and present it for user approval (`ExitPlanMode`) before executing; toggle manually via `/plan` slash command or `Shift-Tab` keyboard shortcut\n- Vis: Add `kimi vis` command for launching an interactive visualization dashboard to inspect session traces — includes wire event timeline, context viewer, session explorer, and usage statistics\n- Web: Fix session stream state management — guard against null reference errors during state resets and preserve slash commands across session switches to avoid a brief empty gap\n\n## 1.18.0 (2026-03-09)\n\n- ACP: Support embedded resource content in ACP mode so that Zed's `@` file references correctly include file contents\n- Core: Use `parameters_json_schema` instead of `parameters` in Google GenAI provider to bypass Pydantic validation that rejects standard JSON Schema metadata fields in MCP tools\n- Shell: Enhance `Ctrl-V` clipboard paste to support video files in addition to images — video file paths are inserted as text, and a crash when clipboard data is `None` is fixed\n- Core: Pass session ID as `user_id` metadata to Anthropic API\n- Web: Preserve slash commands on WebSocket reconnect and add automatic retry logic for session initialization\n\n## 1.17.0 (2026-03-03)\n\n- Core: Add `/export` command to export current session context (messages, metadata) to a Markdown file, and `/import` command to import context from a file or another session ID into the current session\n- Shell: Show token counts (used/total) alongside context usage percentage in the status bar (e.g., `context: 42.0% (4.2k/10.0k)`)\n- Shell: Rotate keyboard shortcut tips in the toolbar — tips cycle through available shortcuts on each prompt submission to save horizontal space\n- MCP: Add loading indicators for MCP server connections — Shell displays a \"Connecting to MCP servers...\" spinner and Web shows a status message while MCP tools are being loaded\n- Web: Fix scrollable file list overflow in the toolbar changes panel\n- Core: Add `compaction_trigger_ratio` config option (default `0.85`) to control when auto-compaction triggers — compaction now fires when context usage reaches the configured ratio or when remaining space falls below `reserved_context_size`, whichever comes first\n- Core: Support custom instructions in `/compact` command (e.g., `/compact keep database discussions`) to guide what the compaction preserves\n- Web: Add URL action parameters (`?action=create` to open create-session dialog, `?action=create-in-dir&workDir=xxx` to create a session directly) for external integrations, and support Cmd/Ctrl+Click on new-session buttons to open session creation in a new browser tab\n- Web: Add todo list display in prompt toolbar — shows task progress with expandable panel when the `SetTodoList` tool is active\n- ACP: Add authentication check for session operations with `AUTH_REQUIRED` error responses for terminal-based login flow\n\n## 1.16.0 (2026-02-27)\n\n- Web: Update ASCII logo banner to a new styled design\n- Core: Add `--add-dir` CLI option and `/add-dir` slash command to expand the workspace scope with additional directories — added directories are accessible to all file tools (read, write, glob, replace), persisted across sessions, and shown in the system prompt\n- Shell: Add `Ctrl-O` keyboard shortcut to open the current input in an external editor (`$VISUAL`/`$EDITOR`), with auto-detection fallback to VS Code, Vim, Vi, or Nano\n- Shell: Add `/editor` slash command to configure and switch the default external editor, with interactive selection and persistent config storage\n- Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI\n- Wire: Auto-hide `AskUserQuestion` tool when the client does not support the `supports_question` capability, preventing the LLM from invoking unsupported interactions\n- Core: Estimate context token count after compaction so context usage percentage is not reported as 0%\n- Web: Show context usage percentage with one decimal place for better precision\n\n## 1.15.0 (2026-02-27)\n\n- Shell: Simplify input prompt by removing username prefix for a cleaner appearance\n- Shell: Add horizontal separator line and expanded keyboard shortcut hints to the toolbar\n- Shell: Add number key shortcuts (1–5) for quick option selection in question and approval panels, with redesigned bordered panel UI and keyboard hints\n- Shell: Add tab-style navigation for multi-question panels — use Left/Right arrows or Tab to switch between questions, with visual indicators for answered, current, and pending states, and automatic state restoration when revisiting a question\n- Shell: Allow Space key to submit single-select questions in the question panel\n- Web: Add tab-style navigation for multi-question dialogs with clickable tab bar, keyboard navigation, and state restoration when revisiting a question\n- Core: Set process title to \"Kimi Code\" (visible in `ps` / Activity Monitor / terminal tab title) and label web worker subprocesses as \"kimi-code-worker\"\n\n## 1.14.0 (2026-02-26)\n\n- Shell: Make FetchURL tool's URL parameter a clickable hyperlink in the terminal\n- Tool: Add `AskUserQuestion` tool for presenting structured questions with predefined options during execution, supporting single-select, multi-select, and custom text input\n- Wire: Add `QuestionRequest` / `QuestionResponse` message types and capability negotiation for structured question interactions\n- Shell: Add interactive question panel for `AskUserQuestion` with keyboard-driven option selection\n- Web: Add `QuestionDialog` component for answering structured questions inline, replacing the prompt composer when a question is pending\n- Core: Persist session state across sessions — approval decisions (YOLO mode, auto-approved actions) and dynamic subagents are now saved and restored when resuming a session\n- Core: Use atomic JSON writes for metadata and session state files to prevent data corruption on crash\n- Wire: Add `steer` request to inject user messages into an active agent turn (protocol version 1.4)\n- Web: Allow Cmd/Ctrl+Click on FetchURL tool's URL parameter to open the link in a new browser tab, with platform-appropriate tooltip hint\n\n## 1.13.0 (2026-02-24)\n\n- Core: Add automatic connection recovery that recreates the HTTP client on connection and timeout errors before retrying, improving resilience against transient network failures\n\n## 1.12.0 (2026-02-11)\n\n- Web: Add subagent activity rendering to display subagent steps (thinking, tool calls, text) inside Task tool messages\n- Web: Add Think tool rendering as a lightweight reasoning-style block\n- Web: Replace emoji status indicators with Lucide icons for tool states and add category-specific icons for tool names\n- Web: Enhance Reasoning component with improved thinking labels and status icons\n- Web: Enhance Todo component with status icons and improved styling\n- Web: Implement WebSocket reconnection with automatic request resending and stale connection watchdog\n- Web: Enhance session creation dialog with command value handling\n- Web: Support tilde (`~`) expansion in session work directory paths\n- Web: Fix assistant message content overflow clipping\n- Wire: Fix deadlock when multiple subagents run concurrently by not blocking the UI loop on approval and tool-call requests\n- Wire: Clean up stale pending requests after agent turn ends\n- Web: Show placeholder text in prompt input with hints for slash commands and file mentions\n- Web: Fix Ctrl+C not working in uvicorn web server by restoring default SIGINT handler and terminal state after shell mode exits\n- Web: Improve session stop handling with proper async cleanup and timeout\n- ACP: Add protocol version negotiation framework for client-server compatibility\n- ACP: Add session resume method to restore session state (experimental)\n\n## 1.11.0 (2026-02-10)\n\n- Web: Move context usage indicator from workspace header to prompt toolbar with a hover card showing detailed token usage breakdown\n- Web: Add folder indicator with work directory path to the bottom of the file changes panel\n- Web: Fix stderr not being restored when switching to web mode, which could suppress web server error output\n- Web: Fix port availability check by setting SO_REUSEADDR on the test socket\n\n## 1.10.0 (2026-02-09)\n\n- Web: Add copy and fork action buttons to assistant messages for quick content copying and session forking\n- Web: Add keyboard shortcuts for approval actions — press `1` to approve, `2` to approve for session, `3` to decline\n- Web: Add message queueing — queue follow-up messages while the AI is processing; queued messages are sent automatically when the response completes\n- Web: Replace Git diff status bar with unified prompt toolbar showing activity status, message queue, and file changes in collapsible tabs\n- Web: Load global MCP configuration in web worker so web sessions can use MCP tools\n- Web: Improve mobile prompt input UX — reduce textarea min-height, add `autoComplete=\"off\"`, and disable focus ring on small screens\n- Web: Handle models that stream text before thinking by ensuring thinking messages always appear before text in the message list\n- Web: Show more specific status messages during session connection (\"Loading history...\", \"Starting environment...\" instead of generic \"Connecting...\")\n- Web: Send error status when session environment initialization fails instead of leaving UI in a waiting state\n- Web: Auto-reconnect when no session status received within 15 seconds after history replay completes\n- Web: Use non-blocking file I/O in session streaming to avoid blocking the event loop during history replay\n\n## 1.9.0 (2026-02-06)\n\n- Config: Add `default_yolo` config option to enable YOLO (auto-approve) mode by default\n- Config: Accept both `max_steps_per_turn` and `max_steps_per_run` as aliases for the loop control setting\n- Wire: Add `replay` request to stream recorded Wire events (protocol version 1.3)\n- Web: Add session fork feature to branch off a new session from any assistant response\n- Web: Add session archive feature with auto-archive for sessions older than 15 days\n- Web: Add multi-select mode for bulk archive, unarchive, and delete operations\n- Web: Add media preview for tool results (images/videos from ReadMediaFile) with clickable thumbnails\n- Web: Add shell command and todo list display components for tool outputs\n- Web: Add activity status indicator showing agent state (processing, waiting for approval, etc.)\n- Web: Add error fallback UI when images fail to load\n- Web: Redesign tool input UI with expandable parameters and syntax highlighting for long values\n- Web: Show compaction indicator when context is being compacted\n- Web: Improve auto-scroll behavior in chat for smoother following of new content\n- Web: Update `last_session_id` for work directory when session stream starts\n- Shell: Remove `Ctrl-/` keyboard shortcut that triggered `/help` command\n- Rust: Move the Rust implementation to `MoonshotAI/kimi-agent-rs` with independent releases; binary renamed to `kimi-agent`\n- Core: Preserve session id when reloading configuration so the session resumes correctly\n- Shell: Fix session replay showing messages that were cleared by `/clear` or `/reset`\n- Web: Fix approval request states not updating when session is interrupted or cancelled\n- Web: Fix IME composition issue when selecting slash commands\n- Web: Fix UI not clearing messages after `/clear`, `/reset`, or `/compact` commands\n\n## 1.8.0 (2026-02-05)\n\n- CLI: Fix startup errors (e.g. invalid config files) being silently swallowed instead of displayed\n\n## 1.7.0 (2026-02-05)\n\n- Rust: Add `kagent`, the Rust implementation of Kimi agent kernel with wire-mode support (experimental)\n- Auth: Fix OAuth token refresh conflicts when running multiple sessions simultaneously\n- Web: Add file mention menu (`@`) to reference uploaded attachments and workspace files with autocomplete\n- Web: Add slash command menu in chat input with autocomplete, keyboard navigation, and alias support\n- Web: Prompt to create directory when specified path doesn't exist during session creation\n- Web: Fix authentication token persistence by switching from sessionStorage to localStorage with 24-hour expiry\n- Web: Add server-side pagination for session list with virtualized scrolling for better performance\n- Web: Improve session and work directories loading with smarter caching and invalidation\n- Web: Fix WebSocket errors during history replay by checking connection state before sending\n- Web: Git diff status bar now shows untracked files (new files not yet added to git)\n- Web: Restrict sensitive APIs only in public mode; update origin enforcement logic\n\n## 1.6 (2026-02-03)\n\n- Web: Add token-based authentication and access control for network mode (`--network`, `--lan-only`, `--public`)\n- Web: Add security options: `--auth-token`, `--allowed-origins`, `--restrict-sensitive-apis`, `--dangerously-omit-auth`\n- Web: Change `--host` option to bind to specific IP address; add automatic network address detection\n- Web: Fix WebSocket disconnect when creating new sessions\n- Web: Increase maximum image dimension from 1024 to 4096 pixels\n- Web: Improve UI responsiveness with enhanced hover effects and better layout handling\n- Wire: Add `TurnEnd` event to signal the completion of an agent turn (protocol version 1.2)\n- Core: Fix custom agent prompt files containing `$` causing silent startup failure\n\n## 1.5 (2026-01-30)\n\n- Web: Add Git diff status bar showing uncommitted changes in session working directory\n- Web: Add \"Open in\" menu for opening files/directories in Terminal, VS Code, Cursor, or other local applications\n- Web: Add search functionality to filter sessions by title or working directory\n- Web: Improve session title display with proper overflow handling\n\n## 1.4 (2026-01-30)\n\n- Shell: Merge `/login` and `/setup` commands; `/setup` is now an alias for `/login`\n- Shell: `/usage` now shows remaining quota percentage; add `/status` alias\n- Config: Add `KIMI_SHARE_DIR` environment variable to customize the share directory path (default: `~/.kimi`)\n- Web: Add new Web UI for browser-based interaction\n- CLI: Add `kimi web` subcommand to launch the Web UI server\n- Auth: Fix encoding error when device name or OS version contains non-ASCII characters\n- Auth: OAuth credentials are now stored in files instead of keyring; existing tokens are automatically migrated on startup\n- Auth: Fix authorization failure after the system sleeps or hibernates\n\n## 1.3 (2026-01-28)\n\n- Auth: Fix authentication issue during agent turns\n- Tool: Wrap media content with descriptive tags in `ReadMediaFile` for better path traceability\n\n## 1.2 (2026-01-27)\n\n- UI: Show description for `kimi-for-coding` model\n\n## 1.1 (2026-01-27)\n\n- LLM: Fix `kimi-for-coding` model's capabilities\n\n## 1.0 (2026-01-27)\n\n- Shell: Add `/login` and `/logout` slash commands for login and logout\n- CLI: Add `kimi login` and `kimi logout` subcommands\n- Core: Fix subagent approval request handling\n\n## 0.88 (2026-01-26)\n\n- MCP: Remove `Mcp-Session-Id` header when connecting to MCP servers to fix compatibility\n\n## 0.87 (2026-01-25)\n\n- Shell: Fix Markdown rendering error when HTML blocks appear outside any element\n- Skills: Add more user-level and project-level skills directory candidates\n- Core: Improve system prompt guidance for media file generation and processing tasks\n- Shell: Fix image pasting from clipboard on macOS\n\n## 0.86 (2026-01-24)\n\n- Build: Fix binary builds\n\n## 0.85 (2026-01-24)\n\n- Shell: Cache pasted images to disk for persistence across sessions\n- Shell: Deduplicate cached attachments based on content hash\n- Shell: Fix display of image/audio/video attachments in message history\n- Tool: Use file path as media identifier in `ReadMediaFile` for better traceability\n- Tool: Fix some MP4 files not being recognized as videos\n- Shell: Handle Ctrl-C during slash command execution\n- Shell: Fix shlex parsing error in shell mode when input contains invalid shell syntax\n- Shell: Fix stderr output from MCP servers and third-party libraries polluting shell UI\n- Wire: Graceful shutdown with proper cleanup of pending requests when connection closes or Ctrl-C is received\n\n## 0.84 (2026-01-22)\n\n- Build: Add cross-platform standalone binary builds for Windows, macOS (with code signing and notarization), and Linux (x86_64 and ARM64)\n- Shell: Fix slash command autocomplete showing suggestions for exact command/alias matches\n- Tool: Treat SVG files as text instead of images\n- Flow: Support D2 markdown block strings (`|md` syntax) for multiline node labels in flow skills\n- Core: Fix possible \"event loop is closed\" error after running `/reload`, `/setup`, or `/clear`\n- Core: Fix panic when `/clear` is used in a continued session\n\n## 0.83 (2026-01-21)\n\n- Tool: Add `ReadMediaFile` tool for reading image/video files; `ReadFile` now focuses on text files only\n- Skills: Flow skills now also register as `/skill:<skill-name>` commands (in addition to `/flow:<skill-name>`)\n\n## 0.82 (2026-01-21)\n\n- Tool: Allow `WriteFile` and `StrReplaceFile` tools to edit/write files outside the working directory when using absolute paths\n- Tool: Upload videos to Kimi files API when using Kimi provider, replacing inline data URLs with `ms://` references\n- Config: Add `reserved_context_size` setting to customize auto-compaction trigger threshold (default: 50000 tokens)\n\n## 0.81 (2026-01-21)\n\n- Skills: Add flow skill type with embedded Agent Flow (Mermaid/D2) in SKILL.md, invoked via `/flow:<skill-name>` commands\n- CLI: Remove `--prompt-flow` option; use flow skills instead\n- Core: Replace `/begin` command with `/flow:<skill-name>` commands for flow skills\n\n## 0.80 (2026-01-20)\n\n- Wire: Add `initialize` method for exchanging client/server info, external tools registration and slash commands advertisement\n- Wire: Support external tool calls via Wire protocol\n- Wire: Rename `ApprovalRequestResolved` to `ApprovalResponse` (backwards-compatible)\n\n## 0.79 (2026-01-19)\n\n- Skills: Add project-level skills support, discovered from `.agents/skills/` (or `.kimi/skills/`, `.claude/skills/`)\n- Skills: Unified skills discovery with layered loading (builtin → user → project); user-level skills now prefer `~/.config/agents/skills/`\n- Shell: Support fuzzy matching for slash command autocomplete\n- Shell: Enhanced approval request preview with shell command and diff content display, use `Ctrl-E` to expand full content\n- Wire: Add `ShellDisplayBlock` type for shell command display in approval requests\n- Shell: Reorder `/help` to show keyboard shortcuts before slash commands\n- Wire: Return proper JSON-RPC 2.0 error responses for invalid requests\n\n## 0.78 (2026-01-16)\n\n- CLI: Add D2 flowchart format support for Prompt Flow (`.d2` extension)\n\n## 0.77 (2026-01-15)\n\n- Shell: Fix line breaking in `/help` and `/changelog` fullscreen pager display\n- Shell: Use `/model` to toggle thinking mode instead of Tab key\n- Config: Add `default_thinking` config option (need to run `/model` to select thinking mode after upgrade)\n- LLM: Add `always_thinking` capability for models that always use thinking mode\n- CLI: Rename `--command`/`-c` to `--prompt`/`-p`, keep `--command`/`-c` as alias, remove `--query`/`-q`\n- Wire: Fix approval requests not responding properly in Wire mode\n- CLI: Add `--prompt-flow` option to load a Mermaid flowchart file as a Prompt Flow\n- Core: Add `/begin` slash command if a Prompt Flow is loaded to start the flow\n- Core: Replace Ralph Loop with Prompt Flow-based implementation\n\n## 0.76 (2026-01-12)\n\n- Tool: Make `ReadFile` tool description reflect model capabilities for image/video support\n- Tool: Fix TypeScript files (`.ts`, `.tsx`, `.mts`, `.cts`) being misidentified as video files\n- Shell: Allow slash commands (`/help`, `/exit`, `/version`, `/changelog`, `/feedback`) in shell mode\n- Shell: Improve `/help` with fullscreen pager, showing slash commands, skills, and keyboard shortcuts\n- Shell: Improve `/changelog` and `/mcp` display with consistent bullet-style formatting\n- Shell: Show current model name in the bottom status bar\n- Shell: Add `Ctrl-/` shortcut to show help\n\n## 0.75 (2026-01-09)\n\n- Tool: Improve `ReadFile` tool description\n- Skills: Add built-in `kimi-cli-help` skill to answer Kimi Code CLI usage and configuration questions\n\n## 0.74 (2026-01-09)\n\n- ACP: Allow ACP clients to select and switch models (with thinking variants)\n- ACP: Add `terminal-auth` authentication method for setup flow\n- CLI: Deprecate `--acp` option in favor of `kimi acp` subcommand\n- Tool: Support reading image and video files in `ReadFile` tool\n\n## 0.73 (2026-01-09)\n\n- Skills: Add built-in skill-creator skill shipped with the package\n- Tool: Expand `~` to the home directory in `ReadFile` paths\n- MCP: Ensure MCP tools finish loading before starting the agent loop\n- Wire: Fix Wire mode failing to accept valid `cancel` requests\n- Setup: Allow `/model` to switch between all available models for the selected provider\n- Lib: Re-export all Wire message types from `kimi_cli.wire.types`, as a replacement of `kimi_cli.wire.message`\n- Loop: Add `max_ralph_iterations` loop control config to limit extra Ralph iterations\n- Config: Rename `max_steps_per_run` to `max_steps_per_turn` in loop control config (backward-compatible)\n- CLI: Add `--max-steps-per-turn`, `--max-retries-per-step` and `--max-ralph-iterations` options to override loop control config\n- SlashCmd: Make `/yolo` toggle auto-approve mode\n- UI: Show a YOLO badge in the shell prompt\n\n## 0.72 (2026-01-04)\n\n- Python: Fix installation on Python 3.14.\n\n## 0.71 (2026-01-04)\n\n- ACP: Route file reads/writes and shell commands through ACP clients for synced edits/output\n- Shell: Add `/model` slash command to switch default models and reload when using the default config\n- Skills: Add `/skill:<name>` slash commands to load `SKILL.md` instructions on demand\n- CLI: Add `kimi info` subcommand for version/protocol details (supports `--json`)\n- CLI: Add `kimi term` to launch the Toad terminal UI\n- Python: Bump the default tooling/CI version to 3.14\n\n## 0.70 (2025-12-31)\n\n- CLI: Add `--final-message-only` (and `--quiet` alias) to only output the final assistant message in print UI\n- LLM: Add `video_in` model capability and support video inputs\n\n## 0.69 (2025-12-29)\n\n- Core: Support discovering skills in `~/.kimi/skills` or `~/.claude/skills`\n- Python: Lower the minimum required Python version to 3.12\n- Nix: Add flake packaging; install with `nix profile install .#kimi-cli` or run `nix run .#kimi-cli`\n- CLI: Add `kimi-cli` script alias for invoking the CLI; can be run via `uvx kimi-cli`\n- Lib: Move LLM config validation into `create_llm` and return `None` when missing config\n\n## 0.68 (2025-12-24)\n\n- CLI: Add `--config` and `--config-file` options to pass in config JSON/TOML\n- Core: Allow `Config` in addition to `Path` for the `config` parameter of `KimiCLI.create`\n- Tool: Include diff display blocks in `WriteFile` and `StrReplaceFile` approvals/results\n- Wire: Add display blocks to approval requests (including diffs) with backward-compatible defaults\n- ACP: Show file diff previews in tool results and approval prompts\n- ACP: Connect to MCP servers managed by ACP clients\n- ACP: Run shell commands in ACP client terminal if supported\n- Lib: Add `KimiToolset.find` method to find tools by class or name\n- Lib: Add `ToolResultBuilder.display` method to append display blocks to tool results\n- MCP: Add `kimi mcp auth` and related subcommands to manage MCP authorization\n\n## 0.67 (2025-12-22)\n\n- ACP: Advertise slash commands in single-session ACP mode (`kimi --acp`)\n- MCP: Add `mcp.client` config section to configure MCP tool call timeout and other future options\n- Core: Improve default system prompt and `ReadFile` tool\n- UI: Fix Ctrl-C not working in some rare cases\n\n## 0.66 (2025-12-19)\n\n- Lib: Provide `token_usage` and `message_id` in `StatusUpdate` Wire message\n- Lib: Add `KimiToolset.load_tools` method to load tools with dependency injection\n- Lib: Add `KimiToolset.load_mcp_tools` method to load MCP tools\n- Lib: Move `MCPTool` from `kimi_cli.tools.mcp` to `kimi_cli.soul.toolset`\n- Lib: Add `InvalidToolError`, `MCPConfigError` and `MCPRuntimeError`\n- Lib: Make the detailed Kimi Code CLI exception classes extend `ValueError` or `RuntimeError`\n- Lib: Allow passing validated `list[fastmcp.mcp_config.MCPConfig]` as `mcp_configs` for `KimiCLI.create` and `load_agent`\n- Lib: Fix exception raising for `KimiCLI.create`, `load_agent`, `KimiToolset.load_tools` and `KimiToolset.load_mcp_tools`\n- LLM: Add provider type `vertexai` to support Vertex AI\n- LLM: Rename Gemini Developer API provider type from `google_genai` to `gemini`\n- Config: Migrate config file from JSON to TOML\n- MCP: Connect to MCP servers in background and parallel to reduce startup time\n- MCP: Add `mcp-session-id` HTTP header when connecting to MCP servers\n- Lib: Split slash commands (prev \"meta commands\") into two groups: Shell-level and KimiSoul-level\n- Lib: Add `available_slash_commands` property to `Soul` protocol\n- ACP: Advertise slash commands `/init`, `/compact` and `/yolo` to ACP clients\n- SlashCmd: Add `/mcp` slash command to display MCP server and tool status\n\n## 0.65 (2025-12-16)\n\n- Lib: Support creating named sessions via `Session.create(work_dir, session_id)`\n- CLI: Automatically create new session when specified session ID is not found\n- CLI: Delete empty sessions on exit and ignore sessions whose context file is empty when listing\n- UI: Improve session replaying\n- Lib: Add `model_config: LLMModel | None` and `provider_config: LLMProvider | None` properties to `LLM` class\n- MetaCmd: Add `/usage` meta command to show API usage for Kimi Code users\n\n## 0.64 (2025-12-15)\n\n- UI: Fix UTF-16 surrogate characters input on Windows\n- Core: Add `/sessions` meta command to list existing sessions and switch to a selected one\n- CLI: Add `--session/-S` option to specify session ID to resume\n- MCP: Add `kimi mcp` subcommand group to manage global MCP config file `~/.kimi/mcp.json`\n\n## 0.63 (2025-12-12)\n\n- Tool: Fix `FetchURL` tool incorrect output when fetching via service fails\n- Tool: Use `bash` instead of `sh` in `Shell` tool for better compatibility\n- Tool: Fix `Grep` tool unicode decoding error on Windows\n- ACP: Support ACP session continuation (list/load sessions) with `kimi acp` subcommand\n- Lib: Add `Session.find` and `Session.list` static methods to find and list sessions\n- ACP: Update agent plans on the client side when `SetTodoList` tool is called\n- UI: Prevent normal messages starting with `/` from being treated as meta commands\n\n## 0.62 (2025-12-08)\n\n- ACP: Fix tool results (including Shell tool output) not being displayed in ACP clients like Zed\n- ACP: Fix compatibility with the latest version of Zed IDE (0.215.3)\n- Tool: Use PowerShell instead of CMD on Windows for better usability\n- Core: Fix startup crash when there is broken symbolic link in the working directory\n- Core: Add builtin `okabe` agent file with `SendDMail` tool enabled\n- CLI: Add `--agent` option to specify builtin agents like `default` and `okabe`\n- Core: Improve compaction logic to better preserve relevant information\n\n## 0.61 (2025-12-04)\n\n- Lib: Fix logging when used as a library\n- Tool: Harden file path check to protect against shared-prefix escape\n- LLM: Improve compatibility with some third-party OpenAI Responses and Anthropic API providers\n\n## 0.60 (2025-12-01)\n\n- LLM: Fix interleaved thinking for Kimi and OpenAI-compatible providers\n\n## 0.59 (2025-11-28)\n\n- Core: Move context file location to `.kimi/sessions/{workdir_md5}/{session_id}/context.jsonl`\n- Lib: Move `WireMessage` type alias to `kimi_cli.wire.message`\n- Lib: Add `kimi_cli.wire.message.Request` type alias request messages (which currently only includes `ApprovalRequest`)\n- Lib: Add `kimi_cli.wire.message.is_event`, `is_request` and `is_wire_message` utility functions to check the type of wire messages\n- Lib: Add `kimi_cli.wire.serde` module for serialization and deserialization of wire messages\n- Lib: Change `StatusUpdate` Wire message to not using `kimi_cli.soul.StatusSnapshot`\n- Core: Record Wire messages to a JSONL file in session directory\n- Core: Introduce `TurnBegin` Wire message to mark the beginning of each agent turn\n- UI: Print user input again with a panel in shell mode\n- Lib: Add `Session.dir` property to get the session directory path\n- UI: Improve \"Approve for session\" experience when there are multiple parallel subagents\n- Wire: Reimplement Wire server mode (which is enabled with `--wire` option)\n- Lib: Rename `ShellApp` to `Shell`, `PrintApp` to `Print`, `ACPServer` to `ACP` and `WireServer` to `WireOverStdio` for better consistency\n- Lib: Rename `KimiCLI.run_shell_mode` to `run_shell`, `run_print_mode` to `run_print`, `run_acp_server` to `run_acp`, and `run_wire_server` to `run_wire_stdio` for better consistency\n- Lib: Add `KimiCLI.run` method to run a turn with given user input and yield Wire messages\n- Print: Fix stream-json print mode not flushing output properly\n- LLM: Improve compatibility with some OpenAI and Anthropic API providers\n- Core: Fix chat provider error after compaction when using Anthropic API\n\n## 0.58 (2025-11-21)\n\n- Core: Fix field inheritance of agent spec files when using `extend`\n- Core: Support using MCP tools in subagents\n- Tool: Add `CreateSubagent` tool to create subagents dynamically (not enabled in default agent)\n- Tool: Use MoonshotFetch service in `FetchURL` tool for Kimi Code plan\n- Tool: Truncate Grep tool output to avoid exceeding token limit\n\n## 0.57 (2025-11-20)\n\n- LLM: Fix Google GenAI provider when thinking toggle is not on\n- UI: Improve approval request wordings\n- Tool: Remove `PatchFile` tool\n- Tool: Rename `Bash`/`CMD` tool to `Shell` tool\n- Tool: Move `Task` tool to `kimi_cli.tools.multiagent` module\n\n## 0.56 (2025-11-19)\n\n- LLM: Add support for Google GenAI provider\n\n## 0.55 (2025-11-18)\n\n- Lib: Add `kimi_cli.app.enable_logging` function to enable logging when directly using `KimiCLI` class\n- Core: Fix relative path resolution in agent spec files\n- Core: Prevent from panic when LLM API connection failed\n- Tool: Optimize `FetchURL` tool for better content extraction\n- Tool: Increase MCP tool call timeout to 60 seconds\n- Tool: Provide better error message in `Glob` tool when pattern is `**`\n- ACP: Fix thinking content not displayed properly\n- UI: Minor UI improvements in shell mode\n\n## 0.54 (2025-11-13)\n\n- Lib: Move `WireMessage` from `kimi_cli.wire.message` to `kimi_cli.wire`\n- Print: Fix `stream-json` output format missing the last assistant message\n- UI: Add warning when API key is overridden by `KIMI_API_KEY` environment variable\n- UI: Make a bell sound when there's an approval request\n- Core: Fix context compaction and clearing on Windows\n\n## 0.53 (2025-11-12)\n\n- UI: Remove unnecessary trailing spaces in console output\n- Core: Throw error when there are unsupported message parts\n- MetaCmd: Add `/yolo` meta command to enable YOLO mode after startup\n- Tool: Add approval request for MCP tools\n- Tool: Disable `Think` tool in default agent\n- CLI: Restore thinking mode from last time when `--thinking` is not specified\n- CLI: Fix `/reload` not working in binary packed by PyInstaller\n\n## 0.52 (2025-11-10)\n\n- CLI: Remove `--ui` option in favor of `--print`, `--acp`, and `--wire` flags (shell is still the default)\n- CLI: More intuitive session continuation behavior\n- Core: Add retry for LLM empty responses\n- Tool: Change `Bash` tool to `CMD` tool on Windows\n- UI: Fix completion after backspacing\n- UI: Fix code block rendering issues on light background colors\n\n## 0.51 (2025-11-08)\n\n- Lib: Rename `Soul.model` to `Soul.model_name`\n- Lib: Rename `LLMModelCapability` to `ModelCapability` and move to `kimi_cli.llm`\n- Lib: Add `\"thinking\"` to `ModelCapability`\n- Lib: Remove `LLM.supports_image_in` property\n- Lib: Add required `Soul.model_capabilities` property\n- Lib: Rename `KimiSoul.set_thinking_mode` to `KimiSoul.set_thinking`\n- Lib: Add `KimiSoul.thinking` property\n- UI: Better checks and notices for LLM model capabilities\n- UI: Clear the screen for `/clear` meta command\n- Tool: Support auto-downloading ripgrep on Windows\n- CLI: Add `--thinking` option to start in thinking mode\n- ACP: Support thinking content in ACP mode\n\n## 0.50 (2025-11-07)\n\n- Improve UI look and feel\n- Improve Task tool observability\n\n## 0.49 (2025-11-06)\n\n- Minor UX improvements\n\n## 0.48 (2025-11-06)\n\n- Support Kimi K2 thinking mode\n\n## 0.47 (2025-11-05)\n\n- Fix Ctrl-W not working in some environments\n- Do not load SearchWeb tool when the search service is not configured\n\n## 0.46 (2025-11-03)\n\n- Introduce Wire over stdio for local IPC (experimental, subject to change)\n- Support Anthropic provider type\n\n- Fix binary packed by PyInstaller not working due to wrong entrypoint\n\n## 0.45 (2025-10-31)\n\n- Allow `KIMI_MODEL_CAPABILITIES` environment variable to override model capabilities\n- Add `--no-markdown` option to disable markdown rendering\n- Support `openai_responses` LLM provider type\n\n- Fix crash when continuing a session\n\n## 0.44 (2025-10-30)\n\n- Improve startup time\n\n- Fix potential invalid bytes in user input\n\n## 0.43 (2025-10-30)\n\n- Basic Windows support (experimental)\n- Display warnings when base URL or API key is overridden in environment variables\n- Support image input if the LLM model supports it\n- Replay recent context history when continuing a session\n\n- Ensure new line after executing shell commands\n\n## 0.42 (2025-10-28)\n\n- Support Ctrl-J or Alt-Enter to insert a new line\n\n- Change mode switch shortcut from Ctrl-K to Ctrl-X\n- Improve overall robustness\n\n- Fix ACP server `no attribute` error\n\n## 0.41 (2025-10-26)\n\n- Fix a bug for Glob tool when no matching files are found\n- Ensure reading files with UTF-8 encoding\n\n- Disable reading command/query from stdin in shell mode\n- Clarify the API platform selection in `/setup` meta command\n\n## 0.40 (2025-10-24)\n\n- Support `ESC` key to interrupt the agent loop\n\n- Fix SSL certificate verification error in some rare cases\n- Fix possible decoding error in Bash tool\n\n## 0.39 (2025-10-24)\n\n- Fix context compaction threshold check\n- Fix panic when SOCKS proxy is set in the shell session\n\n## 0.38 (2025-10-24)\n\n- Minor UX improvements\n\n## 0.37 (2025-10-24)\n\n- Fix update checking\n\n## 0.36 (2025-10-24)\n\n- Add `/debug` meta command to debug the context\n- Add auto context compaction\n- Add approval request mechanism\n- Add `--yolo` option to automatically approve all actions\n- Render markdown content for better readability\n\n- Fix \"unknown error\" message when interrupting a meta command\n\n## 0.35 (2025-10-22)\n\n- Minor UI improvements\n- Auto download ripgrep if not found in the system\n- Always approve tool calls in `--print` mode\n- Add `/feedback` meta command\n\n## 0.34 (2025-10-21)\n\n- Add `/update` meta command to check for updates and auto-update in background\n- Support running interactive shell commands in raw shell mode\n- Add `/setup` meta command to setup LLM provider and model\n- Add `/reload` meta command to reload configuration\n\n## 0.33 (2025-10-18)\n\n- Add `/version` meta command\n- Add raw shell mode, which can be switched to by Ctrl-K\n- Show shortcuts in bottom status line\n\n- Fix logging redirection\n- Merge duplicated input histories\n\n## 0.32 (2025-10-16)\n\n- Add bottom status line\n- Support file path auto-completion (`@filepath`)\n\n- Do not auto-complete meta command in the middle of user input\n\n## 0.31 (2025-10-14)\n\n- Fix step interrupting by Ctrl-C, for real\n\n## 0.30 (2025-10-14)\n\n- Add `/compact` meta command to allow manually compacting context\n\n- Fix `/clear` meta command when context is empty\n\n## 0.29 (2025-10-14)\n\n- Support Enter key to accept completion in shell mode\n- Remember user input history across sessions in shell mode\n- Add `/reset` meta command as an alias for `/clear`\n\n- Fix step interrupting by Ctrl-C\n\n- Disable `SendDMail` tool in Kimi Koder agent\n\n## 0.28 (2025-10-13)\n\n- Add `/init` meta command to analyze the codebase and generate an `AGENTS.md` file\n- Add `/clear` meta command to clear the context\n\n- Fix `ReadFile` output\n\n## 0.27 (2025-10-11)\n\n- Add `--mcp-config-file` and `--mcp-config` options to load MCP configs\n\n- Rename `--agent` option to `--agent-file`\n\n## 0.26 (2025-10-11)\n\n- Fix possible encoding error in `--output-format stream-json` mode\n\n## 0.25 (2025-10-11)\n\n- Rename package name `ensoul` to `kimi-cli`\n- Rename `ENSOUL_*` builtin system prompt arguments to `KIMI_*`\n- Further decouple `App` with `Soul`\n- Split `Soul` protocol and `KimiSoul` implementation for better modularity\n\n## 0.24 (2025-10-10)\n\n- Fix ACP `cancel` method\n\n## 0.23 (2025-10-09)\n\n- Add `extend` field to agent file to support agent file extension\n- Add `exclude_tools` field to agent file to support excluding tools\n- Add `subagents` field to agent file to support defining subagents\n\n## 0.22 (2025-10-09)\n\n- Improve `SearchWeb` and `FetchURL` tool call visualization\n- Improve search result output format\n\n## 0.21 (2025-10-09)\n\n- Add `--print` option as a shortcut for `--ui print`, `--acp` option as a shortcut for `--ui acp`\n- Support `--output-format stream-json` to print output in JSON format\n- Add `SearchWeb` tool with `services.moonshot_search` configuration. You need to configure it with `\"services\": {\"moonshot_search\": {\"api_key\": \"your-search-api-key\"}}` in your config file.\n- Add `FetchURL` tool\n- Add `Think` tool\n- Add `PatchFile` tool, not enabled in Kimi Koder agent\n- Enable `SendDMail` and `Task` tool in Kimi Koder agent with better tool prompts\n- Add `ENSOUL_NOW` builtin system prompt argument\n\n- Better-looking `/release-notes`\n- Improve tool descriptions\n- Improve tool output truncation\n\n## 0.20 (2025-09-30)\n\n- Add `--ui acp` option to start Agent Client Protocol (ACP) server\n\n## 0.19 (2025-09-29)\n\n- Support piped stdin for print UI\n- Support `--input-format=stream-json` for piped JSON input\n\n- Do not include `CHECKPOINT` messages in the context when `SendDMail` is not enabled\n\n## 0.18 (2025-09-29)\n\n- Support `max_context_size` in LLM model configurations to configure the maximum context size (in tokens)\n\n- Improve `ReadFile` tool description\n\n## 0.17 (2025-09-29)\n\n- Fix step count in error message when exceeded max steps\n- Fix history file assertion error in `kimi_run`\n- Fix error handling in print mode and single command shell mode\n- Add retry for LLM API connection errors and timeout errors\n\n- Increase default max-steps-per-run to 100\n\n## 0.16.0 (2025-09-26)\n\n- Add `SendDMail` tool (disabled in Kimi Koder, can be enabled in custom agent)\n\n- Session history file can be specified via `_history_file` parameter when creating a new session\n\n## 0.15.0 (2025-09-26)\n\n- Improve tool robustness\n\n## 0.14.0 (2025-09-25)\n\n- Add `StrReplaceFile` tool\n\n- Emphasize the use of the same language as the user\n\n## 0.13.0 (2025-09-25)\n\n- Add `SetTodoList` tool\n- Add `User-Agent` in LLM API calls\n\n- Better system prompt and tool description\n- Better error messages for LLM\n\n## 0.12.0 (2025-09-24)\n\n- Add `print` UI mode, which can be used via `--ui print` option\n- Add logging and `--debug` option\n\n- Catch EOF error for better experience\n\n## 0.11.1 (2025-09-22)\n\n- Rename `max_retry_per_step` to `max_retries_per_step`\n\n## 0.11.0 (2025-09-22)\n\n- Add `/release-notes` command\n- Add retry for LLM API errors\n- Add loop control configuration, e.g. `{\"loop_control\": {\"max_steps_per_run\": 50, \"max_retry_per_step\": 3}}`\n\n- Better extreme cases handling in `read_file` tool\n- Prevent Ctrl-C from exiting the CLI, force the use of Ctrl-D or `exit` instead\n\n## 0.10.1 (2025-09-18)\n\n- Make slash commands look slightly better\n- Improve `glob` tool\n\n## 0.10.0 (2025-09-17)\n\n- Add `read_file` tool\n- Add `write_file` tool\n- Add `glob` tool\n- Add `task` tool\n\n- Improve tool call visualization\n- Improve session management\n- Restore context usage when `--continue` a session\n\n## 0.9.0 (2025-09-15)\n\n- Remove `--session` and `--continue` options\n\n## 0.8.1 (2025-09-14)\n\n- Fix config model dumping\n\n## 0.8.0 (2025-09-14)\n\n- Add `shell` tool and basic system prompt\n- Add tool call visualization\n- Add context usage count\n- Support interrupting the agent loop\n- Support project-level `AGENTS.md`\n- Support custom agent defined with YAML\n- Support oneshot task via `kimi -c`\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: home\nhero:\n  name: Kimi Code CLI\n  text: ' '\n  actions:\n    - theme: brand\n      text: 简体中文\n      link: /zh/\n    - theme: alt\n      text: English\n      link: /en/\n---\n\n<script setup>\nimport { onMounted } from 'vue'\nimport { useRouter } from 'vitepress'\n\nonMounted(() => {\n  const router = useRouter()\n  const lang = navigator.language || navigator.userLanguage\n  if (lang.startsWith('zh')) {\n    router.go('/zh/')\n  } else {\n    router.go('/en/')\n  }\n})\n</script>\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"kimi-cli-docs\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"sync\": \"node scripts/sync-changelog.mjs\",\n    \"dev\": \"npm run sync && vitepress dev\",\n    \"build\": \"npm run sync && vitepress build\",\n    \"preview\": \"vitepress preview\"\n  },\n  \"devDependencies\": {\n    \"vitepress\": \"^1.5.0\"\n  },\n  \"dependencies\": {\n    \"mermaid\": \"^11.12.2\",\n    \"vitepress-plugin-llms\": \"^1.10.0\",\n    \"vitepress-plugin-mermaid\": \"^2.0.17\"\n  }\n}"
  },
  {
    "path": "docs/scripts/sync-changelog.mjs",
    "content": "#!/usr/bin/env node\n/**\n * Sync CHANGELOG.md to docs/en/release-notes/changelog.md\n *\n * This script copies the content from the root CHANGELOG.md to the docs site,\n * with only formatting changes (title format).\n *\n * Run from the docs directory: node scripts/sync-changelog.mjs\n */\n\nimport { readFileSync, writeFileSync } from \"fs\";\nimport { dirname, join } from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst docsDir = join(__dirname, \"..\");\nconst rootDir = join(docsDir, \"..\");\n\nconst sourcePath = join(rootDir, \"CHANGELOG.md\");\nconst targetPath = join(docsDir, \"en/release-notes/changelog.md\");\n\nconst HEADER = `# Changelog\n\nThis page documents the changes in each Kimi Code CLI release.\n\n`;\n\n// Read the source file\nlet content = readFileSync(sourcePath, \"utf-8\");\n\n// Remove the HTML comment block at the top\ncontent = content.replace(/<!--[\\s\\S]*?-->\\n*/g, \"\");\n\n// Remove the \"# Changelog\" title (we'll add our own header)\ncontent = content.replace(/^# Changelog\\n+/, \"\");\n\n// Convert title format: ## [0.69] - 2025-12-29 -> ## 0.69 (2025-12-29)\ncontent = content.replace(\n  /^## \\[([^\\]]+)\\] - (\\d{4}-\\d{1,2}-\\d{1,2})/gm,\n  \"## $1 ($2)\"\n);\n\n// Remove subsection headers like ### Added, ### Changed, ### Fixed\ncontent = content.replace(/^### (Added|Changed|Fixed|Improved|Tools|SDK)\\n+/gm, \"\");\n\n// Write the target file\nwriteFileSync(targetPath, HEADER + content.trim() + \"\\n\");\n\nconsole.log(`Synced changelog to ${targetPath}`);\n"
  },
  {
    "path": "docs/zh/configuration/config-files.md",
    "content": "# 配置文件\n\nKimi Code CLI 使用配置文件管理 API 供应商、模型、服务和运行参数，支持 TOML 和 JSON 两种格式。\n\n## 配置文件位置\n\n默认配置文件位于 `~/.kimi/config.toml`。首次运行时，如果配置文件不存在，Kimi Code CLI 会自动创建一个默认的配置文件。\n\n你可以通过 `--config-file` 参数指定其他配置文件（TOML 或 JSON 格式均可）：\n\n```sh\nkimi --config-file /path/to/config.toml\n```\n\n在程序化调用 Kimi Code CLI 时，也可以通过 `--config` 参数直接传入完整的配置内容：\n\n```sh\nkimi --config '{\"default_model\": \"kimi-for-coding\", \"providers\": {...}, \"models\": {...}}'\n```\n\n## 配置项\n\n配置文件包含以下顶层配置项：\n\n| 配置项 | 类型 | 说明 |\n| --- | --- | --- |\n| `default_model` | `string` | 默认使用的模型名称，必须是 `models` 中定义的模型 |\n| `default_thinking` | `boolean` | 默认是否开启 Thinking 模式（默认为 `false`） |\n| `default_yolo` | `boolean` | 默认是否开启 YOLO（自动审批）模式（默认为 `false`） |\n| `default_editor` | `string` | 默认外部编辑器命令（如 `\"vim\"`、`\"code --wait\"`），为空时自动检测 |\n| `providers` | `table` | API 供应商配置 |\n| `models` | `table` | 模型配置 |\n| `loop_control` | `table` | Agent 循环控制参数 |\n| `background` | `table` | 后台任务运行参数 |\n| `services` | `table` | 外部服务配置（搜索、抓取） |\n| `mcp` | `table` | MCP 客户端配置 |\n\n### 完整配置示例\n\n```toml\ndefault_model = \"kimi-for-coding\"\ndefault_thinking = false\ndefault_yolo = false\ndefault_editor = \"\"\n\n[providers.kimi-for-coding]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"sk-xxx\"\n\n[models.kimi-for-coding]\nprovider = \"kimi-for-coding\"\nmodel = \"kimi-for-coding\"\nmax_context_size = 262144\n\n[loop_control]\nmax_steps_per_turn = 100\nmax_retries_per_step = 3\nmax_ralph_iterations = 0\nreserved_context_size = 50000\ncompaction_trigger_ratio = 0.85\n\n[background]\nmax_running_tasks = 4\nkeep_alive_on_exit = false\n\n[services.moonshot_search]\nbase_url = \"https://api.kimi.com/coding/v1/search\"\napi_key = \"sk-xxx\"\n\n[services.moonshot_fetch]\nbase_url = \"https://api.kimi.com/coding/v1/fetch\"\napi_key = \"sk-xxx\"\n\n[mcp.client]\ntool_call_timeout_ms = 60000\n```\n\n### `providers`\n\n`providers` 定义 API 供应商连接信息。每个供应商使用一个唯一的名称作为 key。\n\n| 字段 | 类型 | 必填 | 说明 |\n| --- | --- | --- | --- |\n| `type` | `string` | 是 | 供应商类型，详见 [平台与模型](./providers.md) |\n| `base_url` | `string` | 是 | API 基础 URL |\n| `api_key` | `string` | 是 | API 密钥 |\n| `env` | `table` | 否 | 创建供应商实例前设置的环境变量 |\n| `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 |\n\n示例：\n\n```toml\n[providers.moonshot-cn]\ntype = \"kimi\"\nbase_url = \"https://api.moonshot.cn/v1\"\napi_key = \"sk-xxx\"\ncustom_headers = { \"X-Custom-Header\" = \"value\" }\n```\n\n### `models`\n\n`models` 定义可用的模型。每个模型使用一个唯一的名称作为 key。\n\n| 字段 | 类型 | 必填 | 说明 |\n| --- | --- | --- | --- |\n| `provider` | `string` | 是 | 使用的供应商名称，必须在 `providers` 中定义 |\n| `model` | `string` | 是 | 模型标识符（API 中使用的模型名称） |\n| `max_context_size` | `integer` | 是 | 最大上下文长度（token 数） |\n| `capabilities` | `array` | 否 | 模型能力列表，详见 [平台与模型](./providers.md#模型能力) |\n\n示例：\n\n```toml\n[models.kimi-k2-thinking-turbo]\nprovider = \"moonshot-cn\"\nmodel = \"kimi-k2-thinking-turbo\"\nmax_context_size = 262144\ncapabilities = [\"thinking\", \"image_in\"]\n```\n\n### `loop_control`\n\n`loop_control` 控制 Agent 执行循环的行为。\n\n| 字段 | 类型 | 默认值 | 说明 |\n| --- | --- | --- | --- |\n| `max_steps_per_turn` | `integer` | `100` | 单轮最大步数（别名：`max_steps_per_run`） |\n| `max_retries_per_step` | `integer` | `3` | 单步最大重试次数 |\n| `max_ralph_iterations` | `integer` | `0` | 每个 User 消息后额外自动迭代次数；`0` 表示关闭；`-1` 表示无限 |\n| `reserved_context_size` | `integer` | `50000` | 预留给 LLM 响应生成的 token 数量；当 `context_tokens + reserved_context_size >= max_context_size` 时自动触发压缩 |\n| `compaction_trigger_ratio` | `float` | `0.85` | 触发自动压缩的上下文使用率阈值（0.5–0.99）；当 `context_tokens >= max_context_size * compaction_trigger_ratio` 时自动触发压缩，与 `reserved_context_size` 条件取先触发者 |\n\n### `background`\n\n`background` 控制后台任务的运行行为。后台任务通过 `Shell` 工具的 `run_in_background=true` 参数启动。\n\n| 字段 | 类型 | 默认值 | 说明 |\n| --- | --- | --- | --- |\n| `max_running_tasks` | `integer` | `4` | 同时运行的最大后台任务数 |\n| `keep_alive_on_exit` | `boolean` | `false` | CLI 退出时是否保留后台任务运行；默认退出时终止所有后台任务 |\n\n### `services`\n\n`services` 配置 Kimi Code CLI 使用的外部服务。\n\n#### `moonshot_search`\n\n配置网页搜索服务，启用后 `SearchWeb` 工具可用。\n\n| 字段 | 类型 | 必填 | 说明 |\n| --- | --- | --- | --- |\n| `base_url` | `string` | 是 | 搜索服务 API URL |\n| `api_key` | `string` | 是 | API 密钥 |\n| `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 |\n\n#### `moonshot_fetch`\n\n配置网页抓取服务，启用后 `FetchURL` 工具优先使用此服务抓取网页内容。\n\n| 字段 | 类型 | 必填 | 说明 |\n| --- | --- | --- | --- |\n| `base_url` | `string` | 是 | 抓取服务 API URL |\n| `api_key` | `string` | 是 | API 密钥 |\n| `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 |\n\n::: tip 提示\n使用 `/login` 命令配置 Kimi Code 平台时，搜索和抓取服务会自动配置。\n:::\n\n### `mcp`\n\n`mcp` 配置 MCP 客户端行为。\n\n| 字段 | 类型 | 默认值 | 说明 |\n| --- | --- | --- | --- |\n| `client.tool_call_timeout_ms` | `integer` | `60000` | MCP 工具调用超时时间（毫秒） |\n\n## JSON 配置迁移\n\n如果 `~/.kimi/config.toml` 不存在但 `~/.kimi/config.json` 存在，Kimi Code CLI 会自动将 JSON 配置迁移到 TOML 格式，并将原文件备份为 `config.json.bak`。\n\n`--config-file` 指定的配置文件根据扩展名自动选择解析方式。`--config` 传入的配置内容会先尝试按 JSON 解析，失败后再尝试 TOML。\n"
  },
  {
    "path": "docs/zh/configuration/data-locations.md",
    "content": "# 数据路径\n\nKimi Code CLI 将所有数据存储在用户主目录下的 `~/.kimi/` 目录中。本页介绍各类数据文件的位置和用途。\n\n::: tip 提示\n可以通过设置 `KIMI_SHARE_DIR` 环境变量来自定义共享目录路径。详见 [环境变量](./env-vars.md#kimi-share-dir)。\n\n注意：`KIMI_SHARE_DIR` 仅影响上述运行时数据的存储位置，不影响 [Agent Skills](../customization/skills.md) 的搜索路径。Skills 作为跨工具共享的能力扩展，与运行时数据是不同类型的数据。\n:::\n\n## 目录结构\n\n```\n~/.kimi/\n├── config.toml           # 主配置文件\n├── kimi.json             # 元数据\n├── mcp.json              # MCP 服务器配置\n├── credentials/          # OAuth 凭据\n│   └── <provider>.json\n├── sessions/             # 会话数据\n│   └── <work-dir-hash>/\n│       └── <session-id>/\n│           ├── context.jsonl\n│           ├── wire.jsonl\n│           └── state.json\n├── imported_sessions/    # 导入的会话数据（通过 kimi vis 导入）\n│   └── <session-id>/\n│       ├── context.jsonl\n│       ├── wire.jsonl\n│       └── state.json\n├── plans/                # Plan 模式方案文件\n│   └── <slug>.md\n├── user-history/         # 输入历史\n│   └── <work-dir-hash>.jsonl\n└── logs/                 # 日志\n    └── kimi.log\n```\n\n## 配置与元数据\n\n### `config.toml`\n\n主配置文件，存储供应商、模型、服务和运行参数。详见 [配置文件](./config-files.md)。\n\n可以通过 `--config-file` 参数指定其他位置的配置文件。\n\n### `kimi.json`\n\n元数据文件，存储 Kimi Code CLI 的运行状态，包括：\n\n- `work_dirs`: 工作目录列表及其最后使用的会话 ID\n- `thinking`: 上次会话是否启用 thinking 模式\n\n此文件由 Kimi Code CLI 自动管理，通常不需要手动编辑。\n\n### `mcp.json`\n\nMCP 服务器配置文件，存储通过 `kimi mcp add` 命令添加的 MCP 服务器。详见 [MCP](../customization/mcp.md)。\n\n示例结构：\n\n```json\n{\n  \"mcpServers\": {\n    \"context7\": {\n      \"url\": \"https://mcp.context7.com/mcp\",\n      \"transport\": \"http\",\n      \"headers\": {\n        \"CONTEXT7_API_KEY\": \"ctx7sk-xxx\"\n      }\n    }\n  }\n}\n```\n\n## 凭据\n\nOAuth 凭据存储在 `~/.kimi/credentials/` 目录下。通过 `/login` 登录 Kimi 账号后，OAuth token 会保存在此目录中。\n\n此目录中的文件权限设置为仅当前用户可读写（600），以保护敏感信息。\n\n## 会话数据\n\n会话数据按工作目录分组存储在 `~/.kimi/sessions/` 下。每个工作目录对应一个以路径 MD5 哈希命名的子目录，每个会话对应一个以会话 ID 命名的子目录。\n\n### `context.jsonl`\n\n上下文历史文件，以 JSONL 格式存储会话的完整上下文。文件第一行是系统提示词记录（`_system_prompt`），后续每行是一条消息（用户输入、模型回复、工具调用等）或内部记录（检查点、token 用量等）。\n\n系统提示词在会话创建时生成并冻结，会话恢复时直接复用而不重新生成。\n\nKimi Code CLI 使用此文件在 `--continue` 或 `--session` 时恢复会话上下文。\n\n### `wire.jsonl`\n\nWire 消息记录文件，以 JSONL 格式存储会话中的 Wire 事件。用于会话回放和提取会话标题。\n\n### `state.json`\n\n会话状态文件，存储会话的运行状态，包括：\n\n- `approval`：审批决策状态（YOLO 模式开关、已自动批准的操作类型）\n- `plan_mode`：Plan 模式的开关状态\n- `plan_session_id`：当前 Plan 会话的唯一标识符，用于关联 plan 文件\n- `plan_slug`：Plan 文件的路径标识（即 `~/.kimi/plans/<slug>.md` 中的 slug），会话重启后可恢复到同一文件\n- `dynamic_subagents`：动态创建的子 Agent 定义\n- `additional_dirs`：通过 `--add-dir` 或 `/add-dir` 添加的额外工作区目录\n\n恢复会话时，Kimi Code CLI 会读取此文件还原会话状态。此文件使用原子写入，防止崩溃时数据损坏。\n\n## Plan 方案文件\n\nPlan 模式的方案文件存储在 `~/.kimi/plans/` 目录下。每个 Plan 会话对应一个随机命名的 Markdown 文件（即 `<slug>.md`）。\n\n`plan_slug` 保存在 `state.json` 中，会话重启后仍能恢复到同一方案文件。使用 `/plan clear` 命令可以清除当前 Plan 会话的方案文件。\n\n## 输入历史\n\n用户输入历史存储在 `~/.kimi/user-history/` 目录下。每个工作目录对应一个以路径 MD5 哈希命名的 `.jsonl` 文件。\n\n输入历史用于 Shell 模式下的历史浏览（上下方向键）和搜索（Ctrl-R）。\n\n## 日志\n\n运行日志存储在 `~/.kimi/logs/kimi.log`。默认日志级别为 INFO，使用 `--debug` 参数可启用 TRACE 级别。\n\n日志文件用于排查问题。如需报告 bug，请附上相关日志内容。\n\n## 清理数据\n\n删除共享目录（默认 `~/.kimi/`，或 `KIMI_SHARE_DIR` 指定的路径）可以完全清理 Kimi Code CLI 的所有数据，包括配置、会话和历史。\n\n如只需清理部分数据：\n\n| 需求 | 操作 |\n| --- | --- |\n| 重置配置 | 删除 `~/.kimi/config.toml` |\n| 清理所有会话 | 删除 `~/.kimi/sessions/` 目录 |\n| 清理特定工作目录的会话 | 在 Shell 模式下使用 `/sessions` 查看并删除 |\n| 清理 Plan 方案文件 | 删除 `~/.kimi/plans/` 目录，或在 Plan 模式下使用 `/plan clear` |\n| 清理输入历史 | 删除 `~/.kimi/user-history/` 目录 |\n| 清理日志 | 删除 `~/.kimi/logs/` 目录 |\n| 清理 MCP 配置 | 删除 `~/.kimi/mcp.json` 或使用 `kimi mcp remove` |\n| 清理登录凭据 | 删除 `~/.kimi/credentials/` 目录或使用 `/logout` |\n\n"
  },
  {
    "path": "docs/zh/configuration/env-vars.md",
    "content": "# 环境变量\n\nKimi Code CLI 支持通过环境变量覆盖配置或控制运行行为。本页列出所有支持的环境变量。\n\n关于环境变量如何覆盖配置文件的详细说明，请参阅 [配置覆盖](./overrides.md)。\n\n## Kimi 环境变量\n\n以下环境变量在使用 `kimi` 类型的供应商时生效，用于覆盖供应商和模型配置。\n\n| 环境变量 | 说明 |\n| --- | --- |\n| `KIMI_BASE_URL` | API 基础 URL |\n| `KIMI_API_KEY` | API 密钥 |\n| `KIMI_MODEL_NAME` | 模型标识符 |\n| `KIMI_MODEL_MAX_CONTEXT_SIZE` | 最大上下文长度（token 数） |\n| `KIMI_MODEL_CAPABILITIES` | 模型能力，逗号分隔（如 `thinking,image_in`） |\n| `KIMI_MODEL_TEMPERATURE` | 生成参数 `temperature` |\n| `KIMI_MODEL_TOP_P` | 生成参数 `top_p` |\n| `KIMI_MODEL_MAX_TOKENS` | 生成参数 `max_tokens` |\n\n### `KIMI_BASE_URL`\n\n覆盖配置文件中供应商的 `base_url` 字段。\n\n```sh\nexport KIMI_BASE_URL=\"https://api.moonshot.cn/v1\"\n```\n\n### `KIMI_API_KEY`\n\n覆盖配置文件中供应商的 `api_key` 字段。用于在不修改配置文件的情况下注入 API 密钥，适合 CI/CD 环境。\n\n```sh\nexport KIMI_API_KEY=\"sk-xxx\"\n```\n\n### `KIMI_MODEL_NAME`\n\n覆盖配置文件中模型的 `model` 字段（API 调用时使用的模型标识符）。\n\n```sh\nexport KIMI_MODEL_NAME=\"kimi-k2-thinking-turbo\"\n```\n\n### `KIMI_MODEL_MAX_CONTEXT_SIZE`\n\n覆盖配置文件中模型的 `max_context_size` 字段。必须是正整数。\n\n```sh\nexport KIMI_MODEL_MAX_CONTEXT_SIZE=\"262144\"\n```\n\n### `KIMI_MODEL_CAPABILITIES`\n\n覆盖配置文件中模型的 `capabilities` 字段。多个能力用逗号分隔，支持的值为 `thinking`、`always_thinking`、`image_in` 和 `video_in`。\n\n```sh\nexport KIMI_MODEL_CAPABILITIES=\"thinking,image_in\"\n```\n\n### `KIMI_MODEL_TEMPERATURE`\n\n设置生成参数 `temperature`，控制输出的随机性。值越高输出越随机，值越低输出越确定。\n\n```sh\nexport KIMI_MODEL_TEMPERATURE=\"0.7\"\n```\n\n### `KIMI_MODEL_TOP_P`\n\n设置生成参数 `top_p`（nucleus sampling），控制输出的多样性。\n\n```sh\nexport KIMI_MODEL_TOP_P=\"0.9\"\n```\n\n### `KIMI_MODEL_MAX_TOKENS`\n\n设置生成参数 `max_tokens`，限制单次回复的最大 token 数。\n\n```sh\nexport KIMI_MODEL_MAX_TOKENS=\"4096\"\n```\n\n## OpenAI 兼容环境变量\n\n以下环境变量在使用 `openai_legacy` 或 `openai_responses` 类型的供应商时生效。\n\n| 环境变量 | 说明 |\n| --- | --- |\n| `OPENAI_BASE_URL` | API 基础 URL |\n| `OPENAI_API_KEY` | API 密钥 |\n\n### `OPENAI_BASE_URL`\n\n覆盖配置文件中供应商的 `base_url` 字段。\n\n```sh\nexport OPENAI_BASE_URL=\"https://api.openai.com/v1\"\n```\n\n### `OPENAI_API_KEY`\n\n覆盖配置文件中供应商的 `api_key` 字段。\n\n```sh\nexport OPENAI_API_KEY=\"sk-xxx\"\n```\n\n## 其他环境变量\n\n| 环境变量 | 说明 |\n| --- | --- |\n| `KIMI_SHARE_DIR` | 自定义共享目录路径（默认 `~/.kimi`） |\n| `KIMI_CLI_NO_AUTO_UPDATE` | 禁用自动更新检查 |\n\n### `KIMI_SHARE_DIR`\n\n自定义 Kimi Code CLI 的共享目录路径。默认路径为 `~/.kimi`，配置、会话、日志等运行时数据存储在此目录下。\n\n```sh\nexport KIMI_SHARE_DIR=\"/path/to/custom/kimi\"\n```\n\n详见 [数据路径](./data-locations.md)。\n\n::: warning 注意\n`KIMI_SHARE_DIR` 不影响 [Agent Skills](../customization/skills.md) 的搜索路径。Skills 是跨工具共享的能力扩展（与 Claude、Codex 等兼容），与应用运行时数据是不同类型的数据。如需覆盖 Skills 路径，请使用 `--skills-dir` 参数。\n:::\n\n### `KIMI_CLI_NO_AUTO_UPDATE`\n\n设置为 `1`、`true`、`t`、`yes` 或 `y`（不区分大小写）时，禁用 Shell 模式下的后台自动更新检查。\n\n```sh\nexport KIMI_CLI_NO_AUTO_UPDATE=\"1\"\n```\n\n::: tip 提示\n如果你通过 Nix 或其他包管理器安装 Kimi Code CLI，通常会自动设置此环境变量，因为更新由包管理器处理。\n:::\n\n"
  },
  {
    "path": "docs/zh/configuration/overrides.md",
    "content": "# 配置覆盖\n\nKimi Code CLI 的配置可以通过多种方式设置，不同来源的配置按优先级覆盖。\n\n## 优先级\n\n配置的优先级从高到低为：\n\n1. **环境变量** - 最高优先级，用于临时覆盖或 CI/CD 环境\n2. **CLI 参数** - 启动时指定的参数\n3. **配置文件** - `~/.kimi/config.toml` 或通过 `--config-file` 指定的文件\n\n## CLI 参数\n\n### 配置文件相关\n\n| 参数 | 说明 |\n| --- | --- |\n| `--config <TOML/JSON>` | 直接传入配置内容，覆盖默认配置文件 |\n| `--config-file <PATH>` | 指定配置文件路径，替代默认的 `~/.kimi/config.toml` |\n\n`--config` 和 `--config-file` 不能同时使用。\n\n### 模型相关\n\n| 参数 | 说明 |\n| --- | --- |\n| `--model, -m <NAME>` | 指定使用的模型名称 |\n\n`--model` 指定的模型必须在配置文件的 `models` 中定义。如果未指定，使用配置文件中的 `default_model`。\n\n### 行为相关\n\n| 参数 | 说明 |\n| --- | --- |\n| `--thinking` | 启用 thinking 模式 |\n| `--no-thinking` | 禁用 thinking 模式 |\n| `--yolo, --yes, -y` | 自动批准所有操作 |\n\n`--thinking` / `--no-thinking` 会覆盖上次会话保存的 thinking 状态。如果不指定，使用上次会话的状态。\n\n## 环境变量覆盖\n\n环境变量可以在不修改配置文件的情况下覆盖供应商和模型设置。这在以下场景特别有用：\n\n- CI/CD 环境中注入密钥\n- 临时测试不同的 API 端点\n- 在多个环境间切换\n\n环境变量根据当前使用的供应商类型来决定是否生效：\n\n- `kimi` 类型的供应商：使用 `KIMI_*` 环境变量\n- `openai_legacy` 或 `openai_responses` 类型的供应商：使用 `OPENAI_*` 环境变量\n- 其他类型的供应商：不支持环境变量覆盖\n\n完整的环境变量列表请参阅 [环境变量](./env-vars.md)。\n\n示例：\n\n```sh\nKIMI_API_KEY=\"sk-xxx\" KIMI_MODEL_NAME=\"kimi-k2-thinking-turbo\" kimi\n```\n\n## 配置优先级示例\n\n假设配置文件 `~/.kimi/config.toml` 内容如下：\n\n```toml\ndefault_model = \"kimi-for-coding\"\n\n[providers.kimi-for-coding]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"sk-config\"\n\n[models.kimi-for-coding]\nprovider = \"kimi-for-coding\"\nmodel = \"kimi-for-coding\"\nmax_context_size = 262144\n```\n\n以下是不同场景的配置来源：\n\n| 场景 | `base_url` | `api_key` | `model` |\n| --- | --- | --- | --- |\n| `kimi` | 配置文件 | 配置文件 | 配置文件 |\n| `KIMI_API_KEY=sk-env kimi` | 配置文件 | 环境变量 | 配置文件 |\n| `kimi --model other` | 配置文件 | 配置文件 | CLI 参数 |\n| `KIMI_MODEL_NAME=k2 kimi` | 配置文件 | 配置文件 | 环境变量 |\n\n"
  },
  {
    "path": "docs/zh/configuration/providers.md",
    "content": "# 平台与模型\n\nKimi Code CLI 支持多种 LLM 平台，可以通过配置文件或 `/login` 命令进行配置。\n\n## 平台选择\n\n最简单的配置方式是在 Shell 模式下运行 `/login` 命令（别名 `/setup`），按照向导完成平台和模型的选择：\n\n1. 选择 API 平台\n2. 输入 API 密钥\n3. 从可用模型列表中选择模型\n\n配置完成后，Kimi Code CLI 会自动保存设置到 `~/.kimi/config.toml` 并重新加载。\n\n`/login` 目前支持以下平台：\n\n| 平台 | 说明 |\n| --- | --- |\n| Kimi Code | Kimi Code 平台，支持搜索和抓取服务 |\n| Moonshot AI 开放平台 (moonshot.cn) | 中国区 API 端点 |\n| Moonshot AI Open Platform (moonshot.ai) | 全球区 API 端点 |\n\n如需使用其他平台，请手动编辑配置文件。\n\n## 供应商类型\n\n`providers` 配置中的 `type` 字段指定 API 供应商类型。不同类型使用不同的 API 协议和客户端实现。\n\n| 类型 | 说明 |\n| --- | --- |\n| `kimi` | Kimi API |\n| `openai_legacy` | OpenAI Chat Completions API |\n| `openai_responses` | OpenAI Responses API |\n| `anthropic` | Anthropic Claude API |\n| `gemini` | Google Gemini API |\n| `vertexai` | Google Vertex AI |\n\n### `kimi`\n\n用于连接 Kimi API，包括 Kimi Code 和 Moonshot AI 开放平台。\n\n```toml\n[providers.kimi-for-coding]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"sk-xxx\"\n```\n\n### `openai_legacy`\n\n兼容 OpenAI Chat Completions API 的平台，包括 OpenAI 官方 API 和各种兼容服务。\n\n```toml\n[providers.openai]\ntype = \"openai_legacy\"\nbase_url = \"https://api.openai.com/v1\"\napi_key = \"sk-xxx\"\n```\n\n### `openai_responses`\n\n用于 OpenAI Responses API（较新的 API 格式）。\n\n```toml\n[providers.openai-responses]\ntype = \"openai_responses\"\nbase_url = \"https://api.openai.com/v1\"\napi_key = \"sk-xxx\"\n```\n\n### `anthropic`\n\n用于连接 Anthropic Claude API。\n\n```toml\n[providers.anthropic]\ntype = \"anthropic\"\nbase_url = \"https://api.anthropic.com\"\napi_key = \"sk-ant-xxx\"\n```\n\n### `gemini`\n\n用于连接 Google Gemini API。\n\n```toml\n[providers.gemini]\ntype = \"gemini\"\nbase_url = \"https://generativelanguage.googleapis.com\"\napi_key = \"xxx\"\n```\n\n### `vertexai`\n\n用于连接 Google Vertex AI。需要通过 `env` 字段设置必要的环境变量。\n\n```toml\n[providers.vertexai]\ntype = \"vertexai\"\nbase_url = \"https://xxx-aiplatform.googleapis.com\"\napi_key = \"\"\nenv = { GOOGLE_CLOUD_PROJECT = \"your-project-id\" }\n```\n\n## 模型能力\n\n模型配置中的 `capabilities` 字段声明模型支持的能力。这会影响 Kimi Code CLI 的功能可用性。\n\n| 能力 | 说明 |\n| --- | --- |\n| `thinking` | 支持 Thinking 模式（深度思考），可开关 |\n| `always_thinking` | 始终使用 Thinking 模式（不可关闭） |\n| `image_in` | 支持图片输入 |\n| `video_in` | 支持视频输入 |\n\n```toml\n[models.gemini-3-pro-preview]\nprovider = \"gemini\"\nmodel = \"gemini-3-pro-preview\"\nmax_context_size = 262144\ncapabilities = [\"thinking\", \"image_in\"]\n```\n\n### `thinking`\n\n声明模型支持 Thinking 模式。启用后，模型会在回答前进行更深入的推理，适合复杂问题。在 Shell 模式下，可以通过 `/model` 命令切换模型和 Thinking 模式，或在启动时通过 `--thinking` / `--no-thinking` 参数控制。\n\n### `always_thinking`\n\n表示模型始终使用 Thinking 模式，无法关闭。例如 `kimi-k2-thinking-turbo` 等名称中包含 \"thinking\" 的模型通常具有此能力。使用这类模型时，`/model` 命令不会提示选择 Thinking 模式的开关。\n\n### `image_in`\n\n启用图片输入能力后，可以在对话中粘贴图片（`Ctrl-V`）。\n\n### `video_in`\n\n启用视频输入能力后，可以在对话中发送视频内容。\n\n## 搜索和抓取服务\n\n`SearchWeb` 和 `FetchURL` 工具依赖外部服务，目前仅 Kimi Code 平台提供这些服务。\n\n使用 `/login` 选择 Kimi Code 平台时，搜索和抓取服务会自动配置。\n\n| 服务 | 对应工具 | 未配置时的行为 |\n| --- | --- | --- |\n| `moonshot_search` | `SearchWeb` | 工具不可用 |\n| `moonshot_fetch` | `FetchURL` | 回退到本地抓取 |\n\n使用其他平台时，`FetchURL` 工具仍可使用，但会回退到本地抓取。\n\n"
  },
  {
    "path": "docs/zh/customization/agents.md",
    "content": "# Agent 与子 Agent\n\nAgent 定义了 AI 的行为方式，包括系统提示词、可用工具和子 Agent。你可以使用内置 Agent，也可以创建自定义 Agent。\n\n## 内置 Agent\n\nKimi Code CLI 提供两个内置 Agent。启动时可以通过 `--agent` 参数选择：\n\n```sh\nkimi --agent okabe\n```\n\n### `default`\n\n默认 Agent，适合通常情况使用。启用的工具：\n\n`Task`、`AskUserQuestion`、`SetTodoList`、`Shell`、`ReadFile`、`ReadMediaFile`、`Glob`、`Grep`、`WriteFile`、`StrReplaceFile`、`SearchWeb`、`FetchURL`、`EnterPlanMode`、`ExitPlanMode`、`TaskList`、`TaskOutput`、`TaskStop`\n\n### `okabe`\n\n实验性 Agent，用于实验新的提示词和工具。在 `default` 的基础上额外启用 `SendDMail`。\n\n## 自定义 Agent 文件\n\nAgent 使用 YAML 格式定义。通过 `--agent-file` 参数加载自定义 Agent：\n\n```sh\nkimi --agent-file /path/to/my-agent.yaml\n```\n\n**基本结构**\n\n```yaml\nversion: 1\nagent:\n  name: my-agent\n  system_prompt_path: ./system.md\n  tools:\n    - \"kimi_cli.tools.shell:Shell\"\n    - \"kimi_cli.tools.file:ReadFile\"\n    - \"kimi_cli.tools.file:WriteFile\"\n```\n\n**继承与覆盖**\n\n使用 `extend` 可以继承其他 Agent 的配置，只覆盖需要修改的部分：\n\n```yaml\nversion: 1\nagent:\n  extend: default  # 继承默认 Agent\n  system_prompt_path: ./my-prompt.md  # 覆盖系统提示词\n  exclude_tools:  # 排除某些工具\n    - \"kimi_cli.tools.web:SearchWeb\"\n    - \"kimi_cli.tools.web:FetchURL\"\n```\n\n`extend: default` 会继承内置的默认 Agent。你也可以指定相对路径继承其他 Agent 文件。\n\n**配置字段**\n\n| 字段 | 说明 | 是否必填 |\n|------|------|----------|\n| `extend` | 继承的 Agent，可以是 `default` 或相对路径 | 否 |\n| `name` | Agent 名称 | 是（继承时可省略） |\n| `system_prompt_path` | 系统提示词文件路径，相对于 Agent 文件 | 是（继承时可省略） |\n| `system_prompt_args` | 传递给系统提示词的自定义参数，继承时会合并 | 否 |\n| `tools` | 工具列表，格式为 `模块:类名` | 是（继承时可省略） |\n| `exclude_tools` | 要排除的工具 | 否 |\n| `subagents` | 子 Agent 定义 | 否 |\n\n## 系统提示词内置参数\n\n系统提示词文件是一个 Markdown 模板，可以使用 `${VAR}` 语法引用变量。内置变量包括：\n\n| 变量 | 说明 |\n|------|------|\n| `${KIMI_NOW}` | 当前时间（ISO 格式） |\n| `${KIMI_WORK_DIR}` | 工作目录路径 |\n| `${KIMI_WORK_DIR_LS}` | 工作目录文件列表 |\n| `${KIMI_AGENTS_MD}` | AGENTS.md 文件内容（如果存在） |\n| `${KIMI_SKILLS}` | 加载的 Skills 列表 |\n| `${KIMI_ADDITIONAL_DIRS_INFO}` | 通过 `--add-dir` 或 `/add-dir` 添加的额外目录信息 |\n\n你也可以通过 `system_prompt_args` 定义自定义参数：\n\n```yaml\nagent:\n  system_prompt_args:\n    MY_VAR: \"自定义值\"\n```\n\n然后在提示词中使用 `${MY_VAR}`。\n\n**系统提示词示例**\n\n```markdown\n# My Agent\n\nYou are a helpful assistant. Current time: ${KIMI_NOW}.\n\nWorking directory: ${KIMI_WORK_DIR}\n\n${MY_VAR}\n```\n\n## 在 Agent 文件中定义子 Agent\n\n子 Agent 可以处理特定类型的任务。在 Agent 文件中定义子 Agent 后，主 Agent 可以通过 `Task` 工具启动它们：\n\n```yaml\nversion: 1\nagent:\n  extend: default\n  subagents:\n    coder:\n      path: ./coder-sub.yaml\n      description: \"处理编码任务\"\n    reviewer:\n      path: ./reviewer-sub.yaml\n      description: \"代码审查专家\"\n```\n\n子 Agent 文件也是标准的 Agent 格式，通常会继承主 Agent 并排除某些工具：\n\n```yaml\n# coder-sub.yaml\nversion: 1\nagent:\n  extend: ./agent.yaml  # 继承主 Agent\n  system_prompt_args:\n    ROLE_ADDITIONAL: |\n      你现在作为子 Agent 运行...\n  exclude_tools:\n    - \"kimi_cli.tools.multiagent:Task\"  # 排除 Task 工具，避免嵌套\n```\n\n## 子 Agent 的运行方式\n\n通过 `Task` 工具启动的子 Agent 会在独立的上下文中运行，完成后将结果返回给主 Agent。这种方式的优势：\n\n- 隔离上下文，避免污染主 Agent 的对话历史\n- 可以并行处理多个独立任务\n- 子 Agent 可以有针对性的系统提示词\n\n## 动态创建子 Agent\n\n`CreateSubagent` 是一个高级工具，允许 AI 在运行时动态定义新的子 Agent 类型（默认未启用）。动态创建的子 Agent 会随会话状态持久化，恢复会话时自动还原。如需使用，在 Agent 文件中添加：\n\n```yaml\nagent:\n  tools:\n    - \"kimi_cli.tools.multiagent:CreateSubagent\"\n```\n\n## 内置工具列表\n\n以下是 Kimi Code CLI 内置的所有工具。\n\n### `Task`\n\n- **路径**：`kimi_cli.tools.multiagent:Task`\n- **描述**：调度子 Agent 执行任务。子 Agent 无法访问主 Agent 的上下文，需在 prompt 中提供所有必要信息。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `description` | string | 任务简短描述（3-5 词） |\n| `subagent_name` | string | 子 Agent 名称 |\n| `prompt` | string | 任务详细描述 |\n\n### `AskUserQuestion`\n\n- **路径**：`kimi_cli.tools.ask_user:AskUserQuestion`\n- **描述**：在执行过程中向用户展示结构化问题和选项，收集用户偏好或决策。适用于需要用户在多个方案中做出选择、解决模糊指令或收集需求信息的场景。不应过度使用——只在用户的选择真正影响后续操作时才调用。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `questions` | array | 问题列表（1–4 个问题） |\n| `questions[].question` | string | 问题文本，以 `?` 结尾 |\n| `questions[].header` | string | 短标签，最多 12 字符（如 `Auth`、`Style`） |\n| `questions[].options` | array | 可选项（2–4 个），系统会自动添加 \"Other\" 选项 |\n| `questions[].options[].label` | string | 选项标签（1–5 词），推荐选项可追加 `(Recommended)` |\n| `questions[].options[].description` | string | 选项说明 |\n| `questions[].multi_select` | bool | 是否允许多选，默认 false |\n\n### `SetTodoList`\n\n- **路径**：`kimi_cli.tools.todo:SetTodoList`\n- **描述**：管理待办事项列表，跟踪任务进度\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `todos` | array | 待办事项列表 |\n| `todos[].title` | string | 待办事项标题 |\n| `todos[].status` | string | 状态：`pending`、`in_progress`、`done` |\n\n### `Shell`\n\n- **路径**：`kimi_cli.tools.shell:Shell`\n- **描述**：执行 Shell 命令。需要用户审批。根据操作系统使用对应的 Shell（Unix 使用 bash/zsh，Windows 使用 PowerShell）。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `command` | string | 要执行的命令 |\n| `timeout` | int | 超时时间（秒），默认 60，前台最大 300 / 后台最大 86400 |\n| `run_in_background` | bool | 是否作为后台任务运行，默认 false |\n| `description` | string | 后台任务的简短描述，`run_in_background=true` 时必填 |\n\n设置 `run_in_background=true` 后，命令会作为后台任务启动，工具立即返回任务 ID，AI 可以继续执行其他操作。任务完成时系统自动发送通知。适用于耗时的构建、测试、监控等场景。\n\n### `ReadFile`\n\n- **路径**：`kimi_cli.tools.file:ReadFile`\n- **描述**：读取文本文件内容。单次最多读取 1000 行，每行最多 2000 字符。工作目录外的文件需使用绝对路径。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `path` | string | 文件路径 |\n| `line_offset` | int | 起始行号，默认 1 |\n| `n_lines` | int | 读取行数，默认/最大 1000 |\n\n### `ReadMediaFile`\n\n- **路径**：`kimi_cli.tools.file:ReadMediaFile`\n- **描述**：读取图片或视频文件。文件最大 100MB。仅当模型支持图片/视频输入时可用。工作目录外的文件需使用绝对路径。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `path` | string | 文件路径 |\n\n### `Glob`\n\n- **路径**：`kimi_cli.tools.file:Glob`\n- **描述**：按模式匹配文件和目录。最多返回 1000 个匹配项，不允许以 `**` 开头的模式。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `pattern` | string | Glob 模式（如 `*.py`、`src/**/*.ts`） |\n| `directory` | string | 搜索目录，默认工作目录 |\n| `include_dirs` | bool | 是否包含目录，默认 true |\n\n### `Grep`\n\n- **路径**：`kimi_cli.tools.file:Grep`\n- **描述**：使用正则表达式搜索文件内容，基于 ripgrep 实现\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `pattern` | string | 正则表达式模式 |\n| `path` | string | 搜索路径，默认当前目录 |\n| `glob` | string | 文件过滤（如 `*.js`） |\n| `type` | string | 文件类型（如 `py`、`js`、`go`） |\n| `output_mode` | string | 输出模式：`files_with_matches`（默认）、`content`、`count_matches` |\n| `-B` | int | 显示匹配行前 N 行 |\n| `-A` | int | 显示匹配行后 N 行 |\n| `-C` | int | 显示匹配行前后 N 行 |\n| `-n` | bool | 显示行号 |\n| `-i` | bool | 忽略大小写 |\n| `multiline` | bool | 启用多行匹配 |\n| `head_limit` | int | 限制输出行数 |\n\n### `WriteFile`\n\n- **路径**：`kimi_cli.tools.file:WriteFile`\n- **描述**：写入文件。写入操作需要用户审批。写入工作目录外文件时，必须使用绝对路径。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `path` | string | 绝对路径 |\n| `content` | string | 文件内容 |\n| `mode` | string | `overwrite`（默认）或 `append` |\n\n### `StrReplaceFile`\n\n- **路径**：`kimi_cli.tools.file:StrReplaceFile`\n- **描述**：使用字符串替换编辑文件。编辑操作需要用户审批。编辑工作目录外文件时，必须使用绝对路径。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `path` | string | 绝对路径 |\n| `edit` | object/array | 单个编辑或编辑列表 |\n| `edit.old` | string | 要替换的原字符串 |\n| `edit.new` | string | 替换后的字符串 |\n| `edit.replace_all` | bool | 是否替换所有匹配项，默认 false |\n\n### `SearchWeb`\n\n- **路径**：`kimi_cli.tools.web:SearchWeb`\n- **描述**：搜索网页。需要配置搜索服务（Kimi Code 平台自动配置）。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `query` | string | 搜索关键词 |\n| `limit` | int | 结果数量，默认 5，最大 20 |\n| `include_content` | bool | 是否包含页面内容，默认 false |\n\n### `FetchURL`\n\n- **路径**：`kimi_cli.tools.web:FetchURL`\n- **描述**：抓取网页内容，返回提取的主要文本内容。如果配置了抓取服务会优先使用，否则使用本地 HTTP 请求。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `url` | string | 要抓取的 URL |\n\n### `Think`\n\n- **路径**：`kimi_cli.tools.think:Think`\n- **描述**：让 Agent 记录思考过程，适用于复杂推理场景\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `thought` | string | 思考内容 |\n\n### `SendDMail`\n\n- **路径**：`kimi_cli.tools.dmail:SendDMail`\n- **描述**：发送延迟消息（D-Mail），用于检查点回滚场景\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `message` | string | 要发送的消息 |\n| `checkpoint_id` | int | 要发送回的检查点 ID（>= 0） |\n\n### `EnterPlanMode`\n\n- **路径**：`kimi_cli.tools.plan.enter:EnterPlanMode`\n- **描述**：请求进入 Plan 模式。调用后会向用户展示审批请求，用户可以选择同意或拒绝进入 Plan 模式。在 YOLO 模式下，仅在用户明确要求规划或存在重大架构歧义时使用。详见 [Plan 模式](../guides/interaction.md#plan-模式)。\n\n此工具不接受参数。\n\n### `ExitPlanMode`\n\n- **路径**：`kimi_cli.tools.plan:ExitPlanMode`\n- **描述**：在 Plan 模式下完成方案后提交审批。调用前需先将方案写入 plan 文件，此工具会读取 plan 文件内容并展示给用户审批。用户可以选择某个实施路径（退出 Plan 模式并开始执行）、拒绝（保持 Plan 模式等待反馈）或提供修改意见。详见 [Plan 模式](../guides/interaction.md#plan-模式)。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `options` | list \\| null | 当方案包含多个可选实施路径时，列出 2–3 个选项供用户选择。每个选项有 `label`（1–8 个词的简短标签，可附加 \"(Recommended)\"）和可选的 `description`（方案摘要）。不可使用 \"Approve\"、\"Reject\"、\"Revise\" 作为标签名。 |\n\n### `TaskList`\n\n- **路径**：`kimi_cli.tools.background:TaskList`\n- **描述**：列出当前会话中的后台任务。适用于上下文压缩后重新获取任务 ID，或检查哪些任务仍在运行。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `active_only` | bool | 是否仅列出活跃任务，默认 true |\n| `limit` | int | 返回的最大任务数（1–100），默认 20 |\n\n### `TaskOutput`\n\n- **路径**：`kimi_cli.tools.background:TaskOutput`\n- **描述**：获取后台任务的输出和状态。支持阻塞等待或非阻塞查询。返回结构化的任务元数据和输出预览；如果输出被截断，可使用 `ReadFile` 分页读取完整日志。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `task_id` | string | 要查询的任务 ID |\n| `block` | bool | 是否等待任务完成，默认 true |\n| `timeout` | int | `block=true` 时的最大等待秒数（0–3600），默认 30 |\n\n### `TaskStop`\n\n- **路径**：`kimi_cli.tools.background:TaskStop`\n- **描述**：停止正在运行的后台任务。需要用户审批。仅在任务必须取消时使用；对于正常完成的任务，应等待自动通知。在 Plan 模式下不可用。\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `task_id` | string | 要停止的任务 ID |\n| `reason` | string | 停止原因（可选），默认 \"Stopped by TaskStop\" |\n\n### `CreateSubagent`\n\n- **路径**：`kimi_cli.tools.multiagent:CreateSubagent`\n- **描述**：动态创建子 Agent\n\n| 参数 | 类型 | 说明 |\n|------|------|------|\n| `name` | string | 子 Agent 的唯一名称，用于在 `Task` 工具中引用 |\n| `system_prompt` | string | 定义 Agent 角色、能力和边界的系统提示词 |\n\n## 工具安全边界\n\n**工作区范围**\n\n- 文件读写通常在工作目录（及通过 `--add-dir` 或 `/add-dir` 添加的额外目录）内进行\n- 读取工作区外文件需使用绝对路径\n- 写入和编辑操作都需要用户审批；操作工作区外文件时，必须使用绝对路径\n\n**审批机制**\n\n以下操作需要用户审批：\n\n| 操作 | 审批要求 |\n|------|---------|\n| Shell 命令执行 | 每次执行 |\n| 文件写入/编辑 | 每次操作 |\n| MCP 工具调用 | 每次调用 |\n| 停止后台任务 | 每次停止 |\n"
  },
  {
    "path": "docs/zh/customization/mcp.md",
    "content": "# Model Context Protocol\n\n[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 是一个开放协议，让 AI 模型可以安全地与外部工具和数据源交互。Kimi Code CLI 支持连接 MCP 服务器，扩展 AI 的能力。\n\n## MCP 是什么\n\nMCP 服务器提供「工具」给 AI 使用。例如，一个数据库 MCP 服务器可以提供查询工具，让 AI 能够执行 SQL 查询；一个浏览器 MCP 服务器可以让 AI 控制浏览器进行自动化操作。\n\nKimi Code CLI 内置了一些工具（文件读写、Shell 命令、网页抓取等），通过 MCP 你可以添加更多工具，比如：\n\n- 访问特定 API 或数据库\n- 控制浏览器或其他应用\n- 与第三方服务集成（GitHub、Linear、Notion 等）\n\n## MCP 服务器管理\n\n使用 [`kimi mcp`](../reference/kimi-mcp.md) 命令管理 MCP 服务器。\n\n**添加服务器**\n\n添加 HTTP 服务器：\n\n```sh\n# 基本用法\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp\n\n# 带 Header\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp \\\n  --header \"CONTEXT7_API_KEY: your-key\"\n\n# 使用 OAuth 认证\nkimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp\n```\n\n添加 stdio 服务器（本地进程）：\n\n```sh\nkimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest\n```\n\n**列出服务器**\n\n```sh\nkimi mcp list\n```\n\n在 Kimi Code CLI 运行时，也可以输入 `/mcp` 查看已连接的服务器和加载的工具。\n\n**移除服务器**\n\n```sh\nkimi mcp remove context7\n```\n\n**OAuth 授权**\n\n对于使用 OAuth 的服务器，需要先完成授权：\n\n```sh\nkimi mcp auth linear\n```\n\n这会打开浏览器完成 OAuth 流程。授权成功后，Kimi Code CLI 会保存 token 供后续使用。\n\n**测试服务器**\n\n```sh\nkimi mcp test context7\n```\n\n## MCP 配置文件\n\nMCP 服务器配置存储在 `~/.kimi/mcp.json`，格式与其他 MCP 客户端兼容：\n\n```json\n{\n  \"mcpServers\": {\n    \"context7\": {\n      \"url\": \"https://mcp.context7.com/mcp\",\n      \"headers\": {\n        \"CONTEXT7_API_KEY\": \"your-key\"\n      }\n    },\n    \"chrome-devtools\": {\n      \"command\": \"npx\",\n      \"args\": [\"chrome-devtools-mcp@latest\"],\n      \"env\": {\n        \"SOME_VAR\": \"value\"\n      }\n    }\n  }\n}\n```\n\n**临时加载配置**\n\n使用 `--mcp-config-file` 参数可以加载其他位置的配置文件：\n\n```sh\nkimi --mcp-config-file /path/to/mcp.json\n```\n\n使用 `--mcp-config` 参数可以直接传入 JSON 配置：\n\n```sh\nkimi --mcp-config '{\"mcpServers\": {\"test\": {\"url\": \"https://...\"}}}'\n```\n\n## 加载状态\n\nMCP 服务器在 Shell UI 启动后异步初始化，不会阻塞界面的使用。Shell 底部状态栏会实时显示连接进度，连接完成后自动切换为就绪状态。Web 界面也会同步显示各服务器的连接状态。\n\n如果配置了多个 MCP 服务器，加载时间可能较长，状态栏的进度指示可以帮助你了解当前连接情况。\n\n## 安全性\n\nMCP 工具可能会访问和操作外部系统，需要注意安全风险。\n\n**审批机制**\n\nKimi Code CLI 对敏感操作（如文件修改、命令执行）会请求用户确认。MCP 工具也遵循同样的审批机制，所有 MCP 工具调用都会弹出确认提示。\n\n**提示词注入风险**\n\nMCP 工具返回的内容可能包含恶意指令，试图诱导 AI 执行危险操作。Kimi Code CLI 会对工具返回内容进行标记，帮助 AI 区分工具输出和用户指令，但你仍应：\n\n- 只使用可信来源的 MCP 服务器\n- 检查 AI 提议的操作是否合理\n- 对于高风险操作保持手动审批\n\n::: warning 注意\n在 YOLO 模式下，MCP 工具的操作也会被自动批准。建议仅在完全信任 MCP 服务器的情况下使用 YOLO 模式。\n:::\n"
  },
  {
    "path": "docs/zh/customization/print-mode.md",
    "content": "# Print 模式\n\nPrint 模式让 Kimi Code CLI 以非交互方式运行，适合脚本调用和自动化场景。\n\n## 基本用法\n\n使用 `--print` 参数启用 Print 模式：\n\n```sh\n# 通过 -p 传入指令（或 -c）\nkimi --print -p \"列出当前目录的所有 Python 文件\"\n\n# 通过 stdin 传入指令\necho \"解释这段代码的作用\" | kimi --print\n```\n\nPrint 模式的特点：\n\n- **非交互**：执行完指令后自动退出\n- **自动审批**：隐式启用 `--yolo` 模式，所有操作自动批准\n- **文本输出**：AI 的回复输出到 stdout\n\n<!-- TODO: 支持同时从 stdin 读取内容和 -p 读取指令后启用此示例\n**管道组合示例**\n\n```sh\n# 分析 git diff 并生成提交信息\ngit diff --staged | kimi --print -p \"根据这个 diff 生成一个符合 Conventional Commits 规范的提交信息\"\n\n# 读取文件并生成文档\ncat src/api.py | kimi --print -p \"为这个 Python 模块生成 API 文档\"\n```\n-->\n\n## 仅输出最终消息\n\n使用 `--final-message-only` 选项可以只输出最终的 assistant 消息，跳过中间的工具调用过程：\n\n```sh\nkimi --print -p \"根据当前变更给我一个 Git commit message\" --final-message-only\n```\n\n`--quiet` 是 `--print --output-format text --final-message-only` 的快捷方式，适合只需要最终结果的场景：\n\n```sh\nkimi --quiet -p \"根据当前变更给我一个 Git commit message\"\n```\n\n## JSON 格式\n\nPrint 模式支持 JSON 格式的输入和输出，方便程序化处理。输入和输出都使用 [Message](#message-格式) 格式。\n\n**JSON 输出**\n\n使用 `--output-format=stream-json` 以 JSONL（每行一个 JSON）格式输出：\n\n```sh\nkimi --print -p \"你好\" --output-format=stream-json\n```\n\n输出示例：\n\n```jsonl\n{\"role\":\"assistant\",\"content\":\"你好！有什么可以帮助你的吗？\"}\n```\n\n如果 AI 调用了工具，会依次输出 assistant 消息和 tool 消息：\n\n```jsonl\n{\"role\":\"assistant\",\"content\":\"让我查看一下当前目录。\",\"tool_calls\":[{\"type\":\"function\",\"id\":\"tc_1\",\"function\":{\"name\":\"Shell\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}}]}\n{\"role\":\"tool\",\"tool_call_id\":\"tc_1\",\"content\":\"file1.py\\nfile2.py\"}\n{\"role\":\"assistant\",\"content\":\"当前目录有两个 Python 文件。\"}\n```\n\n**JSON 输入**\n\n使用 `--input-format=stream-json` 接收 JSONL 格式的输入：\n\n```sh\necho '{\"role\":\"user\",\"content\":\"你好\"}' | kimi --print --input-format=stream-json --output-format=stream-json\n```\n\n这种模式下，Kimi Code CLI 会持续读取 stdin，每收到一条用户消息就处理并输出响应，直到 stdin 关闭。\n\n## Message 格式\n\n输入和输出都使用统一的 Message 格式。\n\n**User 消息**\n\n```json\n{\"role\": \"user\", \"content\": \"你的问题或指令\"}\n```\n\n也可以使用数组形式的 content：\n\n```json\n{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"你的问题\"}]}\n```\n\n**Assistant 消息**\n\n```json\n{\"role\": \"assistant\", \"content\": \"回复内容\"}\n```\n\n带工具调用的助手消息：\n\n```json\n{\n  \"role\": \"assistant\",\n  \"content\": \"让我执行这个命令。\",\n  \"tool_calls\": [\n    {\n      \"type\": \"function\",\n      \"id\": \"tc_1\",\n      \"function\": {\n        \"name\": \"Shell\",\n        \"arguments\": \"{\\\"command\\\":\\\"ls\\\"}\"\n      }\n    }\n  ]\n}\n```\n\n**Tool 消息**\n\n```json\n{\"role\": \"tool\", \"tool_call_id\": \"tc_1\", \"content\": \"工具执行结果\"}\n```\n\n## 使用场景\n\n**CI/CD 集成**\n\n在 CI 流程中自动生成代码或执行检查：\n\n```sh\nkimi --print -p \"检查 src/ 目录下是否有明显的安全问题，输出 JSON 格式的报告\"\n```\n\n**批量处理**\n\n结合 shell 循环批量处理文件：\n\n```sh\nfor file in src/*.py; do\n  kimi --print -p \"为 $file 添加类型注解\"\ndone\n```\n\n**与其他工具集成**\n\n作为其他工具的后端，通过 JSON 格式进行通信：\n\n```sh\nmy-tool | kimi --print --input-format=stream-json --output-format=stream-json | process-output\n```\n"
  },
  {
    "path": "docs/zh/customization/skills.md",
    "content": "# Agent Skills\n\n[Agent Skills](https://agentskills.io/) 是一个开放格式，用于为 AI Agent 添加专业知识和工作流程。Kimi Code CLI 支持加载 Agent Skills，扩展 AI 的能力。\n\n## Agent Skills 是什么\n\n一个 Skill 就是一个包含 `SKILL.md` 文件的目录。Kimi Code CLI 启动时会发现所有 Skills，并将它们的名称、路径和描述注入到系统提示词中。AI 会根据当前任务的需要，自行决定是否读取具体的 `SKILL.md` 文件来获取详细指引。\n\n例如，你可以创建一个「代码风格」Skill，告诉 AI 你项目的命名规范、注释风格等；或者创建一个「安全审计」Skill，让 AI 在审查代码时关注特定的安全问题。\n\n## Skill 发现\n\nKimi Code CLI 采用分层加载机制发现 Skills，按以下优先级加载（后加载的会覆盖同名 Skill）：\n\n**内置 Skills**\n\n随软件包安装的 Skills，提供基础能力。\n\n**用户级 Skills**\n\n存放在用户主目录中，在所有项目中生效。Kimi Code CLI 会按优先级检查以下目录，使用第一个存在的目录：\n\n1. `~/.config/agents/skills/`（推荐）\n2. `~/.agents/skills/`\n3. `~/.kimi/skills/`\n4. `~/.claude/skills/`\n5. `~/.codex/skills/`\n\n**项目级 Skills**\n\n存放在项目目录中，仅在该项目工作目录下生效。Kimi Code CLI 会按优先级检查以下目录，使用第一个存在的目录：\n\n1. `.agents/skills/`（推荐）\n2. `.kimi/skills/`\n3. `.claude/skills/`\n4. `.codex/skills/`\n\n你也可以通过 `--skills-dir` 参数指定其他目录，此时会跳过用户级和项目级 Skills 的发现：\n\n```sh\nkimi --skills-dir /path/to/my-skills\n```\n\n::: tip 提示\nSkills 路径独立于 [`KIMI_SHARE_DIR`](../configuration/env-vars.md#kimi-share-dir)。`KIMI_SHARE_DIR` 用于自定义配置、会话、日志等运行时数据的存储位置，不影响 Skills 的搜索路径。Skills 是跨工具共享的能力扩展（支持 Kimi CLI、Claude、Codex 等多个工具共用），与应用运行时数据是不同类型的数据。如需覆盖 Skills 路径，请使用 `--skills-dir` 参数。\n:::\n\n## 内置 Skills\n\nKimi Code CLI 内置了以下 Skills：\n\n- **kimi-cli-help**：Kimi Code CLI 帮助。解答关于 Kimi Code CLI 安装、配置、斜杠命令、键盘快捷键、MCP 集成、供应商、环境变量等问题。\n- **skill-creator**：Skill 创建指南。当你需要创建新的 Skill（或更新现有 Skill）来扩展 Kimi 的能力时，可以使用此 Skill 获取详细的创建指导和最佳实践。\n\n## 创建 Skill\n\n创建一个 Skill 只需要两步：\n\n1. 在 skills 目录下创建一个子目录\n2. 在子目录中创建 `SKILL.md` 文件\n\n**目录结构**\n\n一个 Skill 目录至少需要包含 `SKILL.md` 文件，也可以包含辅助目录来组织更复杂的内容：\n\n```\n~/.config/agents/skills/\n└── my-skill/\n    ├── SKILL.md          # 必需：主文件\n    ├── scripts/          # 可选：脚本文件\n    ├── references/       # 可选：参考文档\n    └── assets/           # 可选：其他资源\n```\n\n**`SKILL.md` 格式**\n\n`SKILL.md` 使用 YAML Frontmatter 定义元数据，后面是 Markdown 格式的提示词内容：\n\n```markdown\n---\nname: code-style\ndescription: 我的项目代码风格规范\n---\n\n## 代码风格\n\n在这个项目中，请遵循以下规范：\n\n- 使用 4 空格缩进\n- 变量名使用 camelCase\n- 函数名使用 snake_case\n- 每个函数都需要 docstring\n- 单行不超过 100 字符\n```\n\n**Frontmatter 字段**\n\n| 字段 | 说明 | 是否必填 |\n|------|------|----------|\n| `name` | Skill 名称，1-64 字符，只能使用小写字母、数字和连字符；省略时默认使用目录名 | 否 |\n| `description` | Skill 描述，1-1024 字符，说明 Skill 的用途和使用场景；省略时显示 \"No description provided.\" | 否 |\n| `license` | 许可证名称或文件引用 | 否 |\n| `compatibility` | 环境要求说明，最多 500 字符 | 否 |\n| `metadata` | 额外的键值对属性 | 否 |\n\n**最佳实践**\n\n- 保持 `SKILL.md` 在 500 行以内，将详细内容移到 `scripts/`、`references/` 或 `assets/` 目录\n- 在 `SKILL.md` 中使用相对路径引用其他文件\n- 提供清晰的步骤指引、输入输出示例和边界情况说明\n\n## 示例 Skill\n\n**PPT 制作**\n\n```markdown\n---\nname: pptx\ndescription: 创建和编辑 PowerPoint 演示文稿\n---\n\n## PPT 制作流程\n\n创建演示文稿时，遵循以下步骤：\n\n1. 分析内容结构，规划幻灯片大纲\n2. 选择合适的配色方案和字体\n3. 使用 python-pptx 库生成 .pptx 文件\n\n## 设计原则\n\n- 每页幻灯片聚焦一个主题\n- 文字简洁，使用要点而非长段落\n- 保持视觉层次清晰，标题、正文、注释有明确区分\n- 配色统一，避免超过 3 种主色\n```\n\n**Python 项目规范**\n\n```markdown\n---\nname: python-project\ndescription: Python 项目开发规范，包括代码风格、测试和依赖管理\n---\n\n## Python 开发规范\n\n- 使用 Python 3.14+\n- 使用 ruff 进行代码格式化和 lint\n- 使用 pyright 进行类型检查\n- 测试使用 pytest\n- 依赖管理使用 uv\n\n代码风格：\n- 行长度限制 100 字符\n- 使用类型注解\n- 公开函数需要 docstring\n```\n\n**Git 提交规范**\n\n```markdown\n---\nname: git-commits\ndescription: Git 提交信息规范，使用 Conventional Commits 格式\n---\n\n## Git 提交规范\n\n使用 Conventional Commits 格式：\n\n类型(范围): 描述\n\n允许的类型：feat, fix, docs, style, refactor, test, chore\n\n示例：\n- feat(auth): 添加 OAuth 登录支持\n- fix(api): 修复用户查询返回空值的问题\n- docs(readme): 更新安装说明\n```\n\n## 使用斜杠命令加载 Skill\n\n`/skill:<name>` 斜杠命令让你可以将常用的提示词模板保存为 Skill，需要时快速调用。输入命令后，Kimi Code CLI 会读取对应的 `SKILL.md` 文件内容，并将其作为提示词发送给 Agent。\n\n例如：\n\n- `/skill:code-style`：加载代码风格规范\n- `/skill:pptx`：加载 PPT 制作流程\n- `/skill:git-commits 修复用户登录问题`：加载 Git 提交规范，同时附带额外的任务描述\n\n斜杠命令后面可以附带额外的文本，这些内容会追加到 Skill 提示词之后，作为用户的具体请求。\n\n::: tip 提示\n如果只是普通对话，Agent 会根据上下文自动判断是否需要读取 Skill 内容，不需要手动调用。\n:::\n\nSkills 让你可以将团队的最佳实践和项目规范固化下来，确保 AI 始终遵循一致的标准。\n\n## Flow Skills\n\nFlow Skill 是一种特殊的 Skill 类型，它在 `SKILL.md` 中内嵌 Agent Flow 流程图，用于定义多步骤的自动化工作流。与普通 Skill 不同，Flow Skill 通过 `/flow:<name>` 命令调用，会按照流程图自动执行多个对话轮次。\n\n**创建 Flow Skill**\n\n创建 Flow Skill 需要在 Frontmatter 中设置 `type: flow`，并在内容中包含 Mermaid 或 D2 格式的流程图代码块：\n\n````markdown\n---\nname: code-review\ndescription: 代码审查工作流\ntype: flow\n---\n\n```mermaid\nflowchart TD\nA([BEGIN]) --> B[分析代码变更，列出所有修改的文件和功能]\nB --> C{代码质量是否达标？}\nC -->|是| D[生成代码审查报告]\nC -->|否| E[列出问题并提出改进建议]\nE --> B\nD --> F([END])\n```\n````\n\n**流程图格式**\n\n支持 Mermaid 和 D2 两种格式：\n\n- **Mermaid**：使用 ` ```mermaid ` 代码块，[Mermaid Playground](https://www.mermaidchart.com/play) 可用于编辑和预览\n- **D2**：使用 ` ```d2 ` 代码块，[D2 Playground](https://play.d2lang.com) 可用于编辑和预览\n\n流程图必须包含一个 `BEGIN` 节点和一个 `END` 节点。普通节点的文本作为提示词发送给 Agent；分支节点需要 Agent 在输出中使用 `<choice>分支名</choice>` 选择下一步。\n\n**D2 格式示例**\n\n```\nBEGIN -> B -> C\nB: 分析现有代码，为 XXX 功能编写设计文档\nC: Review 设计文档是否足够详细\nC -> B: 否\nC -> D: 是\nD: 开始实现\nD -> END\n```\n\n对于多行标签，可以使用 D2 的块字符串语法（`|md`）：\n\n```\nBEGIN -> step -> END\nstep: |md\n  # 详细指引\n\n  1. 分析代码结构\n  2. 检查潜在问题\n  3. 生成报告\n|\n```\n\n**执行 Flow Skill**\n\nFlow Skill 可以通过两种方式调用：\n\n- `/flow:<name>`：执行流程，Agent 会从 `BEGIN` 节点开始，按照流程图定义依次处理每个节点，直到到达 `END` 节点\n- `/skill:<name>`：与普通 Skill 一样，将 `SKILL.md` 内容作为提示词发送给 Agent（不自动执行流程）\n\n```sh\n# 执行流程\n/flow:code-review\n\n# 作为普通 Skill 加载\n/skill:code-review\n```\n"
  },
  {
    "path": "docs/zh/customization/wire-mode.md",
    "content": "# Wire 模式\n\nWire 模式是 Kimi Code CLI 的底层通信协议，用于与外部程序进行结构化的双向通信。\n\n## Wire 是什么\n\nWire 是 Kimi Code CLI 内部使用的消息传递层。当你使用终端交互时，Shell UI 通过 Wire 接收 AI 的输出并显示；当你使用 ACP 集成到 IDE 时，ACP 服务器也通过 Wire 与 Agent 核心通信。\n\nWire 模式（`--wire`）将这个通信协议暴露出来，允许外部程序直接与 Kimi Code CLI 交互。这适用于构建自定义 UI 或将 Kimi Code CLI 嵌入到其他应用中。\n\n```sh\nkimi --wire\n```\n\n## 使用场景\n\nWire 模式主要用于：\n\n- **自定义 UI**：构建 Web、桌面或移动端的 Kimi Code CLI 前端\n- **应用集成**：将 Kimi Code CLI 嵌入到其他应用程序中\n- **自动化测试**：对 Agent 行为进行程序化测试\n\n::: tip 提示\n如果你只需要简单的非交互输入输出，使用 [Print 模式](./print-mode.md) 更简单。Wire 模式适合需要完整控制和双向通信的场景。\n:::\n\n## Wire 协议\n\nWire 使用基于 JSON-RPC 2.0 的协议，通过 stdin/stdout 进行双向通信。当前协议版本为 `1.5`。每条消息是一行 JSON，符合 JSON-RPC 2.0 规范。\n\n### 协议类型定义\n\n```typescript\n/** JSON-RPC 2.0 请求消息基础结构 */\ninterface JSONRPCRequest<Method extends string, Params> {\n  jsonrpc: \"2.0\"\n  method: Method\n  id: string\n  params: Params\n}\n\n/** JSON-RPC 2.0 通知消息（无 id，无需响应） */\ninterface JSONRPCNotification<Method extends string, Params> {\n  jsonrpc: \"2.0\"\n  method: Method\n  params: Params\n}\n\n/** JSON-RPC 2.0 成功响应 */\ninterface JSONRPCSuccessResponse<Result> {\n  jsonrpc: \"2.0\"\n  id: string\n  result: Result\n}\n\n/** JSON-RPC 2.0 错误响应 */\ninterface JSONRPCErrorResponse {\n  jsonrpc: \"2.0\"\n  id: string\n  error: JSONRPCError\n}\n\ninterface JSONRPCError {\n  code: number\n  message: string\n  data?: unknown\n}\n```\n\n### `initialize`\n\n::: info 新增\n新增于 Wire 1.1。旧版 Client 可跳过此请求，直接发送 `prompt`。\n:::\n\n- **方向**：Client → Agent\n- **类型**：Request（需要响应）\n\n可选握手请求，用于协商协议版本、提交外部工具定义并获取斜杠命令列表。\n\n```typescript\n/** initialize 请求参数 */\ninterface InitializeParams {\n  /** 协议版本 */\n  protocol_version: string\n  /** Client 信息，可选 */\n  client?: ClientInfo\n  /** 外部工具定义列表，可选 */\n  external_tools?: ExternalTool[]\n  /** Client 能力声明，可选 */\n  capabilities?: ClientCapabilities\n}\n\ninterface ClientCapabilities {\n  /** 是否支持处理 QuestionRequest 消息 */\n  supports_question?: boolean\n  /** 是否支持 Plan 模式 */\n  supports_plan_mode?: boolean\n}\n\ninterface ClientInfo {\n  name: string\n  version?: string\n}\n\ninterface ExternalTool {\n  /** 工具名称，不可与内置工具冲突 */\n  name: string\n  /** 工具描述 */\n  description: string\n  /** JSON Schema 格式的参数定义 */\n  parameters: JSONSchema\n}\n\n/** initialize 响应结果 */\ninterface InitializeResult {\n  /** 协议版本 */\n  protocol_version: string\n  /** Server 信息 */\n  server: ServerInfo\n  /** 可用的斜杠命令列表 */\n  slash_commands: SlashCommandInfo[]\n  /** 外部工具注册结果，仅当请求中包含 external_tools 时返回 */\n  external_tools?: ExternalToolsResult\n  /** Server 能力声明 */\n  capabilities?: ServerCapabilities\n}\n\ninterface ServerCapabilities {\n  /** 是否支持发送 QuestionRequest 消息 */\n  supports_question?: boolean\n}\n\ninterface ServerInfo {\n  name: string\n  version: string\n}\n\ninterface SlashCommandInfo {\n  name: string\n  description: string\n  aliases: string[]\n}\n\ninterface ExternalToolsResult {\n  /** 成功注册的工具名称列表 */\n  accepted: string[]\n  /** 注册失败的工具及原因 */\n  rejected: Array<{ name: string; reason: string }>\n}\n```\n\n**请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"initialize\", \"id\": \"550e8400-e29b-41d4-a716-446655440000\", \"params\": {\"protocol_version\": \"1.5\", \"client\": {\"name\": \"my-ui\", \"version\": \"1.0.0\"}, \"capabilities\": {\"supports_question\": true}, \"external_tools\": [{\"name\": \"open_in_ide\", \"description\": \"Open file in IDE\", \"parameters\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}}]}}\n```\n\n**成功响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"550e8400-e29b-41d4-a716-446655440000\", \"result\": {\"protocol_version\": \"1.5\", \"server\": {\"name\": \"Kimi Code CLI\", \"version\": \"1.14.0\"}, \"slash_commands\": [{\"name\": \"init\", \"description\": \"Analyze the codebase ...\", \"aliases\": []}], \"capabilities\": {\"supports_question\": true}, \"external_tools\": {\"accepted\": [\"open_in_ide\"], \"rejected\": []}}}\n```\n\n若 Server 不支持 `initialize` 方法，Client 会收到 `-32601 method not found` 错误，应自动降级到无握手模式。\n\n### `prompt`\n\n- **方向**：Client → Agent\n- **类型**：Request（需要响应）\n\n发送用户输入并运行 Agent 轮次。调用后 Agent 开始处理，期间会发送 `event` 通知和 `request` 请求，直到轮次完成才返回响应。\n\n```typescript\n/** prompt 请求参数 */\ninterface PromptParams {\n  /** 用户输入，可以是纯文本或内容片段数组 */\n  user_input: string | ContentPart[]\n}\n\n/** prompt 响应结果 */\ninterface PromptResult {\n  /** 轮次结束状态 */\n  status: \"finished\" | \"cancelled\" | \"max_steps_reached\"\n  /** 当 status 为 max_steps_reached 时，包含已执行的步数 */\n  steps?: number\n}\n```\n\n**请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"prompt\", \"id\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"params\": {\"user_input\": \"你好\"}}\n```\n\n**成功响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"finished\"}}\n```\n\n**错误响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32001, \"message\": \"LLM is not set\"}}\n```\n\n| code | 说明 |\n|------|------|\n| `-32000` | 已有轮次正在进行中 |\n| `-32001` | 未配置 LLM |\n| `-32002` | 不支持指定的 LLM |\n| `-32003` | LLM 服务错误 |\n\n### `replay`\n\n::: info 新增\n新增于 Wire 1.3。\n:::\n\n- **方向**：Client → Agent\n- **类型**：Request（需要响应）\n\n触发历史回放。Server 读取会话目录中的 `wire.jsonl`，按顺序重新发送已记录的 `event` 和 `request` 消息。回放是只读的，Client 不应对回放中的 `request` 消息作出响应。如果没有历史记录，Server 直接返回 `events: 0`、`requests: 0`。\n\n```typescript\n/** replay 请求无参数，params 可以是空对象或省略 */\ntype ReplayParams = Record<string, never>\n\n/** replay 响应结果 */\ninterface ReplayResult {\n  /** 回放结束状态 */\n  status: \"finished\" | \"cancelled\"\n  /** 回放的 event 数量 */\n  events: number\n  /** 回放的 request 数量 */\n  requests: number\n}\n```\n\n**请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"replay\", \"id\": \"6ba7b812-9dad-11d1-80b4-00c04fd430c8\"}\n```\n\n**成功响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b812-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"finished\", \"events\": 42, \"requests\": 3}}\n```\n\n### `steer`\n\n::: info 新增\n新增于 Wire 1.4。\n:::\n\n- **方向**：Client → Agent\n- **类型**：Request（需要响应）\n\n在 Agent 轮次进行中注入用户消息。与 `prompt` 不同，`steer` 不会开始新的轮次，而是将消息注入到当前正在进行的轮次中。注入的消息会在当前步骤完成后作为标准用户消息追加到上下文中，从而在下一步骤开始前”引导” AI 的行为。消息被消费时会发出 `SteerInput` 事件。\n\n```typescript\n/** steer 请求参数 */\ninterface SteerParams {\n  /** 用户输入，可以是纯文本或内容片段数组 */\n  user_input: string | ContentPart[]\n}\n\n/** steer 响应结果 */\ninterface SteerResult {\n  /** 固定为 \"steered\" */\n  status: \"steered\"\n}\n```\n\n**请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"steer\", \"id\": \"7ca7c810-9dad-11d1-80b4-00c04fd430c8\", \"params\": {\"user_input\": \"用 Python 实现\"}}\n```\n\n**成功响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"7ca7c810-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"steered\"}}\n```\n\n**错误响应示例**\n\n如果当前没有轮次在进行：\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"7ca7c810-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32000, \"message\": \"No agent turn is in progress\"}}\n```\n\n### `set_plan_mode`\n\n::: info 新增\n新增于 Wire 1.4。\n:::\n\n- **方向**：Client → Agent\n- **类型**：Request（需要响应）\n\n将 Plan 模式设置为指定状态。调用后 Agent 会更新 Plan 模式并通过 `StatusUpdate` 事件通知新的状态。\n\n此功能需要能力协商：Client 在 `initialize` 时通过 `capabilities.supports_plan_mode: true` 声明支持后，Agent 才会启用 Plan 模式相关工具（`EnterPlanMode`、`ExitPlanMode`）。如果 Client 未声明支持，这些工具会从 LLM 的工具列表中自动隐藏。\n\nPlan 模式状态会持久化到会话中，因此在进程重启后可以恢复。\n\n```typescript\n/** set_plan_mode 请求参数 */\ninterface SetPlanModeParams {\n  /** 是否启用 Plan 模式 */\n  enabled: boolean\n}\n\n/** set_plan_mode 响应结果 */\ninterface SetPlanModeResult {\n  /** 固定为 \"ok\" */\n  status: \"ok\"\n  /** 调用后的 Plan 模式状态 */\n  plan_mode: boolean\n}\n```\n\n**请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"set_plan_mode\", \"id\": \"8da7d810-9dad-11d1-80b4-00c04fd430c8\", \"params\": {\"enabled\": true}}\n```\n\n**成功响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"8da7d810-9dad-11d1-80b4-00c04fd430c8\", \"result\": {\"status\": \"ok\", \"plan_mode\": true}}\n```\n\n**错误响应示例**\n\n如果当前环境不支持 Plan 模式：\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"8da7d810-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32000, \"message\": \"Plan mode is not supported\"}}\n```\n\n### `cancel`\n\n- **方向**：Client → Agent\n- **类型**：Request（需要响应）\n\n取消当前正在进行的 Agent 轮次或回放。调用后，正在进行的 `prompt` 请求会返回 `{\"status\": \"cancelled\"}`，回放会返回 `{\"status\": \"cancelled\"}` 及已发送的消息计数。\n\n```typescript\n/** cancel 请求无参数，params 可以是空对象或省略 */\ntype CancelParams = Record<string, never>\n\n/** cancel 响应结果为空对象 */\ntype CancelResult = Record<string, never>\n```\n\n**请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"cancel\", \"id\": \"6ba7b811-9dad-11d1-80b4-00c04fd430c8\"}\n```\n\n**成功响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b811-9dad-11d1-80b4-00c04fd430c8\", \"result\": {}}\n```\n\n**错误响应示例**\n\n如果当前没有轮次在进行：\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"6ba7b811-9dad-11d1-80b4-00c04fd430c8\", \"error\": {\"code\": -32000, \"message\": \"No agent turn is in progress\"}}\n```\n\n### `event`\n\n- **方向**：Agent → Client\n- **类型**：Notification（无需响应）\n\nAgent 在轮次进行过程中发出的事件通知。没有 `id` 字段，Client 无需响应。\n\n```typescript\n/** event 通知参数，包含序列化后的 Wire 消息 */\ninterface EventParams {\n  type: string\n  payload: object\n}\n```\n\n**示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"event\", \"params\": {\"type\": \"ContentPart\", \"payload\": {\"type\": \"text\", \"text\": \"Hello\"}}}\n```\n\n### `request`\n\n- **方向**：Agent → Client\n- **类型**：Request（需要响应）\n\nAgent 向 Client 发出的请求，用于审批确认或外部工具调用。Client 必须响应后 Agent 才能继续执行。\n\n```typescript\n/** request 请求参数，包含序列化后的 Wire 消息 */\ninterface RequestParams {\n  type: \"ApprovalRequest\" | \"ToolCallRequest\" | \"QuestionRequest\"\n  payload: ApprovalRequest | ToolCallRequest | QuestionRequest\n}\n```\n\n**审批请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"request\", \"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"params\": {\"type\": \"ApprovalRequest\", \"payload\": {\"id\": \"approval-1\", \"tool_call_id\": \"tc-1\", \"sender\": \"Shell\", \"action\": \"run shell command\", \"description\": \"Run command `ls`\", \"display\": []}}}\n```\n\n**审批响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"result\": {\"request_id\": \"approval-1\", \"response\": \"approve\"}}\n```\n\n**外部工具调用请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"request\", \"id\": \"a3bb189e-8bf9-3888-9912-ace4e6543002\", \"params\": {\"type\": \"ToolCallRequest\", \"payload\": {\"id\": \"tc-1\", \"name\": \"open_in_ide\", \"arguments\": \"{\\\"path\\\":\\\"README.md\\\"}\"}}}\n```\n\n**外部工具调用响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"a3bb189e-8bf9-3888-9912-ace4e6543002\", \"result\": {\"tool_call_id\": \"tc-1\", \"return_value\": {\"is_error\": false, \"output\": \"Opened\", \"message\": \"Opened README.md in IDE\", \"display\": []}}}\n```\n\n### 标准错误码\n\n所有请求都可能返回 JSON-RPC 2.0 标准错误：\n\n| code | 说明 |\n|------|------|\n| `-32700` | 无效的 JSON 格式 |\n| `-32600` | 无效的请求（如缺少必要字段） |\n| `-32601` | 方法不存在 |\n| `-32602` | 无效的方法参数 |\n| `-32603` | 内部错误 |\n\n## Wire 消息类型\n\nWire 消息通过 `event` 和 `request` 方法传递，格式为 `{\"type\": \"...\", \"payload\": {...}}`。以下使用 TypeScript 风格的类型定义描述所有消息类型。\n\n```typescript\n/** 所有 Wire 消息的联合类型 */\ntype WireMessage = Event | Request\n\n/** 事件：通过 event 方法发送，无需响应 */\ntype Event =\n  | TurnBegin\n  | TurnEnd\n  | StepBegin\n  | StepInterrupted\n  | CompactionBegin\n  | CompactionEnd\n  | StatusUpdate\n  | ContentPart\n  | ToolCall\n  | ToolCallPart\n  | ToolResult\n  | ApprovalResponse\n  | SubagentEvent\n  | SteerInput\n\n/** 请求：通过 request 方法发送，需要响应 */\ntype Request = ApprovalRequest | ToolCallRequest | QuestionRequest\n```\n\n### `TurnBegin`\n\n轮次开始。\n\n```typescript\ninterface TurnBegin {\n  /** 用户输入，可以是纯文本或内容片段数组 */\n  user_input: string | ContentPart[]\n}\n```\n\n### `TurnEnd`\n\n::: info 新增\n新增于 Wire 1.2。\n:::\n\n轮次结束。此事件在轮次的所有其他事件之后发送。如果轮次被中断，此事件可能不会发送。\n\n```typescript\ninterface TurnEnd {\n  // 无额外字段\n}\n```\n\n### `StepBegin`\n\n步骤开始。\n\n```typescript\ninterface StepBegin {\n  /** 步骤编号，从 1 开始 */\n  n: number\n}\n```\n\n### `StepInterrupted`\n\n步骤被中断，无额外字段。\n\n### `CompactionBegin`\n\n上下文压缩开始，无额外字段。\n\n### `CompactionEnd`\n\n上下文压缩结束，无额外字段。\n\n### `StatusUpdate`\n\n状态更新。\n\n```typescript\ninterface StatusUpdate {\n  /** 上下文使用率，0-1 之间的浮点数，JSON 中可能不存在 */\n  context_usage?: number | null\n  /** 当前上下文中的 token 数量，JSON 中可能不存在 */\n  context_tokens?: number | null\n  /** 上下文可容纳的最大 token 数量，JSON 中可能不存在 */\n  max_context_tokens?: number | null\n  /** 当前步骤的 token 用量统计，JSON 中可能不存在 */\n  token_usage?: TokenUsage | null\n  /** 当前步骤的消息 ID，JSON 中可能不存在 */\n  message_id?: string | null\n  /** Plan 模式是否激活，null 表示状态未变更，JSON 中可能不存在 */\n  plan_mode?: boolean | null\n}\n\ninterface TokenUsage {\n  /** 不包括 input_cache_read 和 input_cache_creation 的输入 token 数 */\n  input_other: number\n  /** 总输出 token 数 */\n  output: number\n  /** 缓存的输入 token 数 */\n  input_cache_read: number\n  /** 用于缓存创建的输入 token 数，目前仅 Anthropic API 支持此字段 */\n  input_cache_creation: number\n}\n```\n\n### `ContentPart`\n\n消息内容片段。序列化时 `type` 为 `\"ContentPart\"`，具体类型由 `payload.type` 区分。\n\n```typescript\ntype ContentPart =\n  | TextPart\n  | ThinkPart\n  | ImageURLPart\n  | AudioURLPart\n  | VideoURLPart\n\ninterface TextPart {\n  type: \"text\"\n  /** 文本内容 */\n  text: string\n}\n\ninterface ThinkPart {\n  type: \"think\"\n  /** 思考内容 */\n  think: string\n  /** 加密的思考内容或签名，JSON 中可能不存在 */\n  encrypted?: string | null\n}\n\ninterface ImageURLPart {\n  type: \"image_url\"\n  image_url: {\n    /** 图片 URL，可以是 data URI（如 data:image/png;base64,...） */\n    url: string\n    /** 图片 ID，用于区分不同图片，JSON 中可能不存在 */\n    id?: string | null\n  }\n}\n\ninterface AudioURLPart {\n  type: \"audio_url\"\n  audio_url: {\n    /** 音频 URL，可以是 data URI（如 data:audio/aac;base64,...） */\n    url: string\n    /** 音频 ID，用于区分不同音频，JSON 中可能不存在 */\n    id?: string | null\n  }\n}\n\ninterface VideoURLPart {\n  type: \"video_url\"\n  video_url: {\n    /** 视频 URL，可以是 data URI（如 data:video/mp4;base64,...） */\n    url: string\n    /** 视频 ID，用于区分不同视频，JSON 中可能不存在 */\n    id?: string | null\n  }\n}\n```\n\n### `ToolCall`\n\n工具调用。\n\n```typescript\ninterface ToolCall {\n  /** 固定为 \"function\" */\n  type: \"function\"\n  /** 工具调用 ID */\n  id: string\n  function: {\n    /** 工具名称 */\n    name: string\n    /** JSON 格式的参数字符串，JSON 中可能不存在 */\n    arguments?: string | null\n  }\n  /** 额外信息，JSON 中可能不存在 */\n  extras?: object | null\n}\n```\n\n### `ToolCallPart`\n\n工具调用参数片段（流式）。\n\n```typescript\ninterface ToolCallPart {\n  /** 参数片段，用于流式传输工具调用参数，JSON 中可能不存在 */\n  arguments_part?: string | null\n}\n```\n\n### `ToolResult`\n\n工具执行结果。\n\n```typescript\ninterface ToolResult {\n  /** 对应的工具调用 ID */\n  tool_call_id: string\n  return_value: ToolReturnValue\n}\n\ninterface ToolReturnValue {\n  /** 是否为错误 */\n  is_error: boolean\n  /** 返回给模型的输出内容 */\n  output: string | ContentPart[]\n  /** 给模型的解释性消息 */\n  message: string\n  /** 显示给用户的内容块 */\n  display: DisplayBlock[]\n  /** 额外调试信息，JSON 中可能不存在 */\n  extras?: object | null\n}\n```\n\n### `ApprovalResponse`\n\n::: info 变更\n重命名于 Wire 1.1。原名 `ApprovalRequestResolved`，旧名称仍可使用以保持向后兼容。\n:::\n\n审批响应事件，表示审批请求已完成。\n\n```typescript\ninterface ApprovalResponse {\n  /** 审批请求 ID */\n  request_id: string\n  /** 审批结果 */\n  response: \"approve\" | \"approve_for_session\" | \"reject\"\n}\n```\n\n### `SubagentEvent`\n\n子 Agent 事件。\n\n```typescript\ninterface SubagentEvent {\n  /** 关联的 Task 工具调用 ID */\n  task_tool_call_id: string\n  /** 子 Agent 产生的事件，嵌套的 Wire 消息格式 */\n  event: { type: string; payload: object }\n}\n```\n\n### `SteerInput`\n\n::: info 新增\n新增于 Wire 1.5。\n:::\n\n表示用户在当前运行中的轮次追加了后续输入。此事件在当前步骤完成且输入被追加到上下文之后、下一步骤开始之前发出。\n\n```typescript\ninterface SteerInput {\n  /** 用户输入，可以是纯文本或内容片段数组 */\n  user_input: string | ContentPart[]\n}\n```\n\n### `ApprovalRequest`\n\n审批请求，通过 `request` 方法发送，Client 必须响应后 Agent 才能继续。\n\n```typescript\ninterface ApprovalRequest {\n  /** 请求 ID，用于响应时引用 */\n  id: string\n  /** 关联的工具调用 ID */\n  tool_call_id: string\n  /** 发起者（工具名称） */\n  sender: string\n  /** 操作描述 */\n  action: string\n  /** 详细说明 */\n  description: string\n  /** 显示给用户的内容块，JSON 中可能不存在，默认为 [] */\n  display?: DisplayBlock[]\n}\n```\n\n**响应格式**\n\nClient 需要返回 `ApprovalResponse` 作为响应结果：\n\n```typescript\ninterface ApprovalResponse {\n  request_id: string\n  response: \"approve\" | \"approve_for_session\" | \"reject\"\n}\n```\n\n| response | 说明 |\n|----------|------|\n| `approve` | 批准本次操作 |\n| `approve_for_session` | 批准本会话中的同类操作 |\n| `reject` | 拒绝操作 |\n\n### `ToolCallRequest`\n\n外部工具调用请求，通过 `request` 方法发送。当 Agent 调用 `initialize` 时注册的外部工具时，会发送此请求。Client 必须执行工具并返回 `ToolResult`。\n\n```typescript\ninterface ToolCallRequest {\n  /** 工具调用 ID */\n  id: string\n  /** 工具名称 */\n  name: string\n  /** JSON 格式的参数字符串，JSON 中可能不存在 */\n  arguments?: string | null\n}\n```\n\n**响应格式**\n\nClient 需要返回 `ToolResult` 作为响应结果：\n\n```typescript\ninterface ToolResult {\n  tool_call_id: string\n  return_value: ToolReturnValue\n}\n```\n\n### `QuestionRequest`\n\n::: info 新增\n新增于 Wire 1.4。\n:::\n\n结构化问答请求，通过 `request` 方法发送。当 Agent 使用 `AskUserQuestion` 工具时，会发送此请求。Client 必须响应后 Agent 才能继续执行。\n\n此功能需要能力协商：Client 在 `initialize` 时通过 `capabilities.supports_question: true` 声明支持后，Agent 才会发送 `QuestionRequest`。如果 Client 未声明支持，`AskUserQuestion` 工具会从 LLM 的工具列表中自动隐藏，避免 LLM 调用不受支持的交互。\n\n```typescript\ninterface QuestionRequest {\n  /** 请求 ID，用于响应时引用 */\n  id: string\n  /** 关联的工具调用 ID */\n  tool_call_id: string\n  /** 问题列表（1–4 个问题） */\n  questions: QuestionItem[]\n}\n\ninterface QuestionItem {\n  /** 问题文本 */\n  question: string\n  /** 短标签，最多 12 个字符 */\n  header?: string\n  /** 可选项（2–4 个） */\n  options: QuestionOption[]\n  /** 是否允许多选 */\n  multi_select?: boolean\n}\n\ninterface QuestionOption {\n  /** 选项标签 */\n  label: string\n  /** 选项说明 */\n  description?: string\n}\n```\n\n**请求示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"request\", \"id\": \"b1a2c3d4-e5f6-7890-abcd-ef1234567890\", \"params\": {\"type\": \"QuestionRequest\", \"payload\": {\"id\": \"q-1\", \"tool_call_id\": \"tc-1\", \"questions\": [{\"question\": \"Which language should I use?\", \"header\": \"Lang\", \"options\": [{\"label\": \"Python\", \"description\": \"Widely used, large ecosystem\"}, {\"label\": \"Rust\", \"description\": \"High performance, memory safe\"}], \"multi_select\": false}]}}}\n```\n\n**响应格式**\n\nClient 需要返回 `QuestionResponse` 作为响应结果：\n\n```typescript\ninterface QuestionResponse {\n  /** 对应的请求 ID */\n  request_id: string\n  /** 答案映射，键为问题文本，值为选中的选项标签（多选时用逗号分隔） */\n  answers: Record<string, string>\n}\n```\n\n**响应示例**\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"b1a2c3d4-e5f6-7890-abcd-ef1234567890\", \"result\": {\"request_id\": \"q-1\", \"answers\": {\"Which language should I use?\": \"Python\"}}}\n```\n\n如果 Client 不支持结构化问答或用户关闭了问题面板，可以返回空的 `answers`：\n\n```json\n{\"jsonrpc\": \"2.0\", \"id\": \"b1a2c3d4-e5f6-7890-abcd-ef1234567890\", \"result\": {\"request_id\": \"q-1\", \"answers\": {}}}\n```\n\n### `DisplayBlock`\n\n`ToolResult` 和 `ApprovalRequest` 的 `display` 字段使用的显示块类型。\n\n```typescript\ntype DisplayBlock =\n  | UnknownDisplayBlock\n  | BriefDisplayBlock\n  | DiffDisplayBlock\n  | TodoDisplayBlock\n  | ShellDisplayBlock\n\n/** 无法识别的显示块类型的 fallback */\ninterface UnknownDisplayBlock {\n  /** 任意类型标识 */\n  type: string\n  /** 原始数据 */\n  data: object\n}\n\ninterface BriefDisplayBlock {\n  type: \"brief\"\n  /** 简短的文本内容 */\n  text: string\n}\n\ninterface DiffDisplayBlock {\n  type: \"diff\"\n  /** 文件路径 */\n  path: string\n  /** 原始内容 */\n  old_text: string\n  /** 新内容 */\n  new_text: string\n}\n\ninterface TodoDisplayBlock {\n  type: \"todo\"\n  /** 待办事项列表 */\n  items: TodoDisplayItem[]\n}\n\ninterface TodoDisplayItem {\n  /** 待办事项标题 */\n  title: string\n  /** 状态 */\n  status: \"pending\" | \"in_progress\" | \"done\"\n}\n\ninterface ShellDisplayBlock {\n  type: \"shell\"\n  /** 语法高亮的语言标识（如 \"sh\"、\"powershell\"） */\n  language: string\n  /** Shell 命令内容 */\n  command: string\n}\n```\n\n## Kimi Agent（Rust）Wire Server\n\n::: warning 注意\nKimi Agent 目前为实验性功能，API 和行为可能在后续版本中发生变化。\n:::\n\nKimi Agent (Rust) 是 Kimi Code CLI 内核的 Rust 实现，专为 Wire 模式设计。如果你只需要 Wire 协议服务，Kimi Agent (Rust) 提供了一个更轻量的选择。Rust 实现位于 [`MoonshotAI/kimi-agent-rs`](https://github.com/MoonshotAI/kimi-agent-rs)。\n\n### 特点\n\n- **Wire 协议完全兼容**：与 Python 版 `kimi --wire` 使用相同的 Wire 协议，现有客户端无需修改\n- **更小的体积**：单一静态链接二进制，无需 Python 运行时\n- **更快的启动**：原生编译，启动速度更快\n- **相同的配置**：使用相同的配置文件（`~/.kimi/config.toml`）和会话目录\n\n### 限制\n\n- **仅支持 Wire 模式**：没有 Shell/Print/ACP UI\n- **仅支持 Kimi 供应商**：不支持 OpenAI、Anthropic 等其他供应商\n- **无 Kimi 账号登录功能**：没有 `login`/`logout` 子命令和 `/login`、`/logout` 斜杠命令，需要手动配置 API 密钥\n- **不支持 `--prompt`/`--command`**：Wire 服务器不接受初始提示词\n- **仅支持本地执行**：没有 SSH Kaos 支持\n- **MCP OAuth 存储位置不同**：Kimi Agent 存储在 `~/.kimi/credentials/mcp_auth.json`，Python 版存储在 `~/.fastmcp/oauth-mcp-client-cache/`，两者不兼容\n\n### 安装\n\n从 [GitHub Releases](https://github.com/MoonshotAI/kimi-agent-rs/releases) 下载预编译的二进制文件：\n\n```sh\n# macOS (Apple Silicon)\ncurl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-aarch64-apple-darwin.tar.gz | tar xz\nsudo mv kimi-agent /usr/local/bin/\n\n# Linux (x86_64)\ncurl -L https://github.com/MoonshotAI/kimi-agent-rs/releases/latest/download/kimi-agent-x86_64-unknown-linux-gnu.tar.gz | tar xz\nsudo mv kimi-agent /usr/local/bin/\n```\n\n### 使用\n\nKimi Agent 默认运行 Wire 模式：\n\n```sh\nkimi-agent\n```\n\n常用选项与 `kimi` 命令相同：\n\n```sh\n# 指定工作目录\nkimi-agent --work-dir /path/to/project\n\n# 继续上一个会话\nkimi-agent --continue\n\n# 使用指定会话\nkimi-agent --session <session-id>\n\n# 使用指定模型\nkimi-agent --model k2\n\n# YOLO 模式（跳过审批）\nkimi-agent --yolo\n```\n\n子命令：\n\n```sh\n# 显示版本和环境信息\nkimi-agent info\n\n# 管理 MCP 服务器\nkimi-agent mcp list\nkimi-agent mcp add <name> <command> [args...]\nkimi-agent mcp remove <name>\n```\n\n### 版本同步\n\nKimi Agent 与 Kimi Code CLI 独立发版。兼容性与同步状态以 `MoonshotAI/kimi-agent-rs` 的发布说明为准。\n"
  },
  {
    "path": "docs/zh/faq.md",
    "content": "# 常见问题\n\n## 安装与鉴权\n\n### `/login` 时模型列表为空\n\n如果在运行 `/login`（或 `/setup`）命令时看到 \"No models available for the selected platform\" 错误，可能是以下原因：\n\n- **API 密钥无效或过期**：检查你输入的 API 密钥是否正确，以及是否仍有效。\n- **网络连接问题**：确认能正常访问 API 服务地址（如 `api.kimi.com` 或 `api.moonshot.cn`）。\n\n### API 密钥无效\n\nAPI 密钥无效可能的原因：\n\n- **密钥输入错误**：检查是否有多余的空格或遗漏的字符。\n- **密钥已过期或被撤销**：在平台控制台确认密钥状态。\n- **环境变量覆盖**：检查是否有 `KIMI_API_KEY` 或 `OPENAI_API_KEY` 环境变量覆盖了配置文件中的密钥。可以运行 `echo $KIMI_API_KEY` 检查。\n\n### 会员过期或配额用尽\n\n如果你使用 Kimi Code 平台，可以通过 `/usage` 命令查看当前的配额和会员状态。如果配额用尽或会员过期，需要在 [Kimi Code](https://kimi.com/coding) 续费或升级。\n\n## 交互问题\n\n### Shell 模式中 `cd` 命令无效\n\n在 Shell 模式中执行 `cd` 命令不会改变 Kimi Code CLI 的工作目录。这是因为每次 Shell 命令在独立的子进程中执行，目录切换只在该进程内生效。\n\n如果需要切换工作目录：\n\n- **退出并重新启动**：在目标目录中重新运行 `kimi` 命令。\n- **使用 `--work-dir` 参数**：启动时指定工作目录，如 `kimi --work-dir /path/to/project`。\n- **在命令中使用绝对路径**：直接使用绝对路径执行命令，如 `ls /path/to/dir`。\n\n### 粘贴图片失败\n\n使用 `Ctrl-V` 粘贴图片时，如果提示 \"Current model does not support image input\"，说明当前模型不支持图片输入。\n\n解决方法：\n\n- **切换到支持图片的模型**：使用支持 `image_in` 能力的模型。\n- **检查剪贴板内容**：确保剪贴板中确实有图片数据，而非图片文件的路径。\n\n## ACP 问题\n\n### IDE 无法连接到 Kimi Code CLI\n\n如果 IDE（如 Zed 或 JetBrains IDE）无法连接到 Kimi Code CLI，请检查以下几点：\n\n- **确认 Kimi Code CLI 已安装**：运行 `kimi --version` 确认安装成功。\n- **检查配置路径**：确保 IDE 配置中的 Kimi Code CLI 路径正确。通常可以使用 `kimi acp` 作为命令。\n- **检查 uv 路径**：如果使用 uv 安装，确保 `~/.local/bin` 在 PATH 中。可以使用绝对路径，如 `/Users/yourname/.local/bin/kimi acp`。\n- **查看日志**：检查 `~/.kimi/logs/kimi.log` 中的错误信息。\n\n## MCP 问题\n\n### MCP 服务启动失败\n\n添加 MCP 服务器后，如果工具未加载或报错，可能是以下原因：\n\n- **命令不存在**：对于 stdio 类型的服务器，确保命令（如 `npx`）在 PATH 中。可以使用绝对路径配置。\n- **配置格式错误**：检查 `~/.kimi/mcp.json` 是否为有效的 JSON 格式。运行 `kimi mcp list` 查看当前配置。\n\n调试步骤：\n\n```sh\n# 查看已配置的服务器\nkimi mcp list\n\n# 测试服务器是否正常\nkimi mcp test <server-name>\n```\n\n### OAuth 授权失败\n\n对于需要 OAuth 授权的 MCP 服务器（如 Linear），如果授权失败：\n\n- **检查网络连接**：确保能访问授权服务器。\n- **重新授权**：运行 `kimi mcp auth <server-name>` 重新进行授权。\n- **重置授权**：如果授权信息损坏，可以运行 `kimi mcp reset-auth <server-name>` 清除后重试。\n\n### Header 格式错误\n\n添加 HTTP 类型的 MCP 服务器时，Header 格式应为 `KEY: VALUE`（冒号后有空格）。例如：\n\n```sh\n# 正确\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \"CONTEXT7_API_KEY: your-key\"\n\n# 错误（缺少空格或使用等号）\nkimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \"CONTEXT7_API_KEY=your-key\"\n```\n\n## Print/Wire 模式问题\n\n### JSONL 输入格式无效\n\n使用 `--input-format stream-json` 时，输入必须是有效的 JSONL（每行一个 JSON 对象）。常见问题：\n\n- **JSON 格式错误**：确保每行是完整的 JSON 对象，没有语法错误。\n- **编码问题**：确保输入使用 UTF-8 编码。\n- **换行符问题**：Windows 用户注意检查换行符是否为 `\\n` 而非 `\\r\\n`。\n\n正确的输入格式示例：\n\n```json\n{\"role\": \"user\", \"content\": \"你好\"}\n```\n\n### Print 模式无输出\n\n如果 `--print` 模式下没有输出，可能是：\n\n- **未提供输入**：需要通过 `--prompt`（或 `--command`）或 stdin 提供输入。例如：`kimi --print --prompt \"你好\"`。\n- **输出被缓冲**：尝试使用 `--output-format stream-json` 获取流式输出。\n- **配置未完成**：确保已通过 `/login` 配置 API 密钥和模型。\n\n## 更新与升级\n\n### macOS 首次运行缓慢\n\nmacOS 的 Gatekeeper 安全机制会在首次运行新程序时进行检查，导致启动变慢。解决方法：\n\n- **等待检查完成**：首次运行时耐心等待，后续启动会恢复正常。\n- **添加到开发者工具**：在「系统设置 → 隐私与安全性 → 开发者工具」中添加你的终端应用。\n\n### 如何升级 Kimi Code CLI\n\n使用 uv 升级到最新版本：\n\n```sh\nuv tool upgrade kimi-cli --no-cache\n```\n\n添加 `--no-cache` 参数可以确保获取最新版本。\n\n### 如何禁用自动更新检查\n\n如果不希望 Kimi Code CLI 在后台检查更新，可以设置环境变量：\n\n```sh\nexport KIMI_CLI_NO_AUTO_UPDATE=1\n```\n\n可以将此行添加到你的 shell 配置文件（如 `~/.zshrc` 或 `~/.bashrc`）中。\n"
  },
  {
    "path": "docs/zh/guides/getting-started.md",
    "content": "# 开始使用\n\n## Kimi Code CLI 是什么\n\nKimi Code CLI 是一个运行在终端中的 AI Agent，帮助你完成软件开发任务和终端操作。它可以阅读和编辑代码、执行 Shell 命令、搜索和抓取网页，并在执行过程中自主规划和调整行动。\n\nKimi Code CLI 适合以下场景：\n\n- **编写和修改代码**：实现新功能、修复 bug、重构代码\n- **理解项目**：探索陌生的代码库，解答架构和实现问题\n- **自动化任务**：批量处理文件、执行构建和测试、运行脚本\n\nKimi Code CLI 支持以下几种使用方式：\n\n- **[交互式命令行（`kimi`）](../reference/kimi-command.md)**：在终端中以 Shell 方式与 AI 对话，支持自然语言描述任务或直接执行 Shell 命令\n- **[浏览器界面（`kimi web`）](../reference/kimi-web.md)**：在本地浏览器中打开图形界面，支持会话管理、文件引用、代码高亮等\n- **[Agent 集成（`kimi acp`）](../reference/kimi-acp.md)**：以服务方式运行，通过 [Agent Client Protocol] 集成到 [IDE](./ides.md) 和其他本地 Agent 客户端中\n\n::: info 提示\n如果你遇到问题或有建议，欢迎在 [GitHub Issues](https://github.com/MoonshotAI/kimi-cli/issues) 反馈。\n:::\n\n[Agent Client Protocol]: https://agentclientprotocol.com/\n\n## 安装\n\n运行安装脚本即可完成安装。脚本会先安装 [uv](https://docs.astral.sh/uv/)（Python 包管理工具），再通过 uv 安装 Kimi Code CLI：\n\n```sh\n# Linux / macOS\ncurl -LsSf https://code.kimi.com/install.sh | bash\n```\n\n```powershell\n# Windows (PowerShell)\nInvoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression\n```\n\n验证安装是否成功：\n\n```sh\nkimi --version\n```\n\n::: tip 提示\n由于 macOS 的安全检查机制，首次运行 `kimi` 命令可能需要较长时间。可以在「系统设置 → 隐私与安全性 → 开发者工具」中添加你的终端应用来加速后续启动。\n:::\n\n如果你已经安装了 uv，也可以直接运行：\n\n```sh\nuv tool install --python 3.13 kimi-cli\n```\n\n::: tip 提示\nKimi Code CLI 支持 Python 3.12-3.14，但建议使用 3.13 以获得最佳兼容性。\n:::\n\n## 升级与卸载\n\n升级到最新版本：\n\n```sh\nuv tool upgrade kimi-cli --no-cache\n```\n\n卸载 Kimi Code CLI：\n\n```sh\nuv tool uninstall kimi-cli\n```\n\n## 第一次运行\n\n在你想要工作的项目目录中运行 `kimi` 命令启动 Kimi Code CLI：\n\n```sh\ncd your-project\nkimi\n```\n\n首次启动时，你需要配置 API 来源。输入 `/login` 命令开始配置：\n\n```\n/login\n```\n\n执行后首先选择平台。推荐选择 **Kimi Code**，会自动打开浏览器进行 OAuth 授权；选择其他平台则需要输入 API 密钥。配置完成后 Kimi Code CLI 会自动保存设置并重新加载。详见 [平台与模型](../configuration/providers.md)。\n\n现在你可以直接用自然语言和 Kimi Code CLI 对话了。试着描述你想完成的任务，比如：\n\n```\n帮我看一下这个项目的目录结构\n```\n\n::: tip 提示\n如果项目中没有 `AGENTS.md` 文件，可以运行 `/init` 命令让 Kimi Code CLI 分析项目并生成该文件，帮助 AI 更好地理解项目结构和规范。\n:::\n\n输入 `/help` 可以查看所有可用的 [斜杠命令](../reference/slash-commands.md) 和使用提示。\n"
  },
  {
    "path": "docs/zh/guides/ides.md",
    "content": "# 在 IDE 中使用\n\nKimi Code CLI 支持通过 [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) 集成到 IDE 中，让你在编辑器内直接使用 AI 辅助编程。\n\n## 前置准备\n\n在配置 IDE 之前，请确保已安装 Kimi Code CLI 并完成 `/login` 配置。\n\n## 在 Zed 中使用\n\n[Zed](https://zed.dev/) 是一个支持 ACP 的现代 IDE。\n\n在 Zed 的配置文件 `~/.config/zed/settings.json` 中添加：\n\n```json\n{\n  \"agent_servers\": {\n    \"Kimi Code CLI\": {\n      \"type\": \"custom\",\n      \"command\": \"kimi\",\n      \"args\": [\"acp\"],\n      \"env\": {}\n    }\n  }\n}\n```\n\n配置说明：\n\n- `type`：固定值 `\"custom\"`\n- `command`：Kimi Code CLI 的命令路径，如果 `kimi` 不在 PATH 中，需要使用完整路径\n- `args`：启动参数，`acp` 启用 ACP 模式\n- `env`：环境变量，通常留空即可\n\n保存配置后，在 Zed 的 Agent 面板中就可以创建 Kimi Code CLI 会话了。\n\n## 在 JetBrains IDE 中使用\n\nJetBrains 系列 IDE（IntelliJ IDEA、PyCharm、WebStorm 等）通过 AI 聊天插件支持 ACP。\n\n如果你没有 JetBrains AI 订阅，可以在注册表中启用 `llm.enable.mock.response` 来使用 AI 聊天功能。连按两次 Shift 搜索 \"注册表\" 即可打开。\n\n在 AI 聊天面板的菜单中点击 \"Configure ACP agents\"，添加以下配置：\n\n```json\n{\n  \"agent_servers\": {\n    \"Kimi Code CLI\": {\n      \"command\": \"~/.local/bin/kimi\",\n      \"args\": [\"acp\"],\n      \"env\": {}\n    }\n  }\n}\n```\n\n`command` 需要使用完整路径，可以在终端中运行 `which kimi` 获取。保存后，在 AI 聊天的 Agent 选择器中就可以选择 Kimi Code CLI 了。\n"
  },
  {
    "path": "docs/zh/guides/integrations.md",
    "content": "# 集成到工具\n\n除了在终端和 IDE 中使用，Kimi Code CLI 还可以集成到其他工具中。\n\n## Zsh 插件\n\n[zsh-kimi-cli](https://github.com/MoonshotAI/zsh-kimi-cli) 是一个 Zsh 插件，让你可以在 Zsh 中快速切换到 Kimi Code CLI。\n\n**安装**\n\n如果你使用 Oh My Zsh，可以这样安装：\n\n```sh\ngit clone https://github.com/MoonshotAI/zsh-kimi-cli.git \\\n  ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/kimi-cli\n```\n\n然后在 `~/.zshrc` 中添加插件：\n\n```sh\nplugins=(... kimi-cli)\n```\n\n重新加载 Zsh 配置：\n\n```sh\nsource ~/.zshrc\n```\n\n**使用**\n\n安装后，在 Zsh 中按 `Ctrl-X` 可以快速切换到 Kimi Code CLI，无需手动输入 `kimi` 命令。\n\n::: tip 提示\n如果你使用其他 Zsh 插件管理器（如 zinit、zplug 等），请参考 [zsh-kimi-cli 仓库](https://github.com/MoonshotAI/zsh-kimi-cli) 的 README 了解安装方法。\n:::\n"
  },
  {
    "path": "docs/zh/guides/interaction.md",
    "content": "# 交互与输入\n\nKimi Code CLI 提供了丰富的交互功能，帮助你高效地与 AI 协作。\n\n## Agent 与 Shell 模式\n\nKimi Code CLI 有两种输入模式：\n\n- **Agent 模式**：默认模式，输入的内容会发送给 AI 处理\n- **Shell 模式**：直接执行 Shell 命令，无需离开 Kimi Code CLI\n\n按 `Ctrl-X` 可以在两种模式之间切换。当前模式会显示在底部状态栏中。\n\n在 Shell 模式下，你可以像在普通终端中一样执行命令：\n\n```sh\n$ ls -la\n$ git status\n$ npm run build\n```\n\nShell 模式也支持部分斜杠命令，包括 `/help`、`/exit`、`/version`、`/editor`、`/changelog`、`/feedback`、`/export`、`/import` 和 `/task`。\n\n::: warning 注意\nShell 模式中每个命令独立执行，`cd`、`export` 等改变环境的命令不会影响后续命令。\n:::\n\n## Plan 模式\n\nPlan 模式是一种只读的规划模式，让 AI 在动手编码之前先制定实施方案，避免在错误方向上浪费精力。\n\n在 Plan 模式下，AI 只能使用只读工具（`Glob`、`Grep`、`ReadFile`）探索代码库，不能修改任何文件或执行命令。AI 会将方案写入一个专门的 plan 文件，然后提交给你审批。你可以选择批准、拒绝或提供修改意见。\n\n### 进入 Plan 模式\n\n有三种方式进入 Plan 模式：\n\n- **快捷键**：按 `Shift-Tab` 切换 Plan 模式的开关\n- **斜杠命令**：输入 `/plan` 或 `/plan on`\n- **AI 主动触发**：面对复杂任务时，AI 可能会通过 `EnterPlanMode` 工具请求进入 Plan 模式，你可以选择同意或拒绝\n\n进入 Plan 模式后，提示符会变为 `📋`，底部状态栏会显示蓝色的 `plan` 标识。\n\n### 审批方案\n\nAI 完成方案后会通过 `ExitPlanMode` 提交审批。审批面板会显示完整的方案内容，你可以：\n\n- **批准执行**：如果方案包含多个可选实施路径，AI 会列出 2–3 个带标签的选项（如 \"方案 A\"、\"方案 B (Recommended)\"）供你选择，选中后 AI 退出 Plan 模式并按该路径执行；如果方案只有一条路径，则显示 **Approve** 按钮\n- **Reject**：拒绝方案，保持 Plan 模式，你可以在对话中提供反馈\n- **Revise**：输入修改意见，AI 会据此修订方案并重新提交\n\n按 `Ctrl-E` 可以在全屏分页器中查看完整方案内容。\n\n### 管理 Plan 模式\n\n使用 `/plan` 命令可以管理 Plan 模式：\n\n- `/plan`：切换 Plan 模式开关\n- `/plan on`：开启 Plan 模式\n- `/plan off`：关闭 Plan 模式\n- `/plan view`：查看当前方案内容\n- `/plan clear`：清除当前方案文件\n\n## Thinking 模式\n\nThinking 模式让 AI 在回答前进行更深入的思考，适合处理复杂问题。\n\n你可以通过 `/model` 命令切换模型和 Thinking 模式。在选择模型后，如果模型支持 Thinking 模式，系统会询问是否开启。也可以在启动时通过 `--thinking` 参数启用：\n\n```sh\nkimi --thinking\n```\n\n::: tip 提示\nThinking 模式需要当前模型支持。部分模型（如 `kimi-k2-thinking-turbo`）始终使用 Thinking 模式，无法关闭。\n:::\n\n## 运行中发送消息（steer）\n\n当 AI 正在执行任务时，你可以直接在输入框中输入并发送后续消息，无需等待当前轮次结束。这个功能称为 \"引导\"（steer），可以在 AI 运行过程中调整其方向。\n\n发送的引导消息会在当前步骤完成后追加到上下文中，AI 会在下一步骤中看到并响应你的消息。在 AI 运行期间，审批请求和问答面板也支持内联键盘交互。\n\n::: tip 提示\n引导消息不会中断 AI 当前正在执行的步骤，而是在步骤间被处理。如果需要立即中断，请使用 `Ctrl-C`。\n:::\n\n## 后台任务\n\n当 AI 需要执行耗时较长的命令（如构建项目、运行测试套件、启动开发服务器）时，可以将命令作为后台任务启动。后台任务在独立进程中运行，AI 可以继续处理其他请求，无需等待命令完成。\n\n后台任务的工作流程：\n\n1. AI 使用 `Shell` 工具的 `run_in_background=true` 参数启动命令\n2. 工具立即返回任务 ID，AI 继续处理其他工作\n3. 任务完成后，系统自动通知 AI，AI 会告知你执行结果\n\n你可以使用 `/task` 斜杠命令打开交互式任务浏览器，实时查看所有后台任务的状态和输出。详见 [斜杠命令参考](../reference/slash-commands.md#task)。\n\n::: tip 提示\n默认最多同时运行 4 个后台任务，可在配置文件的 `[background]` 节中调整。CLI 退出时默认会终止所有后台任务。详见 [配置文件](../configuration/config-files.md#background)。\n:::\n\n## 多行输入\n\n有时你需要输入多行内容，比如贴入一段代码或错误日志。按 `Ctrl-J` 或 `Alt-Enter` 可以插入换行，而不是直接发送消息。\n\n输入完成后，按 `Enter` 发送整条消息。\n\n## 剪贴板与媒体粘贴\n\n按 `Ctrl-V` 可以粘贴剪贴板中的文本、图片或视频文件。\n\n在 Agent 模式下，较长的粘贴文本（超过 1000 字符或 15 行）会自动折叠为 `[Pasted text #n]` 占位符显示在输入框中，保持界面整洁。完整内容仍会在发送时展开并传递给模型。使用外部编辑器（`Ctrl-O`）时，占位符会自动展开为原始文本，保存后未修改的部分重新折叠。\n\n如果剪贴板中是图片，Kimi Code CLI 会将图片缓存到磁盘并在输入框中显示为 `[image:…]` 占位符。发送消息后，AI 可以看到并分析这张图片。如果剪贴板中是视频文件，其文件路径会以文本形式插入输入框。\n\n::: tip 提示\n图片输入需要当前模型支持 `image_in` 能力，视频输入需要支持 `video_in` 能力。\n:::\n\n## 斜杠命令\n\n斜杠命令是以 `/` 开头的特殊指令，用于执行 Kimi Code CLI 的内置功能，如 `/help`、`/login`、`/sessions` 等。输入 `/` 后会自动显示可用命令列表。完整的斜杠命令列表请参考 [斜杠命令参考](../reference/slash-commands.md)。\n\n## @ 路径补全\n\n在消息中输入 `@` 后，Kimi Code CLI 会自动补全工作目录中的文件和目录路径。这让你可以方便地引用项目中的文件：\n\n```\n帮我看一下 @src/components/Button.tsx 这个文件有没有问题\n```\n\n输入 `@` 后开始输入文件名，会显示匹配的补全项。按 `Tab` 或 `Enter` 选择补全项。\n\n## 结构化问答\n\n在执行过程中，AI 可能需要你做出选择来决定下一步方向。此时 AI 会使用 `AskUserQuestion` 工具向你展示结构化的问题和选项。\n\n问题面板会显示问题描述和可选项，你可以通过键盘选择：\n\n- 使用方向键（上 / 下）浏览选项\n- 按 `Enter` 确认选择\n- 按 `Space` 切换多选模式下的选中状态\n- 选择 \"Other\" 选项可以输入自定义文本\n- 按 `Esc` 跳过问题\n\n每个问题支持 2–4 个预定义选项，AI 会根据当前任务上下文设置合适的选项和说明。如果有多个问题需要回答，面板会以标签页形式展示，使用左右方向键或 `Tab` 键在问题间切换，已回答的问题会标记为已完成状态，切换回已回答的问题时会恢复之前的选择。\n\n::: tip 提示\nAI 只会在你的选择真正影响后续操作时才使用此工具。对于能从上下文推断的决策，AI 会自行判断并继续执行。\n:::\n\n## 审批与确认\n\n当 AI 需要执行可能有影响的操作（如修改文件、运行命令）时，Kimi Code CLI 会请求你的确认。\n\n确认提示会显示操作的详情，包括 Shell 命令和文件 Diff 预览。如果内容较长被截断，可以按 `Ctrl-E` 展开查看完整内容。你可以选择：\n\n- **允许**：执行这次操作\n- **本会话允许**：在当前会话中自动批准同类操作（此决策会随会话持久化，恢复会话时自动还原）\n- **拒绝**：不执行此操作\n\n如果你信任 AI 的操作，或者你正在安全的隔离环境中运行 Kimi Code CLI，可以启用「YOLO 模式」来自动批准所有请求：\n\n```sh\n# 启动时启用\nkimi --yolo\n\n# 或在运行中切换\n/yolo\n```\n\n你也可以在配置文件中设置 `default_yolo = true`，每次启动时默认开启 YOLO 模式。详见 [配置文件](../configuration/config-files.md)。\n\n开启 YOLO 模式后，底部状态栏会显示黄色的 YOLO 标识。再次输入 `/yolo` 可关闭。\n\n::: warning 注意\nYOLO 模式会跳过所有确认，请确保你了解可能的风险。建议仅在可控环境中使用。\n:::\n\n"
  },
  {
    "path": "docs/zh/guides/sessions.md",
    "content": "# 会话与上下文\n\nKimi Code CLI 会自动保存你的对话历史，方便你随时继续之前的工作。\n\n## 会话续接\n\n每次启动 Kimi Code CLI 时，都会创建一个新的会话。在运行过程中，你也可以输入 `/new` 命令随时创建并切换到一个新会话，无需退出程序。\n\n如果你想继续之前的对话，有几种方式：\n\n**继续最近的会话**\n\n使用 `--continue` 参数可以继续当前工作目录下最近的会话：\n\n```sh\nkimi --continue\n```\n\n**切换到指定会话**\n\n使用 `--session` 参数可以切换到指定 ID 的会话：\n\n```sh\nkimi --session abc123\n```\n\n**在运行中切换会话**\n\n输入 `/sessions`（或 `/resume`）可以查看当前工作目录的所有会话列表，使用方向键选择要切换的会话：\n\n```\n/sessions\n```\n\n列表会显示每个会话的标题和最后更新时间，帮助你找到想要继续的对话。\n\n**启动回放**\n\n当你继续一个已有会话时，Kimi Code CLI 会回放之前的对话历史，让你快速了解上下文。回放过程中会显示之前的消息和 AI 的回复。\n\n## 会话状态持久化\n\n除了对话历史，Kimi Code CLI 还会自动保存和恢复会话的运行状态。当你恢复一个会话时，以下状态会自动还原：\n\n- **审批决策**：YOLO 模式的开关状态、通过 \"本会话允许\" 批准过的操作类型\n- **Plan 模式**：Plan 模式的开关状态\n- **动态子 Agent**：通过 `CreateSubagent` 工具在会话中创建的子 Agent 定义\n- **额外目录**：通过 `--add-dir` 或 `/add-dir` 添加的工作区目录\n\n这意味着你不需要在每次恢复会话时重新配置这些设置。例如，如果你在上次会话中批准了某类 Shell 命令的自动执行，恢复会话后这些批准仍然有效。\n\n## 导出与导入\n\nKimi Code CLI 支持将会话上下文导出为文件，或从外部文件和其他会话导入上下文。\n\n**导出会话**\n\n输入 `/export` 可以将当前会话的完整对话历史导出为 Markdown 文件：\n\n```\n/export\n```\n\n导出文件包含会话元数据、对话概览和按轮次组织的完整对话记录。你也可以指定输出路径：\n\n```\n/export ~/exports/my-session.md\n```\n\n**导入上下文**\n\n输入 `/import` 可以从文件或其他会话导入上下文。导入的内容会作为参考信息附加到当前会话中：\n\n```\n/import ./previous-session-export.md\n/import abc12345\n```\n\n支持导入常见的文本格式文件（Markdown、代码、配置文件等）。你也可以传入一个会话 ID，从该会话导入完整的对话历史。\n\n::: tip 提示\n导出文件可能包含敏感信息（如代码片段、文件路径等），分享前请注意检查。\n:::\n\n## 清空与压缩\n\n随着对话的进行，上下文会越来越长。Kimi Code CLI 会在需要的时候自动对上下文进行压缩，确保对话能够继续。\n\n你也可以使用斜杠命令手动管理上下文：\n\n**清空上下文**\n\n输入 `/clear` 可以清空当前会话的所有上下文，重新开始对话：\n\n```\n/clear\n```\n\n清空后，AI 会忘记之前的所有对话内容。通常你不需要使用这个命令，对于新任务，开启新的会话会是更好的选择。\n\n**压缩上下文**\n\n输入 `/compact` 可以让 AI 总结当前的对话，并用总结替换原有的上下文：\n\n```\n/compact\n```\n\n你也可以在命令后附带自定义指引，告诉 AI 在压缩时优先保留哪些内容：\n\n```\n/compact 保留数据库相关的讨论\n```\n\n压缩会保留关键信息，同时减少 token 消耗。这在对话很长但你还想保留一些上下文时很有用。\n\n::: tip 提示\n底部状态栏会显示当前的上下文使用率和 Token 数量（如 `context: 42.0% (4.2k/10.0k)`），帮助你了解何时需要清空或压缩。\n:::\n\n::: tip 提示\n`/clear` 和 `/reset` 会清空对话上下文，但不会重置会话状态（如审批决策、动态子 Agent 和额外目录）。如需完全重新开始，建议创建一个新会话。\n:::\n"
  },
  {
    "path": "docs/zh/guides/use-cases.md",
    "content": "# 常见使用案例\n\nKimi Code CLI 可以帮助你完成多种软件开发和通用任务，以下是一些典型场景。\n\n## 实现新功能\n\n当你需要为项目添加新功能时，直接用自然语言描述需求即可。Kimi Code CLI 会自动阅读相关代码、理解项目结构，然后进行修改。\n\n```\n给用户列表页面添加分页功能，每页显示 20 条记录\n```\n\nKimi Code CLI 通常会按照「读 → 改 → 验证」的流程工作：\n\n1. **阅读**：搜索和阅读相关代码，理解现有实现\n2. **修改**：编写或修改代码，遵循项目的代码风格\n3. **验证**：运行测试或构建，确保修改没有引入问题\n\n如果你对修改不满意，可以直接告诉 Kimi Code CLI 调整方向：\n\n```\n分页组件的样式和项目其他地方不一致，参考 Button 组件的样式\n```\n\n## 修复 bug\n\n描述你遇到的问题，Kimi Code CLI 会帮你定位原因并修复：\n\n```\n用户登录后跳转到首页时，偶尔会显示未登录状态，帮我排查一下\n```\n\n对于有明确错误信息的问题，可以直接贴上错误日志：\n\n```\n运行 npm test 时出现这个错误：\n\nTypeError: Cannot read property 'map' of undefined\n    at UserList.render (src/components/UserList.jsx:15:23)\n\n帮我修复\n```\n\n你也可以让 Kimi Code CLI 运行命令来复现和验证问题：\n\n```\n运行测试，如果有失败的用例就修复它们\n```\n\n## 理解项目\n\nKimi Code CLI 可以帮你探索和理解不熟悉的代码库：\n\n```\n这个项目的整体架构是怎样的？入口文件在哪里？\n```\n\n```\n用户认证的流程是怎么实现的？涉及哪些文件？\n```\n\n```\n解释一下 src/core/scheduler.py 这个文件的作用\n```\n\n如果你在阅读代码时遇到不理解的部分，可以随时提问：\n\n```\nuseCallback 和 useMemo 有什么区别？这里为什么要用 useCallback？\n```\n\n## 自动化小任务\n\nKimi Code CLI 可以执行各种重复性的小任务：\n\n```\n把 src 目录下所有 .js 文件的 var 声明改成 const 或 let\n```\n\n```\n给所有没有 docstring 的公开函数添加文档注释\n```\n\n```\n生成这个 API 模块的单元测试\n```\n\n```\n更新 package.json 中所有依赖到最新版本，然后运行测试确保没有问题\n```\n\n## 自动化通用任务\n\n除了代码相关的任务，Kimi Code CLI 也可以处理一些通用场景。\n\n**调研任务**\n\n```\n帮我调研一下 Python 的异步 Web 框架，比较 FastAPI、Starlette 和 Sanic 的优缺点\n```\n\n**数据分析**\n\n```\n分析 logs 目录下的访问日志，统计每个接口的调用次数和平均响应时间\n```\n\n**批量文件处理**\n\n```\n把 images 目录下的所有 PNG 图片转换为 JPEG 格式，保存到 output 目录\n```\n\n"
  },
  {
    "path": "docs/zh/index.md",
    "content": "---\nlayout: home\nhero:\n  name: Kimi Code CLI\n  text: 你的终端智能助手\n  tagline: 技术预览版\n  actions:\n    - theme: brand\n      text: 开始使用\n      link: /zh/guides/getting-started\n    - theme: alt\n      text: GitHub\n      link: https://github.com/MoonshotAI/kimi-cli\n---\n"
  },
  {
    "path": "docs/zh/reference/keyboard.md",
    "content": "# 键盘快捷键\n\nKimi Code CLI Shell 模式支持以下键盘快捷键。\n\n## 快捷键列表\n\n| 快捷键 | 功能 |\n|--------|------|\n| `Ctrl-X` | 切换 Agent/Shell 模式 |\n| `Shift-Tab` | 切换 Plan 模式（只读研究与规划） |\n| `Ctrl-O` | 在外部编辑器中编辑（`$VISUAL`/`$EDITOR`） |\n| `Ctrl-J` | 插入换行 |\n| `Alt-Enter` | 插入换行（同 `Ctrl-J`） |\n| `Ctrl-V` | 粘贴（支持图片和视频文件） |\n| `Ctrl-E` | 展开审批请求完整内容 |\n| `1`–`3` | 审批面板快速选择 |\n| `1`–`5` | 问题面板按编号选择选项 |\n| `Ctrl-D` | 退出 Kimi Code CLI |\n| `Ctrl-C` | 中断当前操作 |\n\n## 模式切换\n\n### `Ctrl-X`：切换 Agent/Shell 模式\n\n在输入框中按 `Ctrl-X` 可在两种模式间切换：\n\n- **Agent 模式**：输入发送给 AI Agent 处理\n- **Shell 模式**：输入作为本地 Shell 命令执行\n\n提示符会根据当前模式变化：\n- Agent 模式：`✨`（普通）或 `💫`（Thinking 模式）\n- Plan 模式：`📋`\n- Shell 模式：`$`\n\n## Plan 模式\n\n### `Shift-Tab`：切换 Plan 模式\n\n按 `Shift-Tab` 可以开启或关闭 Plan 模式。Plan 模式下 AI 只能使用只读工具探索代码库，将实施方案写入 plan 文件后提交给你审批。\n\n开启时提示符变为 `📋`，状态栏显示蓝色的 `plan` 标识。也可以使用 `/plan` 斜杠命令管理 Plan 模式。详见 [Plan 模式](../guides/interaction.md#plan-模式)。\n\n## 外部编辑器\n\n### `Ctrl-O`：在外部编辑器中编辑\n\n按 `Ctrl-O` 会打开外部编辑器（如 VS Code、Vim）编辑当前输入内容。编辑器按以下优先级选择：\n\n1. `/editor` 命令配置的编辑器\n2. `$VISUAL` 环境变量\n3. `$EDITOR` 环境变量\n4. 自动检测：`code --wait`（VS Code）→ `vim` → `vi` → `nano`\n\n使用 `/editor` 命令可交互式切换编辑器，也可直接指定，如 `/editor vim`。\n\n在编辑器中保存退出后，编辑后的内容会替换当前输入框内容。如果不保存退出（如 Vim 中 `:q!`），输入框内容保持不变。如果输入中包含粘贴文本占位符，编辑器会自动展开为原始文本供你编辑，保存后未修改的部分会重新折叠为占位符。\n\n适用于编写多行 prompt、复杂代码片段等场景。\n\n## 多行输入\n\n### `Ctrl-J` / `Alt-Enter`：插入换行\n\n默认情况下，按 `Enter` 会提交输入。如需输入多行内容，可使用：\n\n- `Ctrl-J`：在任意位置插入换行\n- `Alt-Enter`：在任意位置插入换行\n\n适用于输入多行代码片段或格式化文本。\n\n## 剪贴板操作\n\n### `Ctrl-V`：粘贴\n\n粘贴剪贴板内容到输入框。支持：\n\n- **文本**：在 Agent 模式下，超过 1000 字符或 15 行的文本会自动折叠为 `[Pasted text #n]` 占位符，保持输入框整洁；完整内容在发送时展开传递给模型。使用 `Ctrl-O` 打开外部编辑器时，占位符会自动展开为原始文本，保存后重新折叠\n- **图片**：缓存到磁盘并显示为 `[image:xxx.png,WxH]` 占位符，实际图片数据在发送时一并传递给模型（需模型支持图片输入）\n- **视频文件**：文件路径以文本形式插入输入框（需模型支持视频输入）\n\n::: tip 提示\n图片粘贴需要模型支持 `image_in` 能力，视频粘贴需要模型支持 `video_in` 能力。\n:::\n\n## 审批请求操作\n\n### `Ctrl-E`：展开完整内容\n\n当审批请求的预览内容被截断时，按 `Ctrl-E` 可以在全屏分页器中查看完整内容。预览被截断时会显示 \"... (truncated, ctrl-e to expand)\" 提示。\n\n适用于查看较长的 Shell 命令或文件 Diff 内容。\n\n### 数字键快速选择\n\n在审批面板中，按 `1`–`3` 可以直接选中并提交对应的审批选项，无需先用方向键选择再按 `Enter`。\n\n## 结构化问答操作\n\n当 AI 使用 `AskUserQuestion` 工具向你提问时，问题面板支持以下键盘操作：\n\n| 快捷键 | 功能 |\n|--------|------|\n| `↑` / `↓` | 浏览选项 |\n| `←` / `→` / `Tab` | 切换问题（多问题模式） |\n| `1`–`5` | 按编号选择选项（单选时自动提交，多选时切换选中状态） |\n| `Space` | 单选模式下提交选择，多选模式下切换选中状态 |\n| `Enter` | 确认选择 |\n| `Esc` | 跳过问题 |\n\n当 AI 一次提出多个问题时，问题面板会以标签页形式展示，使用 `←` / `→` 或 `Tab` 可在问题间切换，已回答的问题会标记为已完成状态，切换回已回答的问题时会恢复之前的选择。\n\n## 退出与中断\n\n### `Ctrl-D`：退出\n\n在输入框为空时按 `Ctrl-D` 退出 Kimi Code CLI。\n\n### `Ctrl-C`：中断\n\n- 在输入框中：清空当前输入\n- Agent 运行时：中断当前操作\n- 斜杠命令执行时：中断命令\n\n## 补全操作\n\n在 Agent 模式下，输入时会自动显示补全菜单：\n\n| 触发 | 补全内容 |\n|------|---------|\n| `/` | 斜杠命令 |\n| `@` | 工作目录文件路径 |\n\n补全操作：\n- 方向键选择\n- `Enter` 确认选择\n- `Esc` 关闭菜单\n- 继续输入过滤选项\n\n## 状态栏\n\n底部状态栏显示：\n\n- 当前时间\n- 当前模式（agent/shell）和模型名称（Agent 模式下显示）\n- YOLO 标识（开启时显示黄色标识）\n- Plan 标识（开启时显示蓝色标识）\n- 快捷键提示\n- 上下文使用率\n\n状态栏会自动刷新更新信息。\n"
  },
  {
    "path": "docs/zh/reference/kimi-acp.md",
    "content": "# `kimi acp` 子命令\n\n`kimi acp` 命令启动一个支持多会话的 ACP (Agent Client Protocol) 服务器。\n\n```sh\nkimi acp\n```\n\n## 说明\n\nACP 是一种标准化协议，允许 IDE 和其他客户端与 AI Agent 进行交互。\n\n## 使用场景\n\n- IDE 插件集成（如 JetBrains、Zed）\n- 自定义 ACP 客户端开发\n- 多会话并发处理\n\n如需在 IDE 中使用 Kimi Code CLI，请参阅 [在 IDE 中使用](../guides/ides.md)。\n\n## 认证\n\nACP 服务器在创建或加载会话前会检查用户认证状态。如果未登录，服务器会返回 `AUTH_REQUIRED` 错误（错误码 `-32000`），并携带可用的认证方式信息。\n\n客户端收到此错误后，应引导用户在终端中执行 `kimi login` 命令完成登录。登录成功后，后续的 ACP 请求即可正常执行。\n"
  },
  {
    "path": "docs/zh/reference/kimi-command.md",
    "content": "# `kimi` 命令\n\n`kimi` 是 Kimi Code CLI 的主命令，用于启动交互式会话或执行单次查询。\n\n```sh\nkimi [OPTIONS] COMMAND [ARGS]\n```\n\n## 基本信息\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--version` | `-V` | 显示版本号并退出 |\n| `--help` | `-h` | 显示帮助信息并退出 |\n| `--verbose` | | 输出详细运行信息 |\n| `--debug` | | 记录调试日志（输出到 `~/.kimi/logs/kimi.log`） |\n\n## Agent 配置\n\n| 选项 | 说明 |\n|------|------|\n| `--agent NAME` | 使用内置 Agent，可选值：`default`、`okabe` |\n| `--agent-file PATH` | 使用自定义 Agent 文件 |\n\n`--agent` 和 `--agent-file` 互斥，不能同时使用。详见 [Agent 与子 Agent](../customization/agents.md)。\n\n## 配置文件\n\n| 选项 | 说明 |\n|------|------|\n| `--config STRING` | 加载 TOML/JSON 配置字符串 |\n| `--config-file PATH` | 加载配置文件（默认 `~/.kimi/config.toml`） |\n\n`--config` 和 `--config-file` 互斥。配置字符串和文件均支持 TOML 和 JSON 格式。详见 [配置文件](../configuration/config-files.md)。\n\n## 模型选择\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--model NAME` | `-m` | 指定 LLM 模型，覆盖配置文件中的默认模型 |\n\n## 工作目录\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--work-dir PATH` | `-w` | 指定工作目录（默认当前目录） |\n| `--add-dir PATH` | | 添加额外目录到工作区范围，可多次指定 |\n\n工作目录决定了文件操作的根目录。在工作目录内可使用相对路径，操作工作目录外的文件需使用绝对路径。\n\n`--add-dir` 可以将工作目录之外的目录纳入工作区范围，使所有文件工具可以访问该目录中的文件。添加的目录会随会话状态持久化。运行中也可以通过 [`/add-dir`](./slash-commands.md#add-dir) 斜杠命令添加。\n\n## 会话管理\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--continue` | `-C` | 继续当前工作目录的上一个会话 |\n| `--session ID` | `-S` | 恢复指定 ID 的会话，若不存在则创建新会话 |\n\n`--continue` 和 `--session` 互斥。\n\n## 输入与命令\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--prompt TEXT` | `-p` | 传入用户提示，不进入交互模式 |\n| `--command TEXT` | `-c` | `--prompt` 的别名 |\n\n使用 `--prompt`（或 `--command`）时，Kimi Code CLI 会处理完查询后退出（除非指定 `--print`，否则仍以交互模式显示结果）。\n\n## 循环控制\n\n| 选项 | 说明 |\n|------|------|\n| `--max-steps-per-turn N` | 单轮最大步数，覆盖配置文件中的 `loop_control.max_steps_per_turn` |\n| `--max-retries-per-step N` | 单步最大重试次数，覆盖配置文件中的 `loop_control.max_retries_per_step` |\n| `--max-ralph-iterations N` | Ralph 循环模式的迭代次数；`0` 表示关闭；`-1` 表示无限 |\n\n### Ralph 循环\n\n[Ralph](https://ghuntley.com/ralph/) 是一种把 Agent 放进循环的技术：同一条提示词会被反复喂给 Agent，让它围绕一个任务持续迭代。\n\n当 `--max-ralph-iterations` 非 `0` 时，Kimi Code CLI 会进入 Ralph 循环模式，自动循环执行任务，直到 Agent 输出 `<choice>STOP</choice>` 或达到迭代上限。\n\n## UI 模式\n\n| 选项 | 说明 |\n|------|------|\n| `--print` | 以 Print 模式运行（非交互式），隐式启用 `--yolo` |\n| `--quiet` | `--print --output-format text --final-message-only` 的快捷方式 |\n| `--acp` | 以 ACP 服务器模式运行（已弃用，请使用 `kimi acp`） |\n| `--wire` | 以 Wire 服务器模式运行（实验性） |\n\n四个选项互斥，只能选择一个。默认使用 Shell 模式。详见 [Print 模式](../customization/print-mode.md) 和 [Wire 模式](../customization/wire-mode.md)。\n\n## Print 模式选项\n\n以下选项仅在 `--print` 模式下有效：\n\n| 选项 | 说明 |\n|------|------|\n| `--input-format FORMAT` | 输入格式：`text`（默认）或 `stream-json` |\n| `--output-format FORMAT` | 输出格式：`text`（默认）或 `stream-json` |\n| `--final-message-only` | 仅输出最终的 assistant 消息 |\n\n`stream-json` 格式使用 JSONL（每行一个 JSON 对象），用于程序化集成。\n\n## MCP 配置\n\n| 选项 | 说明 |\n|------|------|\n| `--mcp-config-file PATH` | 加载 MCP 配置文件，可多次指定 |\n| `--mcp-config JSON` | 加载 MCP 配置 JSON 字符串，可多次指定 |\n\n默认加载 `~/.kimi/mcp.json`（如果存在）。详见 [Model Context Protocol](../customization/mcp.md)。\n\n## 审批控制\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--yolo` | `-y` | 自动批准所有操作 |\n| `--yes` | | `--yolo` 的别名 |\n| `--auto-approve` | | `--yolo` 的别名 |\n\n::: warning 注意\nYOLO 模式下，所有文件修改和 Shell 命令都会自动执行，请谨慎使用。\n:::\n\n## Thinking 模式\n\n| 选项 | 说明 |\n|------|------|\n| `--thinking` | 启用 thinking 模式 |\n| `--no-thinking` | 禁用 thinking 模式 |\n\nThinking 模式需要模型支持。如果不指定，使用上次会话的设置。\n\n## Skills 配置\n\n| 选项 | 说明 |\n|------|------|\n| `--skills-dir PATH` | 指定 skills 目录，跳过自动发现 |\n\n不指定时，Kimi Code CLI 会按优先级自动发现用户级和项目级 Skills 目录。详见 [Agent Skills](../customization/skills.md)。\n\n## 子命令\n\n| 子命令 | 说明 |\n|--------|------|\n| [`kimi login`](#kimi-login) | 登录 Kimi 账号 |\n| [`kimi logout`](#kimi-logout) | 登出 Kimi 账号 |\n| [`kimi info`](./kimi-info.md) | 显示版本和协议信息 |\n| [`kimi acp`](./kimi-acp.md) | 启动多会话 ACP 服务器 |\n| [`kimi mcp`](./kimi-mcp.md) | 管理 MCP 服务器配置 |\n| [`kimi term`](./kimi-term.md) | 启动 Toad 终端 UI |\n| [`kimi export`](#kimi-export) | 导出会话为 ZIP 文件 |\n| [`kimi vis`](./kimi-vis.md) | 启动 Agent Tracing Visualizer（技术预览） |\n| [`kimi web`](./kimi-web.md) | 启动 Web UI 服务器 |\n\n### `kimi login`\n\n登录 Kimi 账号。执行后会自动打开浏览器，完成账号授权后自动配置可用的模型。\n\n```sh\nkimi login\n```\n\n### `kimi logout`\n\n登出 Kimi 账号。会清理存储的 OAuth 凭据并移除配置文件中的相关配置。\n\n```sh\nkimi logout\n```\n\n### `kimi export`\n\n将指定会话的数据导出为 ZIP 文件。ZIP 中包含会话目录下的所有文件（`context.jsonl`、`wire.jsonl`、`state.json` 等）。\n\n```sh\nkimi export <session_id> [-o <output_path>]\n```\n\n| 参数 / 选项 | 说明 |\n|------|------|\n| `<session_id>` | 要导出的会话 ID |\n| `--output, -o` | 输出 ZIP 文件路径（默认为当前目录下的 `session-<id>.zip`） |\n\n::: info 新增\n新增于 1.20 版本。\n:::\n\n### `kimi vis`\n\n::: warning 注意\n技术预览功能，可能不稳定。\n:::\n\n启动 Agent Tracing Visualizer，通过浏览器查看和分析会话追踪数据。\n\n```sh\nkimi vis [OPTIONS]\n```\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--port INTEGER` | `-p` | 绑定的端口号（默认：`5495`） |\n| `--open / --no-open` | | 自动打开浏览器（默认：启用） |\n| `--reload` | | 启用自动重载（开发模式） |\n\n详见 [Agent Tracing Visualizer](./kimi-vis.md)。\n\n### `kimi web`\n\n启动 Web UI 服务器，通过浏览器访问 Kimi Code CLI。\n\n```sh\nkimi web [OPTIONS]\n```\n\n如果默认端口被占用，服务器会自动尝试下一个可用端口（默认范围 `5494`–`5503`），并在终端打印提示。\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--host TEXT` | `-h` | 绑定的主机地址（默认：`127.0.0.1`） |\n| `--port INTEGER` | `-p` | 绑定的端口号（默认：`5494`） |\n| `--reload` | | 启用自动重载（开发模式） |\n| `--open / --no-open` | | 自动打开浏览器（默认：启用） |\n\n示例：\n\n```sh\n# 默认启动，自动打开浏览器\nkimi web\n\n# 指定端口\nkimi web --port 8080\n\n# 不自动打开浏览器\nkimi web --no-open\n\n# 绑定到所有网络接口（允许局域网访问）\nkimi web --host 0.0.0.0\n```\n\n详见 [Web UI](./kimi-web.md)。\n"
  },
  {
    "path": "docs/zh/reference/kimi-info.md",
    "content": "# `kimi info` 子命令\n\n`kimi info` 显示 Kimi Code CLI 的版本和协议信息。\n\n```sh\nkimi info [--json]\n```\n\n## 选项\n\n| 选项 | 说明 |\n|------|------|\n| `--json` | 以 JSON 格式输出 |\n\n## 输出内容\n\n| 字段 | 说明 |\n|------|------|\n| `kimi_cli_version` | Kimi Code CLI 版本号 |\n| `agent_spec_versions` | 支持的 Agent 规格版本列表 |\n| `wire_protocol_version` | Wire 协议版本 |\n| `python_version` | Python 运行时版本 |\n\n## 示例\n\n**文本输出**\n\n```sh\n$ kimi info\nkimi-cli version: 1.20.0\nagent spec versions: 1\nwire protocol: 1.5\npython version: 3.13.1\n```\n\n**JSON 输出**\n\n```sh\n$ kimi info --json\n{\"kimi_cli_version\": \"1.20.0\", \"agent_spec_versions\": [\"1\"], \"wire_protocol_version\": \"1.5\", \"python_version\": \"3.13.1\"}\n```\n"
  },
  {
    "path": "docs/zh/reference/kimi-mcp.md",
    "content": "# `kimi mcp` 子命令\n\n`kimi mcp` 用于管理 MCP (Model Context Protocol) 服务器配置。关于 MCP 的概念和使用方式，详见 [Model Context Protocol](../customization/mcp.md)。\n\n```sh\nkimi mcp COMMAND [ARGS]\n```\n\n## `add`\n\n添加 MCP 服务器配置。\n\n```sh\nkimi mcp add [OPTIONS] NAME [TARGET_OR_COMMAND...]\n```\n\n**参数**\n\n| 参数 | 说明 |\n|------|------|\n| `NAME` | 服务器名称，用于标识和引用 |\n| `TARGET_OR_COMMAND...` | `http` 模式为 URL；`stdio` 模式为命令（需以 `--` 开头） |\n\n**选项**\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--transport TYPE` | `-t` | 传输类型：`stdio`（默认）或 `http` |\n| `--env KEY=VALUE` | `-e` | 环境变量（仅 `stdio`），可多次指定 |\n| `--header KEY:VALUE` | `-H` | HTTP Header（仅 `http`），可多次指定 |\n| `--auth TYPE` | `-a` | 认证类型（如 `oauth`，仅 `http`） |\n\n## `list`\n\n列出所有已配置的 MCP 服务器。\n\n```sh\nkimi mcp list\n```\n\n输出包括：\n- 配置文件路径\n- 每个服务器的名称、传输类型和目标\n- OAuth 服务器的授权状态\n\n## `remove`\n\n移除 MCP 服务器配置。\n\n```sh\nkimi mcp remove NAME\n```\n\n**参数**\n\n| 参数 | 说明 |\n|------|------|\n| `NAME` | 要移除的服务器名称 |\n\n## `auth`\n\n对使用 OAuth 的 MCP 服务器进行授权。\n\n```sh\nkimi mcp auth NAME\n```\n\n执行后会打开浏览器进行 OAuth 授权流程。授权成功后，token 会被缓存以供后续使用。\n\n**参数**\n\n| 参数 | 说明 |\n|------|------|\n| `NAME` | 要授权的服务器名称 |\n\n::: tip 提示\n只有使用 `--auth oauth` 添加的服务器才需要执行此命令。\n:::\n\n## `reset-auth`\n\n清除 MCP 服务器的 OAuth 缓存 token。\n\n```sh\nkimi mcp reset-auth NAME\n```\n\n**参数**\n\n| 参数 | 说明 |\n|------|------|\n| `NAME` | 要重置授权的服务器名称 |\n\n清除后需要重新执行 `kimi mcp auth` 进行授权。\n\n## `test`\n\n测试与 MCP 服务器的连接并列出可用工具。\n\n```sh\nkimi mcp test NAME\n```\n\n**参数**\n\n| 参数 | 说明 |\n|------|------|\n| `NAME` | 要测试的服务器名称 |\n\n输出包括：\n- 连接状态\n- 可用工具数量\n- 工具名称和描述\n"
  },
  {
    "path": "docs/zh/reference/kimi-term.md",
    "content": "# `kimi term` 子命令\n\n`kimi term` 命令启动 [Toad](https://github.com/batrachianai/toad) 终端 UI，这是一个基于 [Textual](https://textual.textualize.io/) 的现代终端界面。\n\n```sh\nkimi term [OPTIONS]\n```\n\n## 说明\n\n[Toad](https://github.com/batrachianai/toad) 是 Kimi Code CLI 的图形化终端界面，通过 ACP 协议与 Kimi Code CLI 后端通信。它提供了更丰富的交互体验，包括更好的输出渲染和界面布局。\n\n运行 `kimi term` 时，会自动在后台启动一个 `kimi acp` 服务器，Toad 作为 ACP 客户端连接到该服务器。\n\n## 选项\n\n所有额外的选项会透传给内部的 `kimi acp` 命令。例如：\n\n```sh\nkimi term --work-dir /path/to/project --model kimi-k2\n```\n\n常用选项：\n\n| 选项 | 说明 |\n|------|------|\n| `--work-dir PATH` | 指定工作目录 |\n| `--model NAME` | 指定模型 |\n| `--yolo` | 自动批准所有操作 |\n\n完整选项请参阅 [`kimi` 命令](./kimi-command.md)。\n\n## 系统要求\n\n::: warning 注意\n`kimi term` 需要 Python 3.14+。如果你使用较低版本的 Python 安装了 Kimi Code CLI，需要重新用 Python 3.14 安装才能使用此功能：\n\n```sh\nuv tool install --python 3.14 kimi-cli\n```\n:::\n"
  },
  {
    "path": "docs/zh/reference/kimi-vis.md",
    "content": "# Agent Tracing Visualizer\n\n::: warning 注意\nAgent Tracing Visualizer 目前为技术预览版（Technical Preview），功能和界面可能在后续版本中发生变化。\n:::\n\nAgent Tracing Visualizer 是一个基于浏览器的可视化仪表板，用于检查和分析 Kimi Code CLI 的会话追踪数据。它可以帮助你理解 Agent 的行为、查看 Wire 事件时间线、分析上下文使用情况，以及浏览历史会话。\n\n## 启动\n\n在终端中运行 `kimi vis` 命令启动 Visualizer：\n\n```sh\nkimi vis\n```\n\n服务器启动后会自动打开浏览器。默认地址为 `http://127.0.0.1:5495`。\n\n如果默认端口被占用，服务器会自动尝试下一个可用端口（默认范围 `5495`–`5504`），并在终端打印访问地址。\n\n## 命令行选项\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--port INTEGER` | `-p` | 指定端口号（默认：`5495`） |\n| `--open / --no-open` | | 启动时自动打开浏览器（默认：`--open`） |\n| `--reload` | | 启用自动重载（用于开发调试） |\n\n示例：\n\n```sh\n# 指定端口\nkimi vis --port 8080\n\n# 不自动打开浏览器\nkimi vis --no-open\n```\n\n## 功能\n\n### Wire 事件时间线\n\n以时间线形式展示 Wire 事件的完整流程，包括轮次（Turn）的开始和结束、步骤（Step）的执行、工具调用和返回结果等。支持事件过滤和详细信息查看。\n\n### 上下文查看器\n\n可视化展示会话的上下文内容，包括 User 消息、Assistant 消息和工具调用。帮助理解 Agent 在每个步骤中 \"看到\" 的信息。\n\n### 会话浏览器\n\n浏览和搜索所有历史会话，按项目分组展示。可以查看每个会话的详细信息，包括工作目录、创建时间和消息数量。\n\n### 会话目录快捷操作\n\n在会话详情页顶部，可以使用 `Open Dir` 直接打开当前会话目录。该操作在 macOS 上调用 Finder，在 Windows 上调用 Explorer。`Copy DIR` 会复制当前会话目录的原始路径，便于你在终端、编辑器或问题报告中继续排查。\n\n### 会话下载与导出\n\n可以将会话数据导出为 ZIP 文件，方便离线分析或分享。\n\n- **ZIP 下载**：在会话浏览器和会话详情页中点击下载按钮，即可将会话目录打包为 ZIP 文件下载\n- **CLI 导出**：使用 `kimi export <session_id>` 命令将指定会话导出为 ZIP 文件\n\n### 会话导入\n\n支持将 ZIP 格式的会话数据导入到 Visualizer 中查看。导入的会话存储在独立的 `~/.kimi/imported_sessions/` 目录中，不会与正常会话混淆。\n\n在会话浏览器中可以通过 \"Imported\" 筛选器切换查看导入的会话。导入的会话支持删除操作，删除前会弹出确认对话框。\n\n### 用量统计\n\n展示 Token 用量的统计数据和图表，包括输入和输出 Token 的分布、缓存命中率等信息。\n"
  },
  {
    "path": "docs/zh/reference/kimi-web.md",
    "content": "# Web UI\n\nWeb UI 提供了基于浏览器的交互界面，让你可以在网页中使用 Kimi Code CLI 的所有功能。相比终端界面，Web UI 提供了更丰富的视觉体验、更灵活的会话管理以及更便捷的文件操作。\n\n## 启动 Web UI\n\n在终端中运行 `kimi web` 命令启动 Web UI 服务器：\n\n```sh\nkimi web\n```\n\n服务器启动后会自动打开浏览器访问 Web UI。默认地址为 `http://127.0.0.1:5494`。\n\n如果默认端口被占用，服务器会自动尝试下一个可用端口（默认范围 `5494`–`5503`），并在终端打印访问地址。\n\n## 命令行选项\n\n### 网络配置\n\n| 选项 | 简写 | 说明 |\n|------|------|------|\n| `--host TEXT` | `-h` | 绑定到指定的 IP 地址 |\n| `--network` | `-n` | 启用网络访问（绑定到 `0.0.0.0`） |\n| `--port INTEGER` | `-p` | 指定端口号（默认：`5494`） |\n\n默认情况下，Web UI 只监听本地回环地址 `127.0.0.1`，仅允许本机访问。\n\n如果你想在局域网或公网中访问 Web UI，可以使用 `--network` 选项或指定 `--host`：\n\n```sh\n# 绑定到所有网络接口，允许局域网访问\nkimi web --network\n\n# 绑定到指定 IP 地址\nkimi web --host 192.168.1.100\n```\n\n::: warning 注意\n当启用网络访问时，请务必配置访问控制选项（如 `--auth-token` 和 `--lan-only`）以确保安全。详见 [访问控制](#访问控制)。\n:::\n\n### 浏览器控制\n\n| 选项 | 说明 |\n|------|------|\n| `--open / --no-open` | 启动时自动打开浏览器（默认：`--open`） |\n\n使用 `--no-open` 可以禁止自动打开浏览器：\n\n```sh\nkimi web --no-open\n```\n\n### 开发选项\n\n| 选项 | 说明 |\n|------|------|\n| `--reload` | 启用自动重载（用于开发调试） |\n\n使用 `--reload` 可以在代码修改后自动重启服务器：\n\n```sh\nkimi web --reload\n```\n\n::: info 说明\n`--reload` 选项仅用于开发调试，日常使用不需要启用。\n:::\n\n### 访问控制\n\nWeb UI 提供了多层访问控制机制，确保服务的安全性。\n\n| 选项 | 说明 |\n|------|------|\n| `--auth-token TEXT` | 设置 Bearer Token 用于 API 认证 |\n| `--allowed-origins TEXT` | 设置允许的 Origin 列表（逗号分隔） |\n| `--lan-only / --public` | 仅允许局域网访问（默认）或允许公网访问 |\n| `--restrict-sensitive-apis / --no-restrict-sensitive-apis` | 限制敏感 API 访问（配置写入、open-in、文件访问限制） |\n| `--dangerously-omit-auth` | 禁用认证检查（危险，仅限受信任的网络环境） |\n\n::: info 新增\n访问控制选项新增于 1.6 版本。\n:::\n\n#### 访问令牌认证\n\n使用 `--auth-token` 可以设置访问令牌，客户端需要在 HTTP 请求头中携带 `Authorization: Bearer <token>` 才能访问 API：\n\n```sh\nkimi web --network --auth-token my-secret-token\n```\n\n::: tip 提示\n访问令牌应该是一个随机生成的字符串，建议至少包含 32 个字符。可以使用 `openssl rand -hex 32` 生成随机令牌。\n:::\n\n#### Origin 检查\n\n使用 `--allowed-origins` 可以限制允许访问 Web UI 的来源域名：\n\n```sh\nkimi web --network --allowed-origins \"https://example.com,https://app.example.com\"\n```\n\n::: tip 提示\n当使用 `--network` 或 `--host` 启用网络访问时，建议配置 `--allowed-origins` 以防止跨站请求伪造（CSRF）攻击。\n:::\n\n#### 网络访问范围\n\n默认情况下，Web UI 使用 `--lan-only` 模式，只允许来自局域网（私有 IP 地址段）的访问。如果需要允许公网访问，可以使用 `--public` 选项：\n\n```sh\nkimi web --network --public --auth-token my-secret-token\n```\n\n::: danger 警告\n使用 `--public` 选项会允许任何 IP 地址访问 Web UI，请务必配置 `--auth-token` 和 `--allowed-origins` 以确保安全。\n:::\n\n#### 限制敏感 API\n\n使用 `--restrict-sensitive-apis` 可以禁用一些敏感的 API 功能：\n\n- 配置文件写入\n- Open-in 功能（打开本地文件、目录、应用）\n- 文件访问限制\n\n```sh\nkimi web --network --restrict-sensitive-apis\n```\n\n在 `--public` 模式下，`--restrict-sensitive-apis` 默认启用；在 `--lan-only` 模式（默认）下则不启用。\n\n::: tip 提示\n当你需要将 Web UI 暴露给不受信任的网络环境时，建议启用 `--restrict-sensitive-apis` 选项。\n:::\n\n#### 禁用认证（不推荐）\n\n在受信任的私有网络环境中，你可以使用 `--dangerously-omit-auth` 跳过所有认证检查：\n\n```sh\nkimi web --dangerously-omit-auth\n```\n\n::: danger 警告\n`--dangerously-omit-auth` 选项会完全禁用认证和访问控制，仅应在完全受信任的网络环境中使用（如断网的本地开发环境）。不要在公网或不受信任的局域网中使用此选项。\n:::\n\n## 从终端切换到 Web UI\n\n如果你正在终端的 Shell 模式中使用 Kimi Code CLI，可以输入 `/web` 命令快速切换到 Web UI：\n\n```\n/web\n```\n\n执行后，Kimi Code CLI 会自动启动 Web UI 服务器并在浏览器中打开当前会话。你可以继续在 Web UI 中进行对话，会话历史会保持同步。\n\n## Web UI 功能特性\n\n### 会话管理\n\nWeb UI 提供了便捷的会话管理界面：\n\n- **会话列表**：查看所有历史会话，包括会话标题和工作目录\n- **会话搜索**：通过标题或工作目录快速筛选会话\n- **创建会话**：指定工作目录创建新会话；如果指定的路径不存在，会提示确认是否创建目录。支持 Cmd/Ctrl+点击新建会话按钮在新标签页中打开会话创建\n- **切换会话**：一键切换到不同的会话\n- **会话分支**：从任意 Assistant 回复处创建分支会话，在不影响原会话的情况下探索不同方向\n- **会话归档**：超过 15 天的会话会自动归档，你也可以手动归档。归档的会话不会出现在主列表中，但可以随时取消归档\n- **批量操作**：在多选模式下批量归档、取消归档或删除会话\n\n::: info 新增\n会话搜索功能新增于 1.5 版本。目录自动创建提示新增于 1.7 版本。会话分支、归档和批量操作新增于 1.9 版本。\n:::\n\n### 提示工具栏\n\nWeb UI 在输入框上方提供统一的提示工具栏，以可折叠标签页的形式展示多种信息：\n\n- **上下文用量**：显示当前上下文的使用百分比，悬停可查看详细的 Token 用量明细（包括输入/输出 Token、缓存读取/写入等）\n- **活动状态**：显示 Agent 当前状态（处理中、等待审批等）\n- **消息队列**：在 AI 处理过程中可以排队发送后续消息，待当前回复完成后自动发送\n- **文件变更**：检测 Git 仓库状态，显示新增、修改和删除的文件数量（包含未跟踪文件），点击可查看详细的变更列表\n- **待办事项**：当 `SetTodoList` 工具处于活动状态时，显示任务进度，支持展开查看详细列表\n- **Plan 模式**：在输入工具栏中切换 Plan 模式开关。Plan 模式激活时，输入框显示蓝色虚线边框。也可以通过 `set_plan_mode` Wire 协议方法程序化设置\n\n::: info 变更\nGit diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。1.10 版本将其统一为提示工具栏。1.11 版本将上下文用量指示器移至提示工具栏。1.20 版本新增 Plan 模式切换。\n:::\n\n### Open-in 功能\n\nWeb UI 支持在本地应用中打开文件或目录：\n\n- **Open in Terminal**：在终端中打开目录\n- **Open in VS Code**：在 VS Code 中打开文件或目录\n- **Open in Cursor**：在 Cursor 中打开文件或目录\n- **Open in System**：使用系统默认应用打开\n\n::: info 新增\nOpen-in 功能新增于 1.5 版本。\n:::\n\n::: warning 注意\nOpen-in 功能需要浏览器支持 Custom Protocol Handler 特性。当使用 `--restrict-sensitive-apis` 选项时，此功能会被禁用。\n:::\n\n### 斜杠命令\n\nWeb UI 支持斜杠命令，在输入框中输入 `/` 即可打开命令菜单：\n\n- **自动补全**：输入命令名称时自动过滤匹配项\n- **键盘导航**：使用上下方向键选择命令，Enter 确认\n- **别名支持**：支持命令别名匹配，如 `/h` 匹配 `/help`\n\n### 文件提及\n\nWeb UI 支持文件提及功能，在输入框中输入 `@` 即可打开文件提及菜单，可以在对话中引用文件：\n\n- **已上传附件**：提及当前消息中已添加的附件文件\n- **工作区文件**：提及当前会话工作目录中的已有文件\n- **自动补全**：输入时按文件名或路径自动过滤匹配项\n- **键盘导航**：使用上下方向键选择文件，Enter 或 Tab 确认，Escape 取消\n\n### 消息操作\n\nAssistant 消息提供以下操作按钮：\n\n- **复制**：一键复制消息内容到剪贴板\n- **分支**：从当前回复处创建分支会话\n\n::: info 新增\n复制和分支按钮新增于 1.10 版本。\n:::\n\n### 结构化问答\n\n当 AI 使用 `AskUserQuestion` 工具时，Web UI 会在聊天区域中展示结构化的问题对话框，替代底部的输入框。问题对话框显示问题描述和可选项，支持单选、多选以及自定义文本输入。当 AI 一次提出多个问题时，对话框顶部会以标签栏形式展示问题列表，支持点击切换、键盘导航，以及切换回已答问题时恢复之前的选择。回答所有问题后，对话框自动关闭，AI 根据你的选择继续执行。\n\n::: info 新增\n结构化问答功能新增于 1.14 版本。\n:::\n\n### 审批键盘快捷键\n\n当 Agent 发起审批请求时，你可以使用键盘快捷键快速响应：\n\n| 快捷键 | 操作 |\n|--------|------|\n| `1` | 批准 |\n| `2` | 本次会话批准 |\n| `3` | 拒绝 |\n\n::: info 新增\n审批键盘快捷键新增于 1.10 版本。\n:::\n\n### 工具输出\n\nWeb UI 对工具调用的输出提供了丰富的展示方式：\n\n- **媒体预览**：`ReadMediaFile` 工具读取的图片和视频会以可点击的缩略图形式展示\n- **Shell 命令**：`Shell` 工具的命令和输出以专用组件渲染\n- **Todo 列表**：`SetTodoList` 工具的待办事项以结构化列表展示\n- **工具输入参数**：重新设计的工具输入 UI，支持展开查看参数详情，长值带有语法高亮\n- **上下文压缩**：上下文压缩进行时会显示压缩指示器\n- **URL 快速打开**：`FetchURL` 工具的 URL 参数支持 Cmd/Ctrl+点击在新标签页中打开链接\n\n::: info 新增\n媒体预览、Shell 命令和 Todo 列表显示组件新增于 1.9 版本。URL 快速打开功能新增于 1.14 版本。\n:::\n\n### 富媒体支持\n\nWeb UI 支持查看和粘贴多种类型的富媒体内容：\n\n- **图片**：直接在聊天界面中显示图片\n- **代码高亮**：自动识别和高亮代码块\n- **Markdown 渲染**：支持完整的 Markdown 语法\n\n### 响应式布局\n\nWeb UI 采用响应式设计，可以在不同尺寸的屏幕上良好显示：\n\n- 桌面端：侧边栏 + 主内容区布局\n- 移动端：可折叠的抽屉式侧边栏\n\n::: info 变更\n响应式布局改进于 1.6 版本，增强了悬停效果和布局处理。\n:::\n\n### URL 操作参数\n\nWeb UI 支持通过 URL 参数触发特定操作，方便从外部工具或脚本中集成：\n\n| 参数 | 说明 |\n|------|------|\n| `?action=create` | 打开创建会话对话框 |\n| `?action=create-in-dir&workDir=<path>` | 直接在指定工作目录下创建会话 |\n\n示例：\n\n```\nhttp://127.0.0.1:5494?action=create\nhttp://127.0.0.1:5494?action=create-in-dir&workDir=/path/to/project\n```\n\n## 示例\n\n### 本地使用\n\n最简单的使用方式，只在本机访问：\n\n```sh\nkimi web\n```\n\n### 局域网共享\n\n在局域网中共享 Web UI，使用访问令牌保护：\n\n```sh\nkimi web --network --auth-token $(openssl rand -hex 32)\n```\n\n执行后，终端会显示访问地址和令牌。其他设备可以通过该地址访问，并在浏览器中输入令牌进行认证。\n\n### 公网访问\n\n在公网环境中部署 Web UI（需要谨慎配置安全选项）：\n\n```sh\nkimi web \\\n  --host 0.0.0.0 \\\n  --public \\\n  --auth-token $(openssl rand -hex 32) \\\n  --allowed-origins \"https://yourdomain.com\" \\\n  --restrict-sensitive-apis\n```\n\n### 开发调试\n\n启用自动重载功能，方便开发调试：\n\n```sh\nkimi web --reload --no-open\n```\n\n## 技术说明\n\nWeb UI 基于以下技术构建：\n\n- **后端**：FastAPI + WebSocket\n- **前端**：React + TypeScript + Vite\n- **API 协议**：符合 OpenAPI 规范，详见 `web/openapi.json`\n\nWeb UI 通过 WebSocket 与 Kimi Code CLI 的 Wire 模式通信，实现实时的双向数据传输。\n"
  },
  {
    "path": "docs/zh/reference/slash-commands.md",
    "content": "# 斜杠命令\n\n斜杠命令是 Kimi Code CLI 的内置命令，用于控制会话、配置和调试。在输入框中输入 `/` 开头的命令即可触发。\n\n::: tip Shell 模式\n部分斜杠命令在 Shell 模式下也可以使用，包括 `/help`、`/exit`、`/version`、`/editor`、`/changelog`、`/feedback`、`/export`、`/import` 和 `/task`。\n:::\n\n## 帮助与信息\n\n### `/help`\n\n显示帮助信息。在全屏分页器中列出键盘快捷键、所有可用的斜杠命令以及已加载的 Skills。按 `q` 退出。\n\n别名：`/h`、`/?`\n\n### `/version`\n\n显示 Kimi Code CLI 版本号。\n\n### `/changelog`\n\n显示最近版本的变更记录。\n\n别名：`/release-notes`\n\n### `/feedback`\n\n打开 GitHub Issues 页面提交反馈。\n\n## 账号与配置\n\n### `/login`\n\n登录或配置 API 平台。执行后首先选择平台：\n\n- **Kimi Code**：自动打开浏览器进行 OAuth 授权登录\n- **其他平台**：输入 API 密钥，然后选择可用模型\n\n配置完成后自动保存到 `~/.kimi/config.toml` 并重新加载。详见 [平台与模型](../configuration/providers.md)。\n\n别名：`/setup`\n\n::: tip 提示\n此命令仅在使用默认配置文件时可用。如果通过 `--config` 或 `--config-file` 指定了配置，则无法使用此命令。\n:::\n\n### `/logout`\n\n登出当前平台。会清理存储的凭据并移除配置文件中的相关配置。登出后 Kimi Code CLI 会自动重新加载配置。\n\n### `/model`\n\n切换模型和 Thinking 模式。\n\n此命令会先从 API 平台刷新可用模型列表。不带参数调用时，显示交互式选择界面，首先选择模型，然后选择是否开启 Thinking 模式（如果模型支持）。\n\n选择完成后，Kimi Code CLI 会自动更新配置文件并重新加载。\n\n::: tip 提示\n此命令仅在使用默认配置文件时可用。如果通过 `--config` 或 `--config-file` 指定了配置，则无法使用此命令。\n:::\n\n### `/editor`\n\n设置外部编辑器。不带参数调用时，显示交互式选择界面；也可以直接指定编辑器命令，如 `/editor vim`。配置后按 `Ctrl-O` 会使用此编辑器打开当前输入内容。详见 [键盘快捷键](./keyboard.md#外部编辑器)。\n\n### `/reload`\n\n重新加载配置文件，无需退出 Kimi Code CLI。\n\n### `/debug`\n\n显示当前上下文的调试信息，包括：\n- 消息数量和 token 数\n- 检查点数量\n- 完整的消息历史\n\n调试信息会在分页器中显示，按 `q` 退出。\n\n### `/usage`\n\n显示 API 用量和配额信息，以进度条和剩余百分比的形式展示各类配额的使用情况。\n\n别名：`/status`\n\n::: tip 提示\n此命令仅适用于 Kimi Code 平台。\n:::\n\n### `/mcp`\n\n显示当前连接的 MCP 服务器和加载的工具。详见 [Model Context Protocol](../customization/mcp.md)。\n\n输出包括：\n- 服务器连接状态（绿色表示已连接）\n- 每个服务器提供的工具列表\n\n## 会话管理\n\n### `/new`\n\n创建一个新会话并立即切换过去，无需退出 Kimi Code CLI。如果当前会话没有任何内容，会自动清理空会话目录。\n\n### `/sessions`\n\n列出当前工作目录下的所有会话，可切换到其他会话。\n\n别名：`/resume`\n\n使用方向键选择会话，按 `Enter` 确认切换，按 `Ctrl-C` 取消。\n\n### `/export`\n\n将当前会话的上下文导出为 Markdown 文件，方便归档或分享。\n\n用法：\n\n- `/export`：导出到当前工作目录，文件名自动生成（格式为 `kimi-export-<会话ID前8位>-<时间戳>.md`）\n- `/export <path>`：导出到指定路径。如果路径是目录，文件名会自动生成；如果是文件路径，则直接写入该文件\n\n导出文件包含：\n- 会话元数据（会话 ID、导出时间、工作目录、消息数、token 数）\n- 对话概览（主题、轮次数、工具调用次数）\n- 完整的对话历史，按轮次组织，包括用户消息、AI 回复、工具调用和工具结果\n\n### `/import`\n\n从文件或其他会话导入上下文到当前会话。导入的内容会作为参考上下文附加到当前对话中，AI 可以利用这些信息来辅助后续的交互。\n\n用法：\n\n- `/import <file_path>`：从文件导入。支持 Markdown、文本、代码、配置文件等常见文本格式；不支持二进制文件（如图片、PDF、压缩包）\n- `/import <session_id>`：从指定会话 ID 导入。不能导入当前会话自身\n\n### `/clear`\n\n清空当前会话的上下文，开始新的对话。\n\n别名：`/reset`\n\n### `/compact`\n\n手动压缩上下文，减少 token 使用。可以在命令后附带自定义指引，告诉 AI 在压缩时优先保留哪些信息，例如 `/compact 保留数据库相关的讨论`。\n\n当上下文过长时，Kimi Code CLI 会自动触发压缩。此命令可手动触发压缩过程。\n\n## Skills\n\n### `/skill:<name>`\n\n加载指定的 Skill，将 `SKILL.md` 内容作为提示词发送给 Agent。此命令适用于普通 Skill 和 Flow Skill。\n\n例如：\n\n- `/skill:code-style`：加载代码风格规范\n- `/skill:pptx`：加载 PPT 制作流程\n- `/skill:git-commits 修复用户登录问题`：加载 Skill 并附带额外的任务描述\n\n命令后面可以附带额外的文本，这些内容会追加到 Skill 提示词之后。详见 [Agent Skills](../customization/skills.md)。\n\n::: tip 提示\nFlow Skill 也可以通过 `/skill:<name>` 调用，此时作为普通 Skill 加载内容，不会自动执行流程。如需执行流程，请使用 `/flow:<name>`。\n:::\n\n### `/flow:<name>`\n\n执行指定的 Flow Skill。Flow Skill 在 `SKILL.md` 中内嵌 Agent Flow 流程图，执行后 Agent 会从 `BEGIN` 节点开始，按照流程图定义依次处理每个节点，直到到达 `END` 节点。\n\n例如：\n\n- `/flow:code-review`：执行代码审查工作流\n- `/flow:release`：执行发布工作流\n\n::: tip 提示\nFlow Skill 也可以通过 `/skill:<name>` 调用，此时作为普通 Skill 加载内容，不会自动执行流程。\n:::\n\n详见 [Agent Skills](../customization/skills.md#flow-skills)。\n\n## 工作区\n\n### `/add-dir`\n\n将额外目录添加到工作区范围。添加后，该目录对所有文件工具（`ReadFile`、`WriteFile`、`Glob`、`Grep`、`StrReplaceFile` 等）可用，并会在系统提示词中展示目录结构。添加的目录会随会话状态持久化，恢复会话时自动还原。\n\n用法：\n\n- `/add-dir <path>`：添加指定目录到工作区\n- `/add-dir`：不带参数时列出已添加的额外目录\n\n::: tip 提示\n已在工作目录内的目录无需添加，因为它们已经可访问。也可以在启动时通过 `--add-dir` 参数添加，详见 [`kimi` 命令](./kimi-command.md#工作目录)。\n:::\n\n## 其他\n\n### `/init`\n\n分析当前项目并生成 `AGENTS.md` 文件。\n\n此命令会启动一个临时子会话分析代码库结构，生成项目说明文档，帮助 Agent 更好地理解项目。\n\n### `/plan`\n\n切换 Plan 模式。Plan 模式下 AI 只能使用只读工具探索代码库，将实施方案写入 plan 文件后提交给你审批。详见 [Plan 模式](../guides/interaction.md#plan-模式)。\n\n用法：\n\n- `/plan`：切换 Plan 模式开关\n- `/plan on`：开启 Plan 模式\n- `/plan off`：关闭 Plan 模式\n- `/plan view`：查看当前方案内容\n- `/plan clear`：清除当前方案文件\n\n开启 Plan 模式后，提示符变为 `📋`，底部状态栏显示蓝色的 `plan` 标识。\n\n### `/task`\n\n打开交互式任务浏览器，查看、监控和管理后台任务。\n\n任务浏览器为三列 TUI 界面：\n\n- **左列**：任务列表，显示任务 ID、状态和描述\n- **中列**：选中任务的详细信息，包括 ID、状态、描述、时间、exit code 等\n- **右列**：最后几行输出预览\n\n支持以下键盘操作：\n\n| 快捷键 | 功能 |\n|--------|------|\n| `Enter` / `O` | 在分页器中查看选中任务的完整输出 |\n| `S` | 请求停止选中任务（需确认） |\n| `Tab` | 切换过滤模式（全部 / 仅活跃任务） |\n| `R` | 刷新任务列表 |\n| `Q` / `Esc` | 退出浏览器 |\n\n任务浏览器每秒自动刷新，实时显示任务状态变化。\n\n::: tip 提示\n后台任务通过 AI 使用 `Shell` 工具的 `run_in_background=true` 参数启动。当后台任务完成时，系统会自动通知 AI。\n:::\n\n### `/yolo`\n\n切换 YOLO 模式。开启后自动批准所有操作，底部状态栏会显示黄色的 YOLO 标识；再次输入可关闭。\n\n::: warning 注意\nYOLO 模式会跳过所有确认，请确保你了解可能的风险。\n:::\n\n### `/web`\n\n切换到 Web UI。执行后 Kimi Code CLI 会启动 Web UI 服务器并在浏览器中打开当前会话，你可以在 Web UI 中继续对话。详见 [Web UI](./kimi-web.md)。\n\n## 命令补全\n\n在输入框中输入 `/` 后，会自动显示可用命令列表。继续输入可过滤命令，支持模糊匹配，按 Enter 选择。\n\n例如，输入 `/ses` 会匹配到 `/sessions`，输入 `/clog` 会匹配到 `/changelog`。命令的别名也支持匹配，例如输入 `/h` 会匹配到 `/help`。\n"
  },
  {
    "path": "docs/zh/release-notes/breaking-changes.md",
    "content": "# 破坏性变更与迁移说明\n\n本页面记录 Kimi Code CLI 各版本中的破坏性变更及对应的迁移指引。\n\n## 未发布\n\n## 0.81 - Prompt Flow 被 Flow Skills 取代\n\n### `--prompt-flow` 选项移除\n\n`--prompt-flow` CLI 选项已移除，请改用 flow skills。\n\n- **受影响**：使用 `--prompt-flow` 加载 Mermaid/D2 流程图的脚本和自动化\n- **迁移**：创建包含嵌入式 Agent Flow 的 flow skill（在 `SKILL.md` 中），并通过 `/flow:<skill-name>` 调用\n\n### `/begin` 命令被替换\n\n`/begin` 斜杠命令已被 `/flow:<skill-name>` 命令替换。\n\n- **受影响**：使用 `/begin` 启动已加载 Prompt Flow 的用户\n- **迁移**：使用 `/flow:<skill-name>` 直接调用 flow skills\n\n## 0.77 - Thinking 模式与 CLI 选项变更\n\n### Thinking 模式设置迁移调整\n\n从 `0.76` 升级后，Thinking 模式设置不再自动保留。此前保存在 `~/.kimi/kimi.json` 中的 `thinking` 状态不再使用，改为通过 `~/.kimi/config.toml` 中的 `default_thinking` 配置项管理，但不会自动从旧版 `metadata` 迁移。\n\n- **受影响**：此前启用 Thinking 模式的用户\n- **迁移**：升级后需重新设置 Thinking 模式：\n  - 使用 `/model` 命令选择模型时设置 Thinking 模式（交互式）\n  - 或手动在 `~/.kimi/config.toml` 中添加：\n\n    ```toml\n    default_thinking = true  # 如需默认启用 Thinking 模式\n    ```\n\n### `--query` 选项移除\n\n`--query`（`-q`）已移除，改用 `--prompt` 作为主推参数，`--command` 作为别名。\n\n- **受影响**：使用 `--query` 或 `-q` 的脚本与自动化\n- **迁移**：\n  - `--query` / `-q` → `--prompt` / `-p`\n  - 或继续使用 `--command` / `-c`\n\n## 0.74 - ACP 命令变更\n\n### `--acp` 选项弃用\n\n`--acp` 选项已弃用，请使用 `kimi acp` 子命令。\n\n- **受影响**：使用 `kimi --acp` 的脚本和 IDE 配置\n- **迁移**：`kimi --acp` → `kimi acp`\n\n## 0.66 - 配置文件与供应商类型\n\n### 配置文件格式迁移\n\n配置文件格式从 JSON 迁移至 TOML。\n\n- **受影响**：使用 `~/.kimi/config.json` 的用户\n- **迁移**：Kimi Code CLI 会自动读取旧的 JSON 配置，但建议手动迁移到 TOML 格式\n- **新位置**：`~/.kimi/config.toml`\n\nJSON 配置示例：\n\n```json\n{\n  \"default_model\": \"kimi-k2-0711\",\n  \"providers\": {\n    \"kimi\": {\n      \"type\": \"kimi\",\n      \"base_url\": \"https://api.kimi.com/coding/v1\",\n      \"api_key\": \"your-key\"\n    }\n  }\n}\n```\n\n对应的 TOML 配置：\n\n```toml\ndefault_model = \"kimi-k2-0711\"\n\n[providers.kimi]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"your-key\"\n```\n\n### `google_genai` 供应商类型重命名\n\nGemini Developer API 的供应商类型从 `google_genai` 重命名为 `gemini`。\n\n- **受影响**：配置中使用 `type = \"google_genai\"` 的用户\n- **迁移**：将配置中的 `type` 值改为 `\"gemini\"`\n- **兼容性**：`google_genai` 仍可使用，但建议更新\n\n## 0.57 - 工具变更\n\n### `Shell` 工具\n\n`Bash` 工具（Windows 上为 `CMD`）统一重命名为 `Shell`。\n\n- **受影响**：Agent 文件中引用 `Bash` 或 `CMD` 工具的配置\n- **迁移**：将工具引用改为 `Shell`\n\n### `Task` 工具移至 `multiagent` 模块\n\n`Task` 工具从 `kimi_cli.tools.task` 移至 `kimi_cli.tools.multiagent` 模块。\n\n- **受影响**：自定义工具中导入 `Task` 工具的代码\n- **迁移**：将导入路径改为 `from kimi_cli.tools.multiagent import Task`\n\n### `PatchFile` 工具移除\n\n`PatchFile` 工具已移除。\n\n- **受影响**：使用 `PatchFile` 工具的 Agent 配置\n- **替代**：使用 `StrReplaceFile` 工具进行文件修改\n\n## 0.52 - CLI 选项变更\n\n### `--ui` 选项移除\n\n`--ui` 选项已移除，改用独立的标志位。\n\n- **受影响**：使用 `--ui print`、`--ui acp`、`--ui wire` 的脚本\n- **迁移**：\n  - `--ui print` → `--print`\n  - `--ui acp` → `kimi acp`\n  - `--ui wire` → `--wire`\n\n## 0.42 - 快捷键变更\n\n### 模式切换快捷键\n\nAgent/Shell 模式切换快捷键从 `Ctrl-K` 改为 `Ctrl-X`。\n\n- **受影响**：习惯使用 `Ctrl-K` 切换模式的用户\n- **迁移**：使用 `Ctrl-X` 切换模式\n\n## 0.27 - CLI 选项重命名\n\n### `--agent` 选项重命名\n\n`--agent` 选项重命名为 `--agent-file`。\n\n- **受影响**：使用 `--agent` 指定自定义 Agent 文件的脚本\n- **迁移**：将 `--agent` 改为 `--agent-file`\n- **注意**：`--agent` 现在用于指定内置 Agent（如 `default`、`okabe`）\n\n## 0.25 - 包名变更\n\n### 包名从 `ensoul` 改为 `kimi-cli`\n\n- **受影响**：使用 `ensoul` 包名的代码或脚本\n- **迁移**：\n  - 安装：`pip install ensoul` → `pip install kimi-cli` 或 `uv tool install kimi-cli`\n  - 命令：`ensoul` → `kimi`\n\n### `ENSOUL_*` 参数前缀变更\n\n系统提示词内置参数前缀从 `ENSOUL_*` 改为 `KIMI_*`。\n\n- **受影响**：自定义 Agent 文件中使用 `ENSOUL_*` 参数的配置\n- **迁移**：将参数前缀改为 `KIMI_*`（如 `ENSOUL_NOW` → `KIMI_NOW`）\n"
  },
  {
    "path": "docs/zh/release-notes/changelog.md",
    "content": "# 变更记录\n\n本页面记录 Kimi Code CLI 各版本的变更内容。\n\n## 未发布\n\n- Shell：在提示工具栏中显示当前工作目录、Git 分支、脏状态以及与远端的 ahead/behind 同步状态\n- Shell：在工具栏中显示活跃后台 Bash 任务数量，按时间轮换快捷键提示，并在窄终端中优雅截断内容以避免溢出\n- Web：修复取消和审批时工具执行状态同步问题——停止生成时工具现在正确过渡到 `output-denied` 状态，审批通过后执行期间显示加载动画（而非勾选图标）\n- Web：会话重放时消除过期的审批和问答对话框——重放会话或后端报告 idle/stopped/error 状态时，所有待处理的审批/问答对话框现在会被正确消除，防止产生孤立的交互元素\n- Web：支持行内数学公式渲染——除块级数学公式（`$$...$$`）外，新增支持单美元符号行内数学公式（`$...$`）\n- Web：优化 Switch 切换开关的比例和对齐——切换轨道现在更大（36×20），拇指按钮保持 16px 并具备更平滑的 16px 位移动画\n\n## 1.24.0 (2026-03-18)\n\n- Shell：提高长文本粘贴自动折叠阈值至 1000 字符或 15 行（之前为 300 字符或 3 行），改善语音/无键盘输入等场景下的体验\n- Core：Plan 模式现在支持多选方案——当 Agent 的计划包含多个不同路径时，`ExitPlanMode` 可展示 2–3 个带标签的选项供用户选择执行哪一个方案；用户选择的方案会作为选定路径返回给 Agent\n- Core：跨进程重启持久化 Plan 会话 ID 和文件路径——Plan 会话标识符和文件 slug 保存到 `SessionState`，重启 Kimi Code 后会继续使用 `~/.kimi/plans/` 下的同一计划文件，而非创建新文件\n- Core：Plan 模式现在支持增量编辑计划文件——Agent 可以使用 `StrReplaceFile` 精准更新计划文件的特定部分，而无需通过 `WriteFile` 重写整个文件；同时非计划文件的编辑现在会被直接阻止，而非弹出审批请求\n- Core：延迟 MCP 启动并展示加载进度——MCP 服务器现在在 Shell UI 启动后异步初始化，并提供实时进度指示器显示连接状态；Shell 在状态区域显示连接中和就绪状态，Web 显示服务器连接状态\n- Core：优化轻量级启动路径——对 CLI 子命令和版本元数据实现延迟加载，显著缩短 `--version` 和 `--help` 等常用命令的启动时间\n- Build：修复 Nix `FileCollisionError` for `bin/kimi`——从 `kimi-code` 包中移除重复的入口点，使 `kimi-cli` 独占 `bin/kimi`\n- Shell：Agent 运行期间保留用户未提交的输入——在模型运行时在提示符中键入的文本不再在轮次结束时丢失，用户可以按回车键将草稿作为下一条消息提交\n- Shell：修复 Agent 运行结束后 Ctrl-C 和 Ctrl-D 无法正常工作的问题——键盘中断和 EOF 信号被静默吞没，而非显示提示信息或退出 Shell\n\n## 1.23.0 (2026-03-17)\n\n- Shell：新增后台 Bash——`Shell` 工具现在支持 `run_in_background=true` 参数，可将耗时命令（构建、测试、服务）作为后台任务启动，Agent 无需等待即可继续工作；新增 `TaskList`、`TaskOutput`、`TaskStop` 工具管理任务生命周期，任务到达终止态时系统自动通知 Agent\n- Shell：新增 `/task` 斜杠命令与交互式任务浏览器——三列 TUI 界面，支持查看、监控和管理后台任务，提供实时刷新、输出预览和键盘驱动的任务停止操作\n- Web：修复切换模型后其他标签页全局配置未刷新的问题——在某个标签页中切换模型时，其他标签页现在能检测到配置更新并自动刷新全局配置\n\n## 1.22.0 (2026-03-13)\n\n- Shell：长文本粘贴自动折叠为 `[Pasted text #n]` 占位符——通过 `Ctrl-V` 或括号粘贴输入的超过 300 字符或 3 行的文本在提示缓冲区中显示为紧凑的占位符标记，完整内容在发送给模型时展开；外部编辑器（`Ctrl-O`）打开时自动展开占位符，保存后重新折叠\n- Shell：粘贴的图片缓存为附件占位符——从剪贴板粘贴的图片存储到磁盘，在提示中显示为 `[image:…]` 标记，保持输入缓冲区整洁\n- Shell：修复粘贴文本中 UTF-16 surrogate 字符导致序列化错误的问题——来自 Windows 剪贴板的孤立 surrogate 字符现在在存储前即被清洗，防止历史记录写入和 JSON 序列化时出现 `UnicodeEncodeError`\n- Shell：重新设计斜杠命令补全菜单——使用全宽自定义菜单替代默认的补全弹窗，展示命令名称和多行描述，支持高亮和滚动\n- Shell：修复取消的 Shell 命令未正确终止子进程的问题——当运行中的命令被取消时，子进程现在会被显式杀死，防止产生孤儿进程\n\n## 1.21.0 (2026-03-12)\n\n- Shell：新增内联运行提示与 steer 输入——模型运行时 Agent 输出直接渲染在提示区域内，用户无需等待轮次结束即可输入并发送后续消息（steer）；审批请求和问答面板支持内联键盘交互\n- Core：将 steer 注入方式从合成工具调用改为常规 User 消息——steer 内容现作为标准 User 消息追加到上下文，而非伪造的 `_steer` 工具调用/工具结果对，改善了上下文序列化和可视化的兼容性\n- Wire：新增 `SteerInput` 事件——当用户在运行中的轮次发送后续 steer 消息时触发的新 Wire 协议事件\n- Shell：Agent 模式下提交后回显用户输入——提示符和输入文本会打印回终端，使对话记录更清晰\n- Shell：改进会话回放对 steer 输入的支持——回放现在能正确重建并展示 steer 消息与常规轮次，并过滤内部 system-reminder 消息\n- Shell：修复 toast 通知中升级命令不一致的问题——升级命令文本统一从 `UPGRADE_COMMAND` 常量获取\n- Core：在 `context.jsonl` 中持久化系统提示词——系统提示词作为上下文文件的第一条记录写入，并在会话生命周期内冻结，使可视化工具能读取完整对话上下文，会话恢复时复用原始提示词而非重新生成\n- Vis：为 `kimi vis` 新增会话目录快捷操作——可在会话页面直接打开当前会话文件夹，使用 `Copy DIR` 复制原始会话目录路径，并支持在 macOS 和 Windows 上打开目录\n- Shell：优化 API 密钥登录体验——验证密钥时显示加载动画，当 401 错误可能因选错平台导致时显示提示信息，登录成功后展示配置摘要，并将 Thinking 模式默认设为开启\n\n## 1.20.0 (2026-03-11)\n\n- Web：新增 Web UI 中的 Plan 模式切换——在输入工具栏中添加开关控件，Plan 模式激活时输入框显示蓝色虚线边框，并支持通过 `set_plan_mode` Wire 协议方法设置 Plan 模式\n- Core：Plan 模式状态跨会话持久化——将 `plan_mode` 保存到 `SessionState`，会话恢复时自动还原\n- Core：修复工具触发的 Plan 模式变更未正确反映在 StatusUpdate 中的问题——在 `EnterPlanMode`/`ExitPlanMode` 工具执行后发送更新的 `StatusUpdate`，确保客户端看到最新状态\n- Core：修复部分 Linux 系统（如内核版本 6.8.0-101）上 HTTP 请求头包含尾部空白/换行符导致连接错误的问题——发送前对 ASCII 请求头值执行空白裁剪\n- Core：修复 OpenAI Responses provider 隐式发送 `reasoning.effort=null` 导致需要推理的 Responses 兼容端点报错的问题——现在仅在显式设置时才发送推理参数\n- Vis：新增会话下载、导入、导出与删除功能——在会话浏览器和详情页支持一键 ZIP 下载，支持将 ZIP 文件导入到独立的 `~/.kimi/imported_sessions/` 目录并通过\"Imported\"筛选器切换查看，新增 `kimi export <session_id>` CLI 命令，支持删除导入的会话并提供 AlertDialog 二次确认\n- Core：修复对话包含媒体内容（图片、音频、视频）时上下文压缩失败的问题——将过滤策略从黑名单（排除 `ThinkPart`）改为白名单（仅保留 `TextPart`），防止不支持的内容类型被发送到压缩 API\n- Web：修复 `@` 文件提及索引在切换会话或工作区文件变更后不刷新的问题——切换会话时重置索引，30 秒过期自动刷新，输入路径前缀可查找超出 500 文件上限的文件\n\n## 1.19.0 (2026-03-10)\n\n- Core：新增 Plan 模式——AI 在编码前先制定实施方案并提交审批。Plan 模式下仅允许使用只读工具（`Glob`、`Grep`、`ReadFile`）探索代码库，将方案写入 plan 文件后通过 `ExitPlanMode` 提交审批，用户可批准、拒绝或提供修改意见；支持 `Shift-Tab` 快捷键和 `/plan` 斜杠命令切换\n- Vis：新增 `kimi vis` 命令，启动交互式可视化仪表板以检查会话追踪——包括 Wire 事件时间线、上下文查看器、会话浏览器和用量统计\n- Web：修复会话流状态管理问题——修复状态重置时的空引用错误，并在切换会话时保留斜杠命令，避免初始化响应返回前出现短暂的空白\n\n## 1.18.0 (2026-03-09)\n\n- ACP：支持 ACP 模式下的嵌入式资源内容，使 Zed 的 `@` 文件引用能够正确包含文件内容\n- Core：在 Google GenAI provider 中使用 `parameters_json_schema` 替代 `parameters`，绕过 Pydantic 校验对 MCP 工具中标准 JSON Schema 元数据字段的拒绝\n- Shell：增强 `Ctrl-V` 剪贴板粘贴功能，支持粘贴视频文件——视频文件路径以文本形式插入输入框，同时修复剪贴板数据为 `None` 时的崩溃问题\n- Core：将会话 ID 作为 `user_id` 元数据传递给 Anthropic API\n- Web：修复 WebSocket 重连时斜杠命令丢失的问题，并为会话初始化添加自动重试逻辑\n\n## 1.17.0 (2026-03-03)\n\n- Core：新增 `/export` 命令，支持将当前会话上下文（消息、元数据）导出为 Markdown 文件；新增 `/import` 命令，支持从文件或其他会话 ID 导入上下文到当前会话\n- Shell：在状态栏上下文用量旁显示 Token 数量（已用/总量），如 `context: 42.0% (4.2k/10.0k)`\n- Shell：工具栏快捷键提示改为轮转显示——每次提交后循环展示不同快捷键提示，节省横向空间\n- MCP：为 MCP 服务器连接添加加载指示器——Shell 在连接 MCP 服务器时显示 \"Connecting to MCP servers...\" 加载动画，Web 在 MCP 工具加载期间显示状态消息\n- Web：修复工具栏变更面板中文件列表滚动溢出的问题\n- Core：新增 `compaction_trigger_ratio` 配置项（默认 `0.85`），用于控制自动压缩的触发时机——当上下文用量达到配置比例或剩余空间低于 `reserved_context_size` 时触发压缩，以先满足的条件为准\n- Core：`/compact` 命令支持自定义指令（如 `/compact keep database discussions`），可指导压缩时重点保留的内容\n- Web：新增 URL 操作参数（`?action=create` 打开创建会话对话框，`?action=create-in-dir&workDir=xxx` 直接创建会话）用于外部集成，支持 Cmd/Ctrl+点击新建会话按钮在新标签页中打开会话创建\n- Web：在提示输入工具栏中添加待办列表显示——当 `SetTodoList` 工具激活时，显示任务进度并支持展开面板查看详情\n- ACP：为会话操作添加认证检查，未认证时返回 `AUTH_REQUIRED` 错误响应，支持终端登录流程\n\n## 1.16.0 (2026-02-27)\n\n- Web：更新 ASCII Logo 横幅为新的样式设计\n- Core：新增 `--add-dir` CLI 选项和 `/add-dir` 斜杠命令，支持将额外目录添加到工作区范围——添加的目录可被所有文件工具（读取、写入、glob、替换）访问，跨会话持久化保存，并在系统提示词中展示\n- Shell：新增 `Ctrl-O` 快捷键，在外部编辑器中编辑当前输入内容（`$VISUAL`/`$EDITOR`），支持自动检测 VS Code、Vim、Vi 或 Nano\n- Shell：新增 `/editor` 斜杠命令，可交互式配置和切换默认外部编辑器，设置持久保存到配置文件\n- Shell：新增 `/new` 斜杠命令，无需重启 Kimi Code CLI 即可创建并切换到新会话\n- Wire：当客户端不支持 `supports_question` 能力时，自动隐藏 `AskUserQuestion` 工具，避免 LLM 调用不受支持的交互\n- Core：在压缩后估算上下文 Token 数量，使上下文用量百分比不再显示为 0%\n- Web：上下文用量百分比显示精确到一位小数，提升精度\n\n## 1.15.0 (2026-02-27)\n\n- Shell：精简输入提示符，移除用户名前缀以获得更简洁的外观\n- Shell：在工具栏中添加水平分隔线和更完整的键盘快捷键提示\n- Shell：为问题面板和审批面板添加数字键（1–5）快速选择选项，并以带边框的面板和键盘提示重新设计交互界面\n- Shell：为多问题面板添加标签式导航——使用左右方向键或 Tab 键在问题间切换，并以可视化指示器区分已答、当前和待答状态，重新访问已答问题时自动恢复选择状态\n- Shell：在问题面板中支持使用空格键提交单选问题\n- Web：为多问题对话框添加标签式导航，支持可点击标签栏、键盘导航，以及重新访问已答问题时恢复选择状态\n- Core：将进程标题设置为 \"Kimi Code\"（在 `ps` / 活动监视器 / 终端标签页标题中可见），并将 Web Worker 子进程标记为 \"kimi-code-worker\"\n\n## 1.14.0 (2026-02-26)\n\n- Shell：在终端中将 `FetchURL` 工具的 URL 参数显示为可点击的超链接\n- Tool：新增 `AskUserQuestion` 工具，支持在执行过程中向用户展示结构化问题和预定义选项，支持单选、多选和自定义文本输入\n- Wire：新增 `QuestionRequest` / `QuestionResponse` 消息类型和能力协商机制，用于结构化问答交互\n- Shell：新增 `AskUserQuestion` 交互式问题面板，支持键盘驱动的选项选择\n- Web：新增 `QuestionDialog` 组件，支持在界面内展示并回答结构化问题，问题待回答时替代提示输入框\n- Core：支持会话状态跨会话持久化——审批决策（YOLO 模式、自动批准的操作）和动态子 Agent 现在会被保存，并在恢复会话时自动还原\n- Core：对元数据和会话状态文件使用原子化 JSON 写入，防止崩溃时数据损坏\n- Wire：新增 `steer` 请求，可在 Agent 轮次进行中注入用户消息（协议版本 1.4）\n- Web：支持在 `FetchURL` 工具的 URL 参数上使用 Cmd/Ctrl+点击在新标签页中打开链接，并显示适合当前平台的提示信息\n\n## 1.13.0 (2026-02-24)\n\n- Core：添加自动连接恢复机制，在连接错误和超时错误时重建 HTTP 客户端并重试，提升对瞬时网络故障的容错能力\n\n## 1.12.0 (2026-02-11)\n\n- Web：添加子 Agent 活动渲染，在 Task 工具消息中展示子 Agent 步骤（思考、工具调用、文本）\n- Web：添加 Think 工具渲染，以轻量级推理风格块展示\n- Web：将 emoji 状态指示器替换为 Lucide 图标，并为工具名称添加分类图标\n- Web：改进 Reasoning 组件，优化思考标签和状态图标\n- Web：改进 Todo 组件，添加状态图标并优化样式\n- Web：实现 WebSocket 断线重连，支持自动重发请求和连接超时监控\n- Web：改进创建会话对话框的命令值处理\n- Web：支持会话工作目录路径中的波浪号（`~`）展开\n- Web：修复 Assistant 消息内容溢出被裁剪的问题\n- Wire：修复多个子 Agent 并发运行时的死锁问题，不再在审批请求和工具调用请求上阻塞 UI 循环\n- Wire：Agent 轮次结束后清理残留的待处理请求\n- Web：在提示输入框中显示引导占位文本，提示可使用斜杠命令和 @ 引用文件\n- Web：修复在 uvicorn Web 服务器中 Ctrl+C 无法使用的问题，在 Shell 模式退出后恢复默认的 SIGINT 信号处理程序和终端状态\n- Web：改进会话停止处理，使用正确的异步清理和超时机制\n- ACP：添加协议版本协商框架，用于客户端与服务端之间的兼容性校验\n- ACP：添加会话恢复方法，用于恢复会话状态（实验性）\n\n## 1.11.0 (2026-02-10)\n\n- Web：将上下文用量指示器从工作区标题栏移至提示工具栏，悬停时显示详细的 Token 用量明细\n- Web：在文件变更面板底部添加文件夹指示器，显示工作目录路径\n- Web：修复切换到 Web 模式时未恢复 stderr 的问题，该问题可能导致 Web 服务器的错误输出被抑制\n- Web：修复端口可用性检查，在测试套接字上设置 SO_REUSEADDR\n\n## 1.10.0 (2026-02-09)\n\n- Web：为 Assistant 消息添加复制和分支(fork)操作按钮，支持快速复制内容和创建分支会话\n- Web：为审批操作添加键盘快捷键——按 `1` 批准、`2` 本次会话批准、`3` 拒绝\n- Web：添加消息队列功能——在 AI 处理过程中可排队发送后续消息，待当前回复完成后自动发送\n- Web：将 Git diff 状态栏替换为统一的提示工具栏，以可折叠标签页展示活动状态、消息队列和文件变更\n- Web：在 Web Worker 中加载全局 MCP 配置，使 Web 会话可以使用 MCP 工具\n- Web：改进移动端提示输入框体验——缩小 textarea 最小高度、添加 `autoComplete=\"off\"`、在小屏幕上禁用聚焦边框\n- Web：处理部分模型先输出文本再输出思考过程的情况，确保思考消息始终显示在文本消息之前\n- Web：在会话连接过程中显示更具体的状态信息（\"Loading history...\"、\"Starting environment...\" 替代通用的 \"Connecting...\"）\n- Web：会话环境初始化失败时发送错误状态，而非让 UI 一直处于等待状态\n- Web：历史回放完成后 15 秒内未收到会话状态时自动重连\n- Web：会话流中使用非阻塞文件 I/O，避免历史回放期间阻塞事件循环\n\n## 1.9.0 (2026-02-06)\n\n- Config：添加 `default_yolo` 配置项，支持默认开启 YOLO（自动审批）模式\n- Config：支持 `max_steps_per_turn` 和 `max_steps_per_run` 作为循环控制设置的别名\n- Wire：新增 `replay` 请求，用于回放已记录的 Wire 事件（协议版本 1.3）\n- Web：添加会话分支(fork)功能，可以从任意 Assistant 回复处创建新的分支会话\n- Web：添加会话归档功能，自动归档超过 15 天的会话\n- Web：添加多选模式，支持批量归档、取消归档和删除操作\n- Web：添加工具结果的媒体预览（ReadMediaFile 的图片/视频），支持可点击缩略图\n- Web：添加 Shell 命令和 Todo 列表的工具输出显示组件\n- Web：添加活动状态指示器，显示 Agent 状态（处理中、等待审批等）\n- Web：添加图片加载失败时的错误回退 UI\n- Web：重新设计工具输入 UI，支持可展开参数和长值的语法高亮\n- Web：上下文压缩时显示压缩指示器\n- Web：改进聊天中的自动滚动行为，更流畅地跟随新内容\n- Web：会话流开始时更新工作目录的最近会话 ID（`last_session_id`）\n- Shell：移除 `Ctrl-/` 快捷键（此前用于触发 `/help` 命令）\n- Rust：Rust 版实现迁移到 `MoonshotAI/kimi-agent-rs` 并独立发版；二进制更名为 `kimi-agent`\n- Core：重新加载配置时保留会话 ID，确保会话正确恢复\n- Shell：修复会话回放时显示已被 `/clear` 或 `/reset` 清除的消息的问题\n- Web：修复会话中断或取消时审批请求状态未更新的问题\n- Web：修复选择斜杠命令时的输入法组合问题\n- Web：修复执行 `/clear`、`/reset` 或 `/compact` 命令后 UI 未清空消息的问题\n\n## 1.8.0 (2026-02-05)\n\n- CLI：修复启动错误（如无效的配置文件）被静默吞掉而不显示的问题\n\n## 1.7.0 (2026-02-05)\n\n- Rust：添加 `kagent`，Kimi Agent 内核的 Rust 实现，支持 Wire 模式（实验性）\n- Auth：修复多个会话同时运行时的 OAuth 令牌刷新冲突\n- Web：添加文件提及菜单（`@`），支持引用已上传附件和工作区文件，带自动补全功能\n- Web：添加斜杠命令菜单，支持自动补全、键盘导航和别名匹配\n- Web：修复认证令牌持久化问题，从 sessionStorage 切换到 localStorage 并设置 24 小时过期\n- Web：创建会话时，若指定的路径不存在则提示创建目录\n- Web：为会话列表添加服务端分页和虚拟滚动，提升性能\n- Web：改进会话和工作目录加载，采用更智能的缓存和失效策略\n- Web：修复历史记录回放时的 WebSocket 错误，发送前检查连接状态\n- Web：Git diff 状态栏现在显示未跟踪文件（尚未添加到 git 的新文件）\n- Web：仅在 public 模式下限制敏感 API；更新 origin 执行逻辑\n\n## 1.6 (2026-02-03)\n\n- Web：为网络模式添加基于 Token 的认证和访问控制（`--network`、`--lan-only`、`--public`）\n- Web：添加安全选项：`--auth-token`、`--allowed-origins`、`--restrict-sensitive-apis`、`--dangerously-omit-auth`\n- Web：变更 `--host` 选项，用于绑定到指定 IP 地址；添加自动网络地址检测\n- Web：修复创建新会话时 WebSocket 断开连接的问题\n- Web：将最大图片尺寸从 1024 提升至 4096 像素\n- Web：通过增强的悬停效果和更好的布局处理改进 UI 响应性\n- Wire：添加 `TurnEnd` 事件，用于标识 Agent 轮次的完成（协议版本 1.2）\n- Core：修复包含 `$` 的自定义 Agent 提示词文件导致静默启动失败的问题\n\n## 1.5 (2026-01-30)\n\n- Web：添加 Git diff 状态栏，显示会话工作目录中的未提交更改\n- Web：添加 \"Open in\" 菜单，用于在终端、VS Code、Cursor 或其他本地应用中打开文件/目录\n- Web：添加会话搜索功能，支持按标题或工作目录过滤会话\n- Web：改进会话标题显示，优化溢出处理\n\n## 1.4 (2026-01-30)\n\n- Shell：合并 `/login` 和 `/setup` 命令，`/setup` 现为 `/login` 的别名\n- Shell：`/usage` 命令现在显示剩余配额百分比；添加 `/status` 别名\n- Config：添加 `KIMI_SHARE_DIR` 环境变量，用于自定义共享目录路径（默认 `~/.kimi`）\n- Web：新增 Web UI，支持基于浏览器的交互\n- CLI：添加 `kimi web` 子命令以启动 Web UI 服务器\n- Auth：修复设备名称或操作系统版本包含非 ASCII 字符时的编码错误\n- Auth：OAuth 凭据现在存储在文件中而非 keyring；启动时自动迁移现有令牌\n- Auth：修复系统休眠或睡眠后的授权失败问题\n\n## 1.3 (2026-01-28)\n\n- Auth：修复 Agent 轮次期间的认证问题\n- Tool：为 `ReadMediaFile` 中的媒体内容添加描述性标签，提高路径可追溯性\n\n## 1.2 (2026-01-27)\n\n- UI：显示 `kimi-for-coding` 模型的说明\n\n## 1.1 (2026-01-27)\n\n- LLM：修复 `kimi-for-coding` 模型的能力\n\n## 1.0 (2026-01-27)\n\n- Shell：添加 `/login` 和 `/logout` 斜杠命令，用于登录和登出\n- CLI：添加 `kimi login` 和 `kimi logout` 子命令\n- Core：修复子 Agent 审批请求处理问题\n\n## 0.88 (2026-01-26)\n\n- MCP：移除连接 MCP 服务器时的 `Mcp-Session-Id` header 以修复兼容性问题\n\n## 0.87 (2026-01-25)\n\n- Shell：修复 HTML 块出现在元素外时的 Markdown 渲染错误\n- Skills：添加更多用户级和项目级 Skills 目录候选\n- Core：改进系统提示词中的媒体文件生成和处理任务指引\n- Shell：修复 macOS 上从剪贴板粘贴图片的问题\n\n## 0.86 (2026-01-24)\n\n- Build：修复二进制构建问题\n\n## 0.85 (2026-01-24)\n\n- Shell：粘贴的图片缓存到磁盘，支持跨会话持久化\n- Shell：基于内容哈希去重缓存的附件\n- Shell：修复消息历史中图片/音频/视频附件的显示\n- Tool：使用文件路径作为 `ReadMediaFile` 中的媒体标识符，提高可追溯性\n- Tool：修复部分 MP4 文件无法识别为视频的问题\n- Shell：执行斜杠命令时支持 Ctrl-C 中断\n- Shell：修复 Shell 模式下输入不符合 Shell 语法的内容时的解析错误\n- Shell：修复 MCP 服务器和第三方库的 stderr 输出污染 Shell UI 的问题\n- Wire：优雅关闭，当连接关闭或收到 Ctrl-C 时正确清理待处理请求\n\n## 0.84 (2026-01-22)\n\n- Build：添加跨平台独立二进制构建，支持 Windows、macOS（含代码签名和公证）和 Linux（x86_64 和 ARM64）\n- Shell：修复斜杠命令自动补全在输入完整命令/别名时仍显示建议的问题\n- Tool：将 SVG 文件作为文本而非图片处理\n- Flow：支持 D2 markdown 块字符串（`|md` 语法），用于 Flow Skill 中的多行节点标签\n- Core：修复运行 `/reload`、`/setup` 或 `/clear` 后可能出现的 \"event loop is closed\" 错误\n- Core：修复在续接会话中使用 `/clear` 时的崩溃问题\n\n## 0.83 (2026-01-21)\n\n- Tool：添加 `ReadMediaFile` 工具用于读取图片/视频文件；`ReadFile` 现在仅用于读取文本文件\n- Skills：Flow Skills 现在也注册为 `/skill:<skill-name>` 命令（除了 `/flow:<skill-name>`）\n\n## 0.82 (2026-01-21)\n\n- Tool：`WriteFile` 和 `StrReplaceFile` 工具支持使用绝对路径编辑/写入工作目录外的文件\n- Tool：使用 Kimi 供应商时，视频文件上传到 Kimi Files API，使用 `ms://` 引用替代 inline data URL\n- Config：添加 `reserved_context_size` 配置项，自定义自动压缩触发阈值（默认 50000 tokens）\n\n## 0.81 (2026-01-21)\n\n- Skills：添加 Flow Skill 类型，在 SKILL.md 中内嵌 Agent Flow（Mermaid/D2），通过 `/flow:<skill-name>` 命令调用\n- CLI：移除 `--prompt-flow` 选项，改用 Flow Skills\n- Core：用 `/flow:<skill-name>` 命令替代原来的 `/begin` 命令\n\n## 0.80 (2026-01-20)\n\n- Wire：添加 `initialize` 方法，用于交换客户端/服务端信息、注册外部工具和公布斜杠命令\n- Wire：支持通过 Wire 协议调用外部工具\n- Wire：将 `ApprovalRequestResolved` 重命名为 `ApprovalResponse`（向后兼容）\n\n## 0.79 (2026-01-19)\n\n- Skills：添加项目级 Skills 支持，从 `.agents/skills/`（或 `.kimi/skills/`、`.claude/skills/`）发现\n- Skills：统一 Skills 发现机制，采用分层加载（内置 → 用户 → 项目）；用户级 Skills 现在优先使用 `~/.config/agents/skills/`\n- Shell：斜杠命令自动补全支持模糊匹配\n- Shell：增强审批请求预览，显示 Shell 命令和 Diff 内容，使用 `Ctrl-E` 展开完整内容\n- Wire：添加 `ShellDisplayBlock` 类型，用于在审批请求中显示 Shell 命令\n- Shell：调整 `/help` 显示顺序，将键盘快捷键移至斜杠命令之前\n- Wire：对无效请求返回符合 JSON-RPC 2.0 规范的错误响应\n\n## 0.78 (2026-01-16)\n\n- CLI：为 Prompt Flow 添加 D2 流程图格式支持（`.d2` 扩展名）\n\n## 0.77 (2026-01-15)\n\n- Shell：修复 `/help` 和 `/changelog` 全屏分页显示中的换行问题\n- Shell：使用 `/model` 命令切换 Thinking 模式，取代 Tab 键\n- Config：添加 `default_thinking` 配置项（升级后需运行 `/model` 选择 Thinking 模式）\n- LLM：为始终使用 Thinking 模式的模型添加 `always_thinking` 能力\n- CLI：将 `--command`/`-c` 重命名为 `--prompt`/`-p`，保留 `--command`/`-c` 作为别名，移除 `--query`/`-q`\n- Wire：修复 Wire 模式下审批请求无法正常响应的问题\n- CLI：添加 `--prompt-flow` 选项，加载 Mermaid 流程图文件作为 Prompt Flow\n- Core：加载 Prompt Flow 后添加 `/begin` 斜杠命令以启动流程\n- Core：使用基于 Prompt Flow 的实现替换旧的 Ralph 循环\n\n## 0.76 (2026-01-12)\n\n- Tool：让 `ReadFile` 工具描述根据模型能力动态反映图片/视频支持情况\n- Tool：修复 TypeScript 文件（`.ts`、`.tsx`、`.mts`、`.cts`）被误识别为视频文件的问题\n- Shell：允许在 Shell 模式下使用部分斜杠命令（`/help`、`/exit`、`/version`、`/changelog`、`/feedback`）\n- Shell：改进 `/help` 显示，使用全屏分页器，展示斜杠命令、Skills 和键盘快捷键\n- Shell：改进 `/changelog` 和 `/mcp` 显示，采用一致的项目符号格式\n- Shell：在底部状态栏显示当前模型名称\n- Shell：添加 `Ctrl-/` 快捷键显示帮助\n\n## 0.75 (2026-01-09)\n\n- Tool：改进 `ReadFile` 工具描述\n- Skills：添加内置 `kimi-cli-help` Skill，解答 Kimi Code CLI 使用和配置问题\n\n## 0.74 (2026-01-09)\n\n- ACP：允许 ACP 客户端选择和切换模型（包含 Thinking 变体）\n- ACP：添加 `terminal-auth` 认证方式，用于配置流程\n- CLI：弃用 `--acp` 选项，请使用 `kimi acp` 子命令\n- Tool：`ReadFile` 工具现支持读取图片和视频文件\n\n## 0.73 (2026-01-09)\n\n- Skills：添加随软件包发布的内置 skill-creator Skill\n- Tool：在 `ReadFile` 路径中将 `~` 展开为用户主目录\n- MCP：确保 MCP 工具加载完成后再开始 Agent 循环\n- Wire：修复 Wire 模式无法接受有效 `cancel` 请求的问题\n- Setup：`/model` 命令现在可以切换所选供应商的所有可用模型\n- Lib：从 `kimi_cli.wire.types` 重新导出所有 Wire 消息类型，作为 `kimi_cli.wire.message` 的替代\n- Loop：添加 `max_ralph_iterations` 循环控制配置，限制额外的 Ralph 迭代次数\n- Config：将循环控制配置中的 `max_steps_per_run` 重命名为 `max_steps_per_turn`（向后兼容）\n- CLI：添加 `--max-steps-per-turn`、`--max-retries-per-step` 和 `--max-ralph-iterations` 选项，覆盖循环控制配置\n- SlashCmd：`/yolo` 命令现在切换 YOLO 模式\n- UI：在 Shell 模式的提示符中显示 YOLO 标识\n\n## 0.72 (2026-01-04)\n\n- Python：修复在 Python 3.14 上的安装问题\n\n## 0.71 (2026-01-04)\n\n- ACP：通过 ACP 客户端路由文件读写和 Shell 命令，实现同步编辑/输出\n- Shell：添加 `/model` 斜杠命令，在使用默认配置时切换默认模型并重新加载\n- Skills：添加 `/skill:<name>` 斜杠命令，按需加载 `SKILL.md` 指引\n- CLI：添加 `kimi info` 子命令，显示版本和协议信息（支持 `--json`）\n- CLI：添加 `kimi term` 命令，启动 Toad 终端 UI\n- Python：将默认工具/CI 版本升级到 3.14\n\n## 0.70 (2025-12-31)\n\n- CLI：添加 `--final-message-only`（及 `--quiet` 别名），在 Print 模式下仅输出最终的 assistant 消息\n- LLM：添加 `video_in` 模型能力，支持视频输入\n\n## 0.69 (2025-12-29)\n\n- Core：支持在 `~/.kimi/skills` 或 `~/.claude/skills` 中发现 Skills\n- Python：降低最低 Python 版本要求至 3.12\n- Nix：添加 flake 打包支持；可通过 `nix profile install .#kimi-cli` 安装或 `nix run .#kimi-cli` 运行\n- CLI：添加 `kimi-cli` 脚本别名；可通过 `uvx kimi-cli` 运行\n- Lib：将 LLM 配置验证移入 `create_llm`，配置缺失时返回 `None`\n\n## 0.68 (2025-12-24)\n\n- CLI：添加 `--config` 和 `--config-file` 选项，支持传入 JSON/TOML 配置\n- Core：`KimiCLI.create` 的 `config` 参数现在除了 `Path` 也支持 `Config` 类型\n- Tool：在 `WriteFile` 和 `StrReplaceFile` 的审批/结果中包含 diff 显示块\n- Wire：在审批请求中添加显示块（包括 diff），保持向后兼容\n- ACP：在工具结果和审批提示中显示文件 diff 预览\n- ACP：连接 ACP 客户端管理的 MCP 服务器\n- ACP：如果支持，在 ACP 客户端终端中运行 Shell 命令\n- Lib：添加 `KimiToolset.find` 方法，按类或名称查找工具\n- Lib：添加 `ToolResultBuilder.display` 方法，向工具结果追加显示块\n- MCP：添加 `kimi mcp auth` 及相关子命令，管理 MCP 授权\n\n## 0.67 (2025-12-22)\n\n- ACP：在单会话 ACP 模式（`kimi --acp`）中广播斜杠命令\n- MCP：添加 `mcp.client` 配置节，用于配置 MCP 工具调用超时等选项\n- Core：改进默认系统提示词和 `ReadFile` 工具\n- UI：修复某些罕见情况下 Ctrl-C 不工作的问题\n\n## 0.66 (2025-12-19)\n\n- Lib：在 `StatusUpdate` Wire 消息中提供 `token_usage` 和 `message_id`\n- Lib：添加 `KimiToolset.load_tools` 方法，支持依赖注入加载工具\n- Lib：添加 `KimiToolset.load_mcp_tools` 方法，加载 MCP 工具\n- Lib：将 `MCPTool` 从 `kimi_cli.tools.mcp` 移至 `kimi_cli.soul.toolset`\n- Lib：添加 `InvalidToolError`、`MCPConfigError` 和 `MCPRuntimeError` 异常类\n- Lib：使 Kimi Code CLI 详细异常类扩展 `ValueError` 或 `RuntimeError`\n- Lib：`KimiCLI.create` 和 `load_agent` 的 `mcp_configs` 参数支持传入验证后的 `list[fastmcp.mcp_config.MCPConfig]`\n- Lib：修复 `KimiCLI.create`、`load_agent`、`KimiToolset.load_tools` 和 `KimiToolset.load_mcp_tools` 的异常抛出\n- LLM：添加 `vertexai` 供应商类型，支持 Vertex AI\n- LLM：将 Gemini Developer API 的供应商类型从 `google_genai` 重命名为 `gemini`\n- Config：配置文件从 JSON 迁移至 TOML\n- MCP：后台并行连接 MCP 服务器，减少启动时间\n- MCP：连接 MCP 服务器时添加 `mcp-session-id` HTTP 头\n- Lib：将斜杠命令（原\"元命令\"）拆分为两组：Shell 级和 KimiSoul 级\n- Lib：在 `Soul` 协议中添加 `available_slash_commands` 属性\n- ACP：向 ACP 客户端广播 `/init`、`/compact` 和 `/yolo` 斜杠命令\n- SlashCmd：添加 `/mcp` 斜杠命令，显示 MCP 服务器和工具状态\n\n## 0.65 (2025-12-16)\n\n- Lib：支持通过 `Session.create(work_dir, session_id)` 创建命名会话\n- CLI：指定的会话 ID 不存在时自动创建新会话\n- CLI：退出时删除空会话，列表中忽略上下文文件为空的会话\n- UI：改进会话回放\n- Lib：在 `LLM` 类中添加 `model_config: LLMModel | None` 和 `provider_config: LLMProvider | None` 属性\n- MetaCmd：添加 `/usage` 元命令，为 Kimi Code 用户显示 API 使用情况\n\n## 0.64 (2025-12-15)\n\n- UI：修复 Windows 上 UTF-16 代理字符输入问题\n- Core：添加 `/sessions` 元命令，列出现有会话并切换到选中的会话\n- CLI：添加 `--session/-S` 选项，指定要恢复的会话 ID\n- MCP：添加 `kimi mcp` 子命令组，管理全局 MCP 配置文件 `~/.kimi/mcp.json`\n\n## 0.63 (2025-12-12)\n\n- Tool：修复 `FetchURL` 工具通过服务获取失败时输出不正确的问题\n- Tool：在 `Shell` 工具中使用 `bash` 而非 `sh`，提高兼容性\n- Tool：修复 Windows 上 `Grep` 工具的 Unicode 解码错误\n- ACP：通过 `kimi acp` 子命令支持 ACP 会话续接（列出/加载会话）\n- Lib：添加 `Session.find` 和 `Session.list` 静态方法，查找和列出会话\n- ACP：调用 `SetTodoList` 工具时在客户端更新 Agent 计划\n- UI：防止以 `/` 开头的普通消息被误当作元命令处理\n\n## 0.62 (2025-12-08)\n\n- ACP：修复工具结果（包括 Shell 工具输出）在 Zed 等 ACP 客户端中不显示的问题\n- ACP：修复与最新版 Zed IDE (0.215.3) 的兼容性\n- Tool：Windows 上使用 PowerShell 替代 CMD，提升可用性\n- Core：修复工作目录中存在损坏符号链接时的启动崩溃\n- Core：添加内置 `okabe` Agent 文件，启用 `SendDMail` 工具\n- CLI：添加 `--agent` 选项，指定内置 Agent（如 `default`、`okabe`）\n- Core：改进压缩逻辑，更好地保留相关信息\n\n## 0.61 (2025-12-04)\n\n- Lib：修复作为库使用时的日志问题\n- Tool：加强文件路径检查，防止共享前缀逃逸\n- LLM：改进与部分第三方 OpenAI Responses 和 Anthropic API 供应商的兼容性\n\n## 0.60 (2025-12-01)\n\n- LLM：修复 Kimi 和 OpenAI 兼容供应商的交错思考问题\n\n## 0.59 (2025-11-28)\n\n- Core：将上下文文件位置移至 `.kimi/sessions/{workdir_md5}/{session_id}/context.jsonl`\n- Lib：将 `WireMessage` 类型别名移至 `kimi_cli.wire.message`\n- Lib：添加 `kimi_cli.wire.message.Request` 类型别名，用于请求消息（目前仅包含 `ApprovalRequest`）\n- Lib：添加 `kimi_cli.wire.message.is_event`、`is_request` 和 `is_wire_message` 工具函数，检查 Wire 消息类型\n- Lib：添加 `kimi_cli.wire.serde` 模块，用于 Wire 消息的序列化和反序列化\n- Lib：修改 `StatusUpdate` Wire 消息，不再使用 `kimi_cli.soul.StatusSnapshot`\n- Core：在会话目录中记录 Wire 消息到 JSONL 文件\n- Core：引入 `TurnBegin` Wire 消息，标记每个 Agent 轮次的开始\n- UI：Shell 模式下用面板重新打印用户输入\n- Lib：添加 `Session.dir` 属性，获取会话目录路径\n- UI：改进多个并行子代理时的\"本会话批准\"体验\n- Wire：重新实现 Wire 服务器模式（通过 `--wire` 选项启用）\n- Lib：重命名类以保持一致性：`ShellApp` → `Shell`，`PrintApp` → `Print`，`ACPServer` → `ACP`，`WireServer` → `WireOverStdio`\n- Lib：重命名方法以保持一致性：`KimiCLI.run_shell_mode` → `run_shell`，`run_print_mode` → `run_print`，`run_acp_server` → `run_acp`，`run_wire_server` → `run_wire_stdio`\n- Lib：添加 `KimiCLI.run` 方法，使用给定用户输入运行一轮并产生 Wire 消息\n- Print：修复 stream-json 打印模式输出刷新不正确的问题\n- LLM：改进与部分 OpenAI 和 Anthropic API 供应商的兼容性\n- Core：修复使用 Anthropic API 时压缩后的聊天供应商错误\n\n## 0.58 (2025-11-21)\n\n- Core：修复使用 `extend` 时 Agent 规格文件的字段继承问题\n- Core：支持在子代理中使用 MCP 工具\n- Tool：添加 `CreateSubagent` 工具，动态创建子代理（默认 Agent 中未启用）\n- Tool：Kimi Code 方案在 `FetchURL` 工具中使用 MoonshotFetch 服务\n- Tool：截断 Grep 工具输出，避免超出 token 限制\n\n## 0.57 (2025-11-20)\n\n- LLM：修复思考开关未开启时的 Google GenAI 供应商问题\n- UI：改进审批请求措辞\n- Tool：移除 `PatchFile` 工具\n- Tool：将 `Bash`/`CMD` 工具重命名为 `Shell` 工具\n- Tool：将 `Task` 工具移至 `kimi_cli.tools.multiagent` 模块\n\n## 0.56 (2025-11-19)\n\n- LLM：添加 Google GenAI 供应商支持\n\n## 0.55 (2025-11-18)\n\n- Lib：添加 `kimi_cli.app.enable_logging` 函数，直接使用 `KimiCLI` 类时启用日志\n- Core：修复 Agent 规格文件中的相对路径解析\n- Core：防止 LLM API 连接失败时 panic\n- Tool：优化 `FetchURL` 工具，改进内容提取\n- Tool：将 MCP 工具调用超时增加到 60 秒\n- Tool：在 `Glob` 工具中提供更好的错误消息（当模式为 `**` 时）\n- ACP：修复思考内容显示不正确的问题\n- UI：Shell 模式的小幅 UI 改进\n\n## 0.54 (2025-11-13)\n\n- Lib：将 `WireMessage` 从 `kimi_cli.wire.message` 移至 `kimi_cli.wire`\n- Print：修复 `stream-json` 输出格式缺少最后一条助手消息的问题\n- UI：当 API 密钥被 `KIMI_API_KEY` 环境变量覆盖时添加警告\n- UI：审批请求时发出提示音\n- Core：修复 Windows 上的上下文压缩和清除问题\n\n## 0.53 (2025-11-12)\n\n- UI：移除控制台输出中不必要的尾部空格\n- Core：存在不支持的消息部分时抛出错误\n- MetaCmd：添加 `/yolo` 元命令，启动后启用 YOLO 模式\n- Tool：为 MCP 工具添加审批请求\n- Tool：在默认 Agent 中禁用 `Think` 工具\n- CLI：未指定 `--thinking` 时恢复上次的思考模式\n- CLI：修复 PyInstaller 打包的二进制文件中 `/reload` 不工作的问题\n\n## 0.52 (2025-11-10)\n\n- CLI：移除 `--ui` 选项，改用 `--print`、`--acp` 和 `--wire` 标志（Shell 仍为默认）\n- CLI：更直观的会话续接行为\n- Core：为 LLM 空响应添加重试\n- Tool：Windows 上将 `Bash` 工具改为 `CMD` 工具\n- UI：修复退格后的补全问题\n- UI：修复浅色背景下代码块的渲染问题\n\n## 0.51 (2025-11-08)\n\n- Lib：将 `Soul.model` 重命名为 `Soul.model_name`\n- Lib：将 `LLMModelCapability` 重命名为 `ModelCapability` 并移至 `kimi_cli.llm`\n- Lib：在 `ModelCapability` 中添加 `\"thinking\"`\n- Lib：移除 `LLM.supports_image_in` 属性\n- Lib：添加必需的 `Soul.model_capabilities` 属性\n- Lib：将 `KimiSoul.set_thinking_mode` 重命名为 `KimiSoul.set_thinking`\n- Lib：添加 `KimiSoul.thinking` 属性\n- UI：改进 LLM 模型能力检查和提示\n- UI：`/clear` 元命令时清屏\n- Tool：支持 Windows 上自动下载 ripgrep\n- CLI：添加 `--thinking` 选项，以思考模式启动\n- ACP：ACP 模式支持思考内容\n\n## 0.50 (2025-11-07)\n\n- 改进 UI 外观和体验\n- 改进 Task 工具可观测性\n\n## 0.49 (2025-11-06)\n\n- 小幅用户体验改进\n\n## 0.48 (2025-11-06)\n\n- 支持 Kimi K2 思考模式\n\n## 0.47 (2025-11-05)\n\n- 修复某些环境下 Ctrl-W 不工作的问题\n- 搜索服务未配置时不加载 SearchWeb 工具\n\n## 0.46 (2025-11-03)\n\n- 引入 Wire over stdio 用于本地 IPC（实验性，可能变更）\n- 支持 Anthropic 供应商类型\n\n- 修复 PyInstaller 打包的二进制文件因入口点错误而无法工作的问题\n\n## 0.45 (2025-10-31)\n\n- 允许 `KIMI_MODEL_CAPABILITIES` 环境变量覆盖模型能力\n- 添加 `--no-markdown` 选项禁用 Markdown 渲染\n- 支持 `openai_responses` LLM 供应商类型\n\n- 修复续接会话时的崩溃问题\n\n## 0.44 (2025-10-30)\n\n- 改进启动时间\n\n- 修复用户输入中可能出现的无效字节\n\n## 0.43 (2025-10-30)\n\n- 基础 Windows 支持（实验性）\n- 环境变量覆盖 base URL 或 API 密钥时显示警告\n- 如果 LLM 模型支持，则支持图片输入\n- 续接会话时回放近期上下文历史\n\n- 确保执行 Shell 命令后换行\n\n## 0.42 (2025-10-28)\n\n- 支持 Ctrl-J 或 Alt-Enter 插入换行\n\n- 模式切换快捷键从 Ctrl-K 改为 Ctrl-X\n- 改进整体健壮性\n\n- 修复 ACP 服务器 `no attribute` 错误\n\n## 0.41 (2025-10-26)\n\n- 修复 Glob 工具未找到匹配文件时的 bug\n- 确保使用 UTF-8 编码读取文件\n\n- Shell 模式下禁用从 stdin 读取命令/查询\n- 澄清 `/setup` 元命令中的 API 平台选择\n\n## 0.40 (2025-10-24)\n\n- 支持 `ESC` 键中断 Agent 循环\n\n- 修复某些罕见情况下的 SSL 证书验证错误\n- 修复 Bash 工具中可能的解码错误\n\n## 0.39 (2025-10-24)\n\n- 修复上下文压缩阈值检查\n- 修复 Shell 会话中设置 SOCKS 代理时的 panic\n\n## 0.38 (2025-10-24)\n\n- 小幅用户体验改进\n\n## 0.37 (2025-10-24)\n\n- 修复更新检查\n\n## 0.36 (2025-10-24)\n\n- 添加 `/debug` 元命令用于调试上下文\n- 添加自动上下文压缩\n- 添加审批请求机制\n- 添加 `--yolo` 选项自动批准所有操作\n- 渲染 Markdown 内容以提高可读性\n\n- 修复中断元命令时的\"未知错误\"消息\n\n## 0.35 (2025-10-22)\n\n- 小幅 UI 改进\n- 系统中未找到 ripgrep 时自动下载\n- `--print` 模式下始终批准工具调用\n- 添加 `/feedback` 元命令\n\n## 0.34 (2025-10-21)\n\n- 添加 `/update` 元命令检查更新，并在后台自动更新\n- 支持在原始 Shell 模式下运行交互式 Shell 命令\n- 添加 `/setup` 元命令设置 LLM 供应商和模型\n- 添加 `/reload` 元命令重新加载配置\n\n## 0.33 (2025-10-18)\n\n- 添加 `/version` 元命令\n- 添加原始 Shell 模式，可通过 Ctrl-K 切换\n- 在底部状态栏显示快捷键\n\n- 修复日志重定向\n- 合并重复的输入历史\n\n## 0.32 (2025-10-16)\n\n- 添加底部状态栏\n- 支持文件路径自动补全（`@filepath`）\n\n- 不在用户输入中间自动补全元命令\n\n## 0.31 (2025-10-14)\n\n- 真正修复 Ctrl-C 中断步骤的问题\n\n## 0.30 (2025-10-14)\n\n- 添加 `/compact` 元命令，允许手动压缩上下文\n\n- 修复上下文为空时的 `/clear` 元命令\n\n## 0.29 (2025-10-14)\n\n- Shell 模式下支持 Enter 键接受补全\n- Shell 模式下跨会话记住用户输入历史\n- 添加 `/reset` 元命令作为 `/clear` 的别名\n\n- 修复 Ctrl-C 中断步骤的问题\n\n- 在 Kimi Koder Agent 中禁用 `SendDMail` 工具\n\n## 0.28 (2025-10-13)\n\n- 添加 `/init` 元命令分析代码库并生成 `AGENTS.md` 文件\n- 添加 `/clear` 元命令清除上下文\n\n- 修复 `ReadFile` 输出\n\n## 0.27 (2025-10-11)\n\n- 添加 `--mcp-config-file` 和 `--mcp-config` 选项加载 MCP 配置\n\n- 将 `--agent` 选项重命名为 `--agent-file`\n\n## 0.26 (2025-10-11)\n\n- 修复 `--output-format stream-json` 模式下可能的编码错误\n\n## 0.25 (2025-10-11)\n\n- 将包名从 `ensoul` 重命名为 `kimi-cli`\n- 将 `ENSOUL_*` 内置系统提示词参数重命名为 `KIMI_*`\n- 进一步解耦 `App` 与 `Soul`\n- 拆分 `Soul` 协议和 `KimiSoul` 实现以提高模块化\n\n## 0.24 (2025-10-10)\n\n- 修复 ACP `cancel` 方法\n\n## 0.23 (2025-10-09)\n\n- 在 Agent 文件中添加 `extend` 字段支持 Agent 文件扩展\n- 在 Agent 文件中添加 `exclude_tools` 字段支持排除工具\n- 在 Agent 文件中添加 `subagents` 字段支持定义子代理\n\n## 0.22 (2025-10-09)\n\n- 改进 `SearchWeb` 和 `FetchURL` 工具调用可视化\n- 改进搜索结果输出格式\n\n## 0.21 (2025-10-09)\n\n- 添加 `--print` 选项作为 `--ui print` 的快捷方式，`--acp` 选项作为 `--ui acp` 的快捷方式\n- 支持 `--output-format stream-json` 以 JSON 格式输出\n- 添加 `SearchWeb` 工具，使用 `services.moonshot_search` 配置。需要在配置文件中配置 `\"services\": {\"moonshot_search\": {\"api_key\": \"your-search-api-key\"}}`\n- 添加 `FetchURL` 工具\n- 添加 `Think` 工具\n- 添加 `PatchFile` 工具，Kimi Koder Agent 中未启用\n- 在 Kimi Koder Agent 中启用 `SendDMail` 和 `Task` 工具，改进工具提示词\n- 添加 `ENSOUL_NOW` 内置系统提示词参数\n\n- 改进 `/release-notes` 外观\n- 改进工具描述\n- 改进工具输出截断\n\n## 0.20 (2025-09-30)\n\n- 添加 `--ui acp` 选项启动 Agent Client Protocol (ACP) 服务器\n\n## 0.19 (2025-09-29)\n\n- print UI 支持管道输入的 stdin\n- 支持 `--input-format=stream-json` 用于管道输入的 JSON\n\n- 未启用 `SendDMail` 时不在上下文中包含 `CHECKPOINT` 消息\n\n## 0.18 (2025-09-29)\n\n- 支持 LLM 模型配置中的 `max_context_size`，配置最大上下文大小（token 数）\n\n- 改进 `ReadFile` 工具描述\n\n## 0.17 (2025-09-29)\n\n- 修复超过最大步数时错误消息中的步数\n- 修复 `kimi_run` 中的历史文件断言错误\n- 修复 print 模式和单命令 Shell 模式中的错误处理\n- 为 LLM API 连接错误和超时错误添加重试\n\n- 将默认 max-steps-per-run 增加到 100\n\n## 0.16.0 (2025-09-26)\n\n- 添加 `SendDMail` 工具（Kimi Koder 中禁用，可在自定义 Agent 中启用）\n\n- 可通过 `_history_file` 参数在创建新会话时指定会话历史文件\n\n## 0.15.0 (2025-09-26)\n\n- 改进工具健壮性\n\n## 0.14.0 (2025-09-25)\n\n- 添加 `StrReplaceFile` 工具\n\n- 强调使用与用户相同的语言\n\n## 0.13.0 (2025-09-25)\n\n- 添加 `SetTodoList` 工具\n- 在 LLM API 调用中添加 `User-Agent`\n\n- 改进系统提示词和工具描述\n- 改进 LLM 错误消息\n\n## 0.12.0 (2025-09-24)\n\n- 添加 `print` UI 模式，可通过 `--ui print` 选项使用\n- 添加日志和 `--debug` 选项\n\n- 捕获 EOF 错误以改善体验\n\n## 0.11.1 (2025-09-22)\n\n- 将 `max_retry_per_step` 重命名为 `max_retries_per_step`\n\n## 0.11.0 (2025-09-22)\n\n- 添加 `/release-notes` 命令\n- 为 LLM API 错误添加重试\n- 添加循环控制配置，如 `{\"loop_control\": {\"max_steps_per_run\": 50, \"max_retry_per_step\": 3}}`\n\n- 改进 `read_file` 工具的极端情况处理\n- 禁止 Ctrl-C 退出 CLI，强制使用 Ctrl-D 或 `exit` 退出\n\n## 0.10.1 (2025-09-18)\n\n- 小幅改进斜杠命令外观\n- 改进 `glob` 工具\n\n## 0.10.0 (2025-09-17)\n\n- 添加 `read_file` 工具\n- 添加 `write_file` 工具\n- 添加 `glob` 工具\n- 添加 `task` 工具\n\n- 改进工具调用可视化\n- 改进会话管理\n- `--continue` 会话时恢复上下文使用量\n\n## 0.9.0 (2025-09-15)\n\n- 移除 `--session` 和 `--continue` 选项\n\n## 0.8.1 (2025-09-14)\n\n- 修复配置模型转储\n\n## 0.8.0 (2025-09-14)\n\n- 添加 `shell` 工具和基础系统提示词\n- 添加工具调用可视化\n- 添加上下文使用量计数\n- 支持中断 Agent 循环\n- 支持项目级 `AGENTS.md`\n- 支持 YAML 定义的自定义 Agent\n- 支持通过 `kimi -c` 执行一次性任务\n"
  },
  {
    "path": "examples/.gitignore",
    "content": "uv.lock\n"
  },
  {
    "path": "examples/custom-echo-soul/README.md",
    "content": "# Example: Custom Echo Soul\n\nThis example demonstrates how to write a custom `Soul` (agent loop) implementation that can be used with Kimi Code CLI's `Shell` UI.\n\n```sh\ncd examples/custom-echo-soul\nuv sync --reinstall\nuv run main.py\n```\n"
  },
  {
    "path": "examples/custom-echo-soul/main.py",
    "content": "import asyncio\nfrom typing import Any\n\nfrom kimi_cli.llm import ALL_MODEL_CAPABILITIES, ModelCapability\nfrom kimi_cli.soul import StatusSnapshot, wire_send\nfrom kimi_cli.ui.shell import Shell\nfrom kimi_cli.utils.slashcmd import SlashCommand\nfrom kimi_cli.wire.types import ContentPart, StepBegin, TextPart\n\n\nclass EchoSoul:\n    def __init__(self) -> None:\n        pass\n\n    @property\n    def name(self) -> str:\n        return \"EchoSoul\"\n\n    @property\n    def model_name(self) -> str:\n        return \"mock\"\n\n    @property\n    def model_capabilities(self) -> set[ModelCapability]:\n        return ALL_MODEL_CAPABILITIES\n\n    @property\n    def status(self) -> StatusSnapshot:\n        return StatusSnapshot(context_usage=0.0)\n\n    @property\n    def available_slash_commands(self) -> list[SlashCommand[Any]]:\n        return []\n\n    async def run(self, user_input: str | list[ContentPart]) -> None:\n        wire_send(StepBegin(n=1))\n        if isinstance(user_input, str):\n            wire_send(TextPart(text=user_input))\n        else:\n            for part in user_input:\n                wire_send(part)\n\n\nif __name__ == \"__main__\":\n    soul = EchoSoul()\n    ui = Shell(soul)\n    asyncio.run(ui.run())\n"
  },
  {
    "path": "examples/custom-echo-soul/pyproject.toml",
    "content": "[project]\nname = \"custom-echo-soul\"\nversion = \"0.1.0\"\ndescription = \"Add your description here\"\nreadme = \"README.md\"\nrequires-python = \">=3.13\"\ndependencies = [\"kimi-cli\", \"kosong\"]\n\n[tool.uv.sources]\nkimi-cli = { path = \"../../\" }\n"
  },
  {
    "path": "examples/custom-kimi-soul/README.md",
    "content": "# Example: Custom Kimi Soul\n\nThis example demonstrates how to extend the `KimiSoul` (builtin agent loop) to customize its behavior and use it with the `Shell` UI.\n\n```sh\ncd examples/custom-kimi-soul\nuv sync --reinstall\nuv run main.py\n```\n"
  },
  {
    "path": "examples/custom-kimi-soul/main.py",
    "content": "import asyncio\nimport os\nfrom pathlib import Path\nfrom typing import override\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, Toolset\nfrom kosong.tooling.simple import SimpleToolset\nfrom pydantic import BaseModel, Field, SecretStr\n\nfrom kimi_cli.auth.oauth import OAuthManager\nfrom kimi_cli.config import LLMModel, LLMProvider, get_default_config\nfrom kimi_cli.llm import LLM, create_llm\nfrom kimi_cli.session import Session\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell import Shell\nfrom kimi_cli.wire.types import ContentPart, ToolReturnValue\n\n\nclass HakimiSoul(KimiSoul):\n    @staticmethod\n    async def create(\n        llm: LLM | None,\n        system_prompt: str,\n        toolset: Toolset,\n        session: Session | None = None,\n        work_dir: Path | None = None,\n    ) -> \"HakimiSoul\":\n        config = get_default_config()\n        kaos_work_dir = KaosPath.unsafe_from_local_path(work_dir) if work_dir else KaosPath.cwd()\n        session = session or await Session.create(kaos_work_dir)\n        runtime = await Runtime.create(\n            config=config,\n            oauth=OAuthManager(config),\n            llm=llm,\n            session=session,\n            yolo=True,\n        )\n        agent = Agent(\n            name=\"HakimiAgent\",\n            system_prompt=system_prompt,\n            toolset=toolset,\n            runtime=runtime,\n        )\n        context = Context(session.context_file)\n        return HakimiSoul(agent, context=context)\n\n    @property\n    @override\n    def name(self) -> str:\n        return \"Hakimi\"\n\n    @override\n    async def run(self, user_input: str | list[ContentPart]) -> None:\n        if not self._context.history:\n            await self._context.restore()\n        await super().run(user_input)\n\n\nclass MyBashParams(BaseModel):\n    command: str = Field(description=\"The bash command to execute.\")\n\n\nclass MyBashTool(CallableTool2):\n    name: str = \"MyBashTool\"\n    description: str = \"A tool to execute bash commands.\"\n    params: type[MyBashParams] = MyBashParams\n\n    async def __call__(self, params: MyBashParams) -> ToolReturnValue:\n        import subprocess\n\n        result = subprocess.run(params.command, shell=True, capture_output=True, text=True)\n        if result.returncode != 0:\n            return ToolError(\n                output=result.stdout,\n                message=f\"Command failed with error: {result.stderr}\",\n                brief=\"Bash command failed\",\n            )\n        return ToolOk(output=result.stdout)\n\n\nasync def main():\n    toolset = SimpleToolset()\n    toolset += MyBashTool()\n\n    soul = await HakimiSoul.create(\n        llm=create_llm(\n            LLMProvider(\n                type=\"kimi\",\n                base_url=os.getenv(\"KIMI_BASE_URL\") or \"https://api.moonshot.ai/v1\",\n                api_key=SecretStr(os.getenv(\"KIMI_API_KEY\") or \"\"),\n            ),\n            LLMModel(\n                provider=\"kimi\",\n                model=\"kimi-k2-turbo-preview\",\n                max_context_size=250_000,\n            ),\n        ),\n        system_prompt=\"You are Hakimi, an AI assistant that helps users with various tasks.\",\n        toolset=toolset,\n    )\n    ui = Shell(soul)\n    await ui.run()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/custom-kimi-soul/pyproject.toml",
    "content": "[project]\nname = \"custom-kimi-soul\"\nversion = \"0.1.0\"\ndescription = \"Add your description here\"\nreadme = \"README.md\"\nrequires-python = \">=3.13\"\ndependencies = [\"kimi-cli\", \"kosong\"]\n\n[tool.uv.sources]\nkimi-cli = { path = \"../../\" }\n"
  },
  {
    "path": "examples/custom-tools/README.md",
    "content": "# Example: Custom Tools\n\nThis example demonstrates how to write custom tools for Kimi Code CLI and add them to your agent spec file.\n\n```sh\ncd examples/custom-tools\nuv sync --reinstall\nuv run main.py\n```\n"
  },
  {
    "path": "examples/custom-tools/main.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.app import KimiCLI, enable_logging\nfrom kimi_cli.session import Session\n\n\nasync def main():\n    enable_logging()\n    session = await Session.create(KaosPath.cwd())\n    myagent = Path(__file__).parent / \"myagent.yaml\"\n    instance = await KimiCLI.create(session, agent_file=myagent)\n    await instance.run_print(\n        input_format=\"text\",\n        output_format=\"text\",\n        command=\"What tools do you have?\",\n    )\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/custom-tools/my_tools/__init__.py",
    "content": ""
  },
  {
    "path": "examples/custom-tools/my_tools/ls.py",
    "content": "from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\n\nclass Params(BaseModel):\n    directory: str = Field(description=\"The directory to list files from.\", default=\".\")\n\n\nclass Ls(CallableTool2):\n    name: str = \"Ls\"\n    description: str = \"List files in a directory.\"\n    params: type[Params] = Params\n\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        import os\n\n        try:\n            files = os.listdir(params.directory)\n            output = \"\\n\".join(files)\n            return ToolOk(output=output)\n        except Exception as e:\n            return ToolError(\n                output=\"\",\n                message=str(e),\n                brief=\"Failed to list files\",\n            )\n"
  },
  {
    "path": "examples/custom-tools/myagent.yaml",
    "content": "version: 1\nagent:\n  extend: default\n  tools:\n    - \"kimi_cli.tools.multiagent:Task\"\n    - \"kimi_cli.tools.todo:SetTodoList\"\n    - \"kimi_cli.tools.shell:Shell\"\n    - \"kimi_cli.tools.file:ReadFile\"\n    - \"kimi_cli.tools.file:Glob\"\n    - \"kimi_cli.tools.file:Grep\"\n    - \"kimi_cli.tools.file:WriteFile\"\n    - \"kimi_cli.tools.file:StrReplaceFile\"\n    - \"kimi_cli.tools.web:SearchWeb\"\n    - \"kimi_cli.tools.web:FetchURL\"\n    - \"my_tools.ls:Ls\" # custom tool\n"
  },
  {
    "path": "examples/custom-tools/pyproject.toml",
    "content": "[project]\nname = \"custom-tools\"\nversion = \"0.1.0\"\ndescription = \"Add your description here\"\nreadme = \"README.md\"\nrequires-python = \">=3.13\"\ndependencies = [\"kimi-cli\", \"kosong\"]\n\n[tool.uv.sources]\nkimi-cli = { path = \"../../\" }\n"
  },
  {
    "path": "examples/kimi-cli-stream-json/README.md",
    "content": "# Example: Kimi Code CLI Stream JSON\n\nThis example demonstrates how to run Kimi Code CLI in a subprocess and interact with it using JSON messages over standard input and output.\n\n```sh\ncd examples/kimi-cli-stream-json\nuv run main.py\n```\n"
  },
  {
    "path": "examples/kimi-cli-stream-json/main.py",
    "content": "import asyncio\nimport json\nimport os\n\nKIMI_CLI_COMMAND = \"uv run --project ../../ kimi\"\n\n\nasync def main():\n    proc = await asyncio.create_subprocess_exec(\n        *KIMI_CLI_COMMAND.split(),\n        \"--work-dir\",\n        os.getcwd(),\n        \"--print\",\n        \"--input-format\",\n        \"stream-json\",\n        \"--output-format\",\n        \"stream-json\",\n        stdin=asyncio.subprocess.PIPE,\n        stdout=asyncio.subprocess.PIPE,\n    )\n\n    assert proc.stdout is not None, \"stdout is None\"\n    assert proc.stdin is not None, \"stdin is None\"\n\n    user_message = {\n        \"role\": \"user\",\n        \"content\": \"How many lines of code are there in the current working directory?\",\n    }\n    proc.stdin.write(json.dumps(user_message).encode(\"utf-8\") + b\"\\n\")\n    await proc.stdin.drain()\n\n    while True:\n        line = await proc.stdout.readline()\n        if not line:\n            break\n        message = json.loads(line.decode(\"utf-8\"))\n        print(\"Received message:\", message)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/kimi-cli-stream-json/pyproject.toml",
    "content": "[project]\nname = \"kimi-cli-stream-json\"\nversion = \"0.1.0\"\ndescription = \"Add your description here\"\nreadme = \"README.md\"\nrequires-python = \">=3.13\"\ndependencies = []\n"
  },
  {
    "path": "examples/kimi-cli-wire-messages/README.md",
    "content": "# Example: Kimi Code CLI Wire Messages\n\nThis example demonstrates how to create and run a Kimi Code CLI instance with raw Wire message output.\n\n```sh\ncd examples/kimi-cli-wire-messages\nuv sync --reinstall\nuv run main.py\n```\n"
  },
  {
    "path": "examples/kimi-cli-wire-messages/main.py",
    "content": "import asyncio\n\nfrom kaos.path import KaosPath\nfrom rich import print\n\nfrom kimi_cli.app import KimiCLI, enable_logging\nfrom kimi_cli.session import Session\n\n\nasync def main():\n    enable_logging()\n    session = await Session.create(KaosPath.cwd())\n    instance = await KimiCLI.create(session)\n    user_input = \"Hello!\"\n\n    async for msg in instance.run(\n        user_input=user_input,\n        cancel_event=asyncio.Event(),\n        merge_wire_messages=True,\n    ):\n        print(msg)\n\n    # print the last assistant message\n    print(instance.soul.context.history[-1])\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/kimi-cli-wire-messages/pyproject.toml",
    "content": "[project]\nname = \"kimi-cli-wire-messages\"\nversion = \"0.1.0\"\ndescription = \"Add your description here\"\nreadme = \"README.md\"\nrequires-python = \">=3.13\"\ndependencies = [\n    \"kimi-cli\",\n    \"rich\",\n]\n\n[tool.uv.sources]\nkimi-cli = { path = \"../../\" }\n"
  },
  {
    "path": "examples/kimi-psql/README.md",
    "content": "# kimi-psql\n\nAI-assisted PostgreSQL interactive terminal.\n\n## Features\n\n- **AI Mode** (default): Natural language to SQL - AI executes read-only queries\n- **PSQL Mode**: Full interactive psql experience (Ctrl-X to switch)\n- **Read-only by design**: AI mode uses read-only transactions for safety\n\n## Usage\n\n```sh\ncd examples/kimi-psql\nuv sync --reinstall\n\n# Connection URL with password\nuv run main.py --conninfo 'postgresql://user:pass@host/db'\n\n# Traditional psql arguments with PGPASSWORD env var\nPGPASSWORD=yourpass uv run main.py -h localhost -U postgres -d mydb\n```\n\n## Example\n\n```\nkimi-psql✨ show all users who registered last month\n• Used ExecuteSql ({\"sql\": \"SELECT * FROM users WHERE ...\"})\n\n  id | name  | created_at\n  ---+-------+------------\n  42 | Alice | 2024-11-15\n\nkimi-psql✨ ^X    # Switch to PSQL mode\npostgres=# \\d users\n...\n```\n"
  },
  {
    "path": "examples/kimi-psql/agent.yaml",
    "content": "version: 1\n\nagent:\n  extend: default\n  name: kimi-psql\n  system_prompt_args:\n    ROLE_ADDITIONAL: |\n      You are now a PostgreSQL assistant with read-only access to a PostgreSQL database.\n\n      Database Tools:\n      - ExecuteSql: Execute read-only SQL queries in the connected PostgreSQL database\n\n      When the user asks about data or wants to run queries:\n      1. Use the ExecuteSql tool to run the appropriate SQL query\n      2. Use proper PostgreSQL SQL syntax (psql meta-commands like \\d, \\dt are NOT supported)\n      3. For database introspection, use SQL queries from information_schema or pg_catalog\n\n      Examples:\n      - User: \"show all tables\"\n        -> Use ExecuteSql with: SELECT tablename FROM pg_tables WHERE schemaname = 'public';\n      - User: \"describe users table\"\n        -> Use ExecuteSql with: SELECT column_name, data_type FROM information_schema.columns\n           WHERE table_name = 'users';\n      - User: \"count users\" -> Use ExecuteSql with: SELECT COUNT(*) FROM users;\n      - User: \"find orders from last week\"\n        -> Use ExecuteSql with: SELECT * FROM orders WHERE created_at >= NOW() - INTERVAL '7 days';\n      - User: \"save this query to a file\"\n        -> Use WriteFile to save the SQL query\n      - User: \"run the migration.sql file\"\n        -> Use ReadFile to read the SQL file, then explain what it does (ExecuteSql is read-only)\n\n      For write operations (INSERT, UPDATE, DELETE), return the SQL in a markdown code block\n      for the user to execute manually.\n"
  },
  {
    "path": "examples/kimi-psql/main.py",
    "content": "\"\"\"\nkimi-psql: AI-assisted PostgreSQL interactive terminal.\n\nUsage:\n    uv run main.py -h localhost -p 5432 -U postgres -d mydb\n\"\"\"\n\nimport asyncio\nimport contextlib\nimport fcntl\nimport os\nimport pty\nimport select\nimport signal\nimport sys\nimport termios\nimport tty\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import LiteralString, cast\n\nimport psycopg\nimport typer\nfrom kaos.path import KaosPath\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.formatted_text import FormattedText\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.patch_stdout import patch_stdout\nfrom pydantic import BaseModel, Field, SecretStr\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.text import Text\n\nfrom kimi_cli.auth.oauth import OAuthManager\nfrom kimi_cli.config import LLMModel, LLMProvider\nfrom kimi_cli.llm import LLM, create_llm\nfrom kimi_cli.session import Session\nfrom kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, run_soul\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell.visualize import visualize\nfrom kimi_cli.wire.types import StatusUpdate\n\n\nclass ExecuteSqlParams(BaseModel):\n    \"\"\"Parameters for ExecuteSql tool.\"\"\"\n\n    sql: str = Field(description=\"The SQL query to execute in the connected PostgreSQL database\")\n\n\nclass ExecuteSql(CallableTool2[ExecuteSqlParams]):\n    \"\"\"Execute read-only SQL query in the connected PostgreSQL database.\"\"\"\n\n    name: str = \"ExecuteSql\"\n    description: str = (\n        \"Execute a READ-ONLY SQL query in the connected PostgreSQL database. \"\n        \"Use this tool for SELECT queries and database introspection queries. \"\n        \"This tool CANNOT execute write operations (INSERT, UPDATE, DELETE, DROP, etc.). \"\n        \"For write operations, return the SQL in a markdown code block for the user to \"\n        \"execute manually. \"\n        \"Note: psql meta-commands (\\\\d, \\\\dt, etc.) are NOT supported - use SQL queries \"\n        \"instead (e.g., SELECT * FROM pg_tables WHERE schemaname = 'public').\"\n    )\n    params: type[ExecuteSqlParams] = ExecuteSqlParams\n\n    def __init__(self, conninfo: str):\n        \"\"\"\n        Initialize ExecuteSql tool with database connection info.\n\n        Args:\n            conninfo: PostgreSQL connection string\n                (e.g., \"host=localhost port=5432 dbname=mydb user=postgres\")\n        \"\"\"\n        super().__init__()\n        self._conninfo = conninfo\n\n    async def __call__(self, params: ExecuteSqlParams) -> ToolReturnValue:\n        try:\n            # Connect and execute in read-only transaction\n            async with (\n                await psycopg.AsyncConnection.connect(self._conninfo, autocommit=False) as conn,\n                conn.cursor() as cur,\n            ):\n                # Set read-only mode\n                await conn.set_read_only(True)\n                # Cast to LiteralString for type checker - SQL is validated at runtime\n                await cur.execute(cast(LiteralString, params.sql))\n\n                # Check if query returns results\n                if cur.description:\n                    rows = await cur.fetchall()\n                    if not rows:\n                        return ToolOk(output=\"Query returned no rows.\")\n\n                    # Format as table\n                    columns = [desc[0] for desc in cur.description]\n                    col_widths = [len(col) for col in columns]\n\n                    # Calculate column widths\n                    for row in rows:\n                        for i, val in enumerate(row):\n                            col_widths[i] = max(col_widths[i], len(str(val)))\n\n                    # Build table\n                    lines = []\n                    # Header\n                    header = \" | \".join(col.ljust(col_widths[i]) for i, col in enumerate(columns))\n                    lines.append(header)\n                    lines.append(\"-\" * len(header))\n                    # Rows\n                    for row in rows:\n                        line = \" | \".join(\n                            str(val).ljust(col_widths[i]) for i, val in enumerate(row)\n                        )\n                        lines.append(line)\n\n                    lines.append(f\"\\n({len(rows)} row{'s' if len(rows) != 1 else ''})\")\n                    return ToolOk(output=\"\\n\".join(lines))\n                else:\n                    # Non-SELECT query (should not happen in read-only mode)\n                    return ToolOk(output=\"Query executed successfully (no results).\")\n\n        except psycopg.errors.ReadOnlySqlTransaction as e:\n            return ToolError(\n                message=f\"Cannot execute write operation in read-only mode: {e}\",\n                brief=\"Write operation not allowed\",\n            )\n        except Exception as e:\n            return ToolError(message=f\"SQL execution error: {e}\", brief=\"SQL error\")\n\n\nconsole = Console()\n\n# ============================================================================\n# PsqlProcess: PTY-based psql subprocess management\n# ============================================================================\n\n\nclass PsqlProcess:\n    \"\"\"Manages a psql subprocess with PTY support for full interactive experience.\"\"\"\n\n    def __init__(self, psql_args: list[str]):\n        self.psql_args = psql_args\n        self._master_fd: int | None = None\n        self._pid: int | None = None\n        self._running = False\n        self._original_termios: list | None = None\n\n    def start(self) -> None:\n        \"\"\"Spawn psql in a pseudo-terminal.\"\"\"\n        # Save original terminal settings\n        if sys.stdin.isatty():\n            self._original_termios = termios.tcgetattr(sys.stdin)\n\n        pid, master_fd = pty.fork()\n\n        if pid == 0:\n            # Child process: exec psql\n            os.execvp(\"psql\", self.psql_args)\n        else:\n            # Parent process\n            self._pid = pid\n            self._master_fd = master_fd\n            self._running = True\n\n            # Set master fd to non-blocking\n            flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)\n            fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)\n\n            # Sync terminal size\n            self._sync_window_size()\n\n            # Handle window resize\n            signal.signal(signal.SIGWINCH, self._handle_sigwinch)\n\n    def _sync_window_size(self) -> None:\n        \"\"\"Sync PTY window size with current terminal.\"\"\"\n        if self._master_fd is None:\n            return\n        if sys.stdin.isatty():\n            winsize = fcntl.ioctl(sys.stdin, termios.TIOCGWINSZ, b\"\\x00\" * 8)\n            fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)\n\n    def _handle_sigwinch(self, signum: int, frame: object) -> None:\n        \"\"\"Handle terminal window resize.\"\"\"\n        self._sync_window_size()\n\n    def read(self, timeout: float = 0.1) -> bytes:\n        \"\"\"Read output from psql (non-blocking with timeout).\"\"\"\n        if self._master_fd is None:\n            return b\"\"\n        ready, _, _ = select.select([self._master_fd], [], [], timeout)\n        if ready:\n            try:\n                return os.read(self._master_fd, 4096)\n            except OSError:\n                return b\"\"\n        return b\"\"\n\n    def write(self, data: bytes) -> None:\n        \"\"\"Write input to psql.\"\"\"\n        if self._master_fd is None:\n            return\n        os.write(self._master_fd, data)\n\n    def is_running(self) -> bool:\n        \"\"\"Check if psql process is still running.\"\"\"\n        if self._pid is None:\n            return False\n        try:\n            pid, status = os.waitpid(self._pid, os.WNOHANG)\n            if pid != 0:\n                self._running = False\n            return self._running\n        except ChildProcessError:\n            self._running = False\n            return False\n\n    def stop(self) -> None:\n        \"\"\"Terminate psql process and restore terminal.\"\"\"\n        if self._pid is not None:\n            try:\n                os.kill(self._pid, signal.SIGTERM)\n                os.waitpid(self._pid, 0)\n            except (ProcessLookupError, ChildProcessError):\n                pass\n\n        if self._master_fd is not None:\n            with contextlib.suppress(OSError):\n                os.close(self._master_fd)\n\n        # Restore original terminal settings\n        if self._original_termios and sys.stdin.isatty():\n            termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._original_termios)\n\n        self._running = False\n\n    @property\n    def master_fd(self) -> int | None:\n        return self._master_fd\n\n\n# ============================================================================\n# PsqlMode: Operation mode enumeration\n# ============================================================================\n\n\nclass PsqlMode(Enum):\n    AI = \"ai\"  # AI assistance mode (default)\n    PSQL = \"psql\"  # Direct psql interaction\n\n    def toggle(self) -> \"PsqlMode\":\n        return PsqlMode.PSQL if self == PsqlMode.AI else PsqlMode.AI\n\n\n# ============================================================================\n# PsqlSoul: SQL generation specialized Soul\n# ============================================================================\n\n\nasync def create_psql_soul(llm: LLM | None, conninfo: str) -> KimiSoul:\n    \"\"\"Create a KimiSoul configured for PostgreSQL with ExecuteSql tool\n    and standard kimi-cli tools.\"\"\"\n    from typing import cast\n\n    from kimi_cli.config import load_config\n    from kimi_cli.soul.agent import load_agent\n    from kimi_cli.soul.toolset import KimiToolset\n\n    config = load_config()\n    kaos_work_dir = KaosPath.cwd()\n    session = await Session.create(kaos_work_dir)\n    runtime = await Runtime.create(\n        config=config,\n        oauth=OAuthManager(config),\n        llm=llm,\n        session=session,\n        yolo=True,  # Auto-approve read-only SQL queries\n    )\n\n    # Load agent from configuration\n    agent_file = Path(__file__).parent / \"agent.yaml\"\n    agent = await load_agent(agent_file, runtime, mcp_configs=[])\n\n    # Add custom ExecuteSql tool to the loaded agent\n    cast(KimiToolset, agent.toolset).add(ExecuteSql(conninfo))\n\n    context = Context(session.context_file)\n    return KimiSoul(agent, context=context)\n\n\n# ============================================================================\n# PsqlShell: Main TUI orchestrator\n# ============================================================================\n\n\nclass PsqlShell:\n    \"\"\"Main TUI orchestrator for kimi-psql.\"\"\"\n\n    PROMPT_SYMBOL_AI = \"✨\"\n    PROMPT_SYMBOL_PSQL = \"$\"\n\n    def __init__(self, soul: KimiSoul, psql_process: PsqlProcess):\n        self.soul = soul\n        self._psql_process = psql_process\n        self._mode = PsqlMode.AI\n        self._switch_requested = False\n        self._prompt_session: PromptSession[str] | None = None\n        self._psql_entered_before = False  # Track if we've entered PSQL mode before\n\n    def _create_prompt_session(self) -> PromptSession[str]:\n        \"\"\"Create a prompt_toolkit session with Ctrl-X binding.\"\"\"\n        kb = KeyBindings()\n\n        @kb.add(\"c-x\", eager=True)\n        def _(event) -> None:\n            \"\"\"Switch to PSQL mode on Ctrl-X.\"\"\"\n            self._switch_requested = True\n            event.app.exit(result=\"\")\n\n        def get_prompt() -> FormattedText:\n            symbol = self.PROMPT_SYMBOL_AI if self._mode == PsqlMode.AI else self.PROMPT_SYMBOL_PSQL\n            return FormattedText([(\"bold fg:blue\", f\"kimi-psql{symbol} \")])\n\n        def get_bottom_toolbar() -> FormattedText:\n            mode_str = self._mode.value.upper()\n            return FormattedText(\n                [\n                    (\"bg:#333333 fg:#ffffff\", f\" [{mode_str}] \"),\n                    (\"bg:#333333 fg:#888888\", \" | ctrl-x: switch mode | ctrl-d: exit \"),\n                ]\n            )\n\n        return PromptSession(\n            message=get_prompt,\n            key_bindings=kb,\n            bottom_toolbar=get_bottom_toolbar,\n        )\n\n    async def run(self) -> None:\n        \"\"\"Main event loop.\"\"\"\n        # Create prompt session\n        self._prompt_session = self._create_prompt_session()\n\n        # Print welcome message\n        self._print_welcome()\n\n        try:\n            while self._psql_process.is_running():\n                if self._mode == PsqlMode.AI:\n                    await self._run_ai_mode()\n                else:\n                    await self._run_psql_mode()\n        except KeyboardInterrupt:\n            console.print(\"\\n[grey50]Bye![/grey50]\")\n        finally:\n            self._psql_process.stop()\n\n    def _print_welcome(self) -> None:\n        \"\"\"Print welcome message.\"\"\"\n        console.print(\n            Panel(\n                Text.from_markup(\n                    \"[bold]Welcome to kimi-psql![/bold]\\n\"\n                    \"[grey50]AI-assisted PostgreSQL interactive terminal[/grey50]\\n\\n\"\n                    \"[cyan]Ctrl-X[/cyan]: Switch between AI and PSQL mode\\n\"\n                    \"[cyan]Ctrl-D[/cyan]: Exit\"\n                ),\n                border_style=\"blue\",\n                expand=False,\n            )\n        )\n        console.print(f\"[grey50]Current mode: [bold]{self._mode.value.upper()}[/bold][/grey50]\\n\")\n\n    async def _run_ai_mode(self) -> None:\n        \"\"\"Handle AI assistance mode using prompt_toolkit with run_soul + visualize.\"\"\"\n        if not self._prompt_session:\n            return\n\n        self._switch_requested = False\n\n        try:\n            with patch_stdout(raw=True):\n                user_input = await self._prompt_session.prompt_async()\n        except EOFError:\n            raise KeyboardInterrupt from None\n        except KeyboardInterrupt:\n            console.print()\n            return\n\n        # Check if mode switch was requested\n        if self._switch_requested:\n            self._switch_mode()\n            return\n\n        user_input = user_input.strip()\n        if not user_input:\n            return\n\n        # Check for exit commands\n        if user_input.lower() in [\"exit\", \"quit\", \"\\\\q\"]:\n            raise KeyboardInterrupt\n\n        # Run soul with visualize (same as kimi-cli shell)\n        cancel_event = asyncio.Event()\n\n        try:\n            await run_soul(\n                self.soul,\n                user_input,\n                lambda wire: visualize(\n                    wire.ui_side(merge=False),\n                    initial_status=StatusUpdate(context_usage=self.soul.status.context_usage),\n                    cancel_event=cancel_event,\n                ),\n                cancel_event,\n            )\n        except LLMNotSet:\n            console.print(\"[red]LLM not set, run `kimi /setup` to configure[/red]\")\n        except LLMNotSupported as e:\n            console.print(f\"[red]{e}[/red]\")\n        except MaxStepsReached as e:\n            console.print(f\"[yellow]{e}[/yellow]\")\n        except RunCancelled:\n            console.print(\"[red]Interrupted by user[/red]\")\n        except Exception as e:\n            console.print(f\"[red]Error: {e}[/red]\")\n\n    async def _run_psql_mode(self) -> None:\n        \"\"\"Handle direct psql interaction with full PTY pass-through.\"\"\"\n        if not self._psql_process or self._psql_process.master_fd is None:\n            return\n\n        console.print(\n            \"[grey50]Entering PSQL mode. Press Ctrl-X to switch back to AI mode.[/grey50]\"\n        )\n\n        # Flush any pending output from psql before entering raw mode\n        while True:\n            chunk = self._psql_process.read(timeout=0.05)\n            if chunk:\n                sys.stdout.write(chunk.decode(\"utf-8\", errors=\"replace\"))\n                sys.stdout.flush()\n            else:\n                break\n\n        # Save terminal settings and set raw mode\n        old_settings = None\n        if sys.stdin.isatty():\n            old_settings = termios.tcgetattr(sys.stdin)\n            tty.setraw(sys.stdin)\n\n        master_fd = self._psql_process.master_fd\n\n        # Only send newline to refresh prompt if we've entered PSQL mode before\n        # First time, psql already shows its prompt after startup\n        if self._psql_entered_before:\n            self._psql_process.write(b\"\\n\")\n        self._psql_entered_before = True\n\n        try:\n            while self._psql_process.is_running():\n                # Wait for input from either stdin or psql\n                readable, _, _ = select.select([sys.stdin, master_fd], [], [], 0.1)\n\n                for fd in readable:\n                    if fd == sys.stdin:\n                        # Read from user\n                        data = os.read(sys.stdin.fileno(), 1024)\n                        if not data:\n                            return\n\n                        # Check for Ctrl-X (0x18)\n                        if b\"\\x18\" in data:\n                            # Restore terminal and switch mode\n                            if old_settings:\n                                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)\n                            self._switch_mode()\n                            return\n\n                        # Forward to psql\n                        self._psql_process.write(data)\n\n                    elif fd == master_fd:\n                        # Read from psql and display\n                        try:\n                            data = os.read(master_fd, 4096)\n                            if data:\n                                os.write(sys.stdout.fileno(), data)\n                        except OSError:\n                            break\n\n        finally:\n            # Restore terminal settings\n            if old_settings and sys.stdin.isatty():\n                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)\n\n    def _switch_mode(self) -> None:\n        \"\"\"Switch between AI and PSQL mode.\"\"\"\n        self._mode = self._mode.toggle()\n        console.print(f\"\\n[yellow]Switched to {self._mode.value.upper()} mode[/yellow]\\n\")\n\n\n# ============================================================================\n# CLI Entry Point\n# ============================================================================\n\napp = typer.Typer(\n    name=\"kimi-psql\",\n    help=\"AI-assisted PostgreSQL interactive terminal\",\n    add_completion=False,\n)\n\n\n@app.command()\ndef main(\n    dbname: str = typer.Argument(None, help=\"Database name (same as psql)\"),\n    username_arg: str = typer.Argument(None, help=\"Database user (same as psql)\"),\n    host: str = typer.Option(None, \"-h\", \"--host\", help=\"Database server host\"),\n    port: int = typer.Option(None, \"-p\", \"--port\", help=\"Database server port\"),\n    username: str = typer.Option(None, \"-U\", \"--username\", help=\"Database user\"),\n    dbname_opt: str = typer.Option(None, \"-d\", \"--dbname\", help=\"Database name\"),\n    conninfo: str = typer.Option(\n        None, \"--conninfo\", help=\"PostgreSQL connection URL (e.g., postgresql://user:pass@host/db)\"\n    ),\n) -> None:\n    \"\"\"\n    Start kimi-psql: AI-assisted PostgreSQL interactive terminal.\n\n    Usage is compatible with psql:\n      kimi-psql mydb\n      kimi-psql mydb postgres\n      kimi-psql -h localhost -U postgres -d mydb\n      kimi-psql --conninfo postgresql://user:pass@host/db\n    \"\"\"\n    # Resolve dbname and username (positional takes precedence over options)\n    final_dbname = dbname or dbname_opt\n    final_username = username_arg or username\n\n    asyncio.run(_run_async(host, port, final_username, final_dbname, conninfo=conninfo))\n\n\nasync def _run_async(\n    host: str | None,\n    port: int | None,\n    username: str | None,\n    dbname: str | None,\n    conninfo: str | None = None,\n    config_file: Path | None = None,\n) -> None:\n    \"\"\"Async entry point.\"\"\"\n    from kimi_cli.config import load_config\n    from kimi_cli.llm import augment_provider_with_env_vars\n\n    # If conninfo URL is provided, use it directly\n    if conninfo:\n        # For psql, just pass the connection URL\n        psql_args = [\"psql\", conninfo]\n        # For psycopg, use the URL as-is\n        conninfo_str = conninfo\n    else:\n        # Build psql command args\n        psql_args = [\"psql\"]\n        if host:\n            psql_args.extend([\"-h\", host])\n        if port:\n            psql_args.extend([\"-p\", str(port)])\n        if username:\n            psql_args.extend([\"-U\", username])\n        if dbname:\n            psql_args.extend([\"-d\", dbname])\n\n        # Build connection info for psycopg\n        conninfo_parts = []\n        if host:\n            conninfo_parts.append(f\"host={host}\")\n        if port:\n            conninfo_parts.append(f\"port={port}\")\n        if username:\n            conninfo_parts.append(f\"user={username}\")\n        if dbname:\n            conninfo_parts.append(f\"dbname={dbname}\")\n        conninfo_str = \" \".join(conninfo_parts)\n\n    # Load config (same as kimi-cli)\n    config = load_config(config_file)\n\n    model: LLMModel | None = None\n    provider: LLMProvider | None = None\n\n    # Try to use config file\n    if config.default_model:\n        model = config.models.get(config.default_model)\n        if model:\n            provider = config.providers.get(model.provider)\n\n    # Fallback to defaults\n    if not model:\n        model = LLMModel(provider=\"kimi\", model=\"\", max_context_size=250_000)\n    if not provider:\n        provider = LLMProvider(type=\"kimi\", base_url=\"\", api_key=SecretStr(\"\"))\n\n    # Override with environment variables\n    env_overrides = augment_provider_with_env_vars(provider, model)\n\n    if not provider.base_url or not model.model:\n        console.print(\"[red]LLM not configured. Run `kimi /setup` to configure.[/red]\")\n        return\n\n    if env_overrides:\n        console.print(f\"[grey50]Using env overrides: {', '.join(env_overrides.keys())}[/grey50]\")\n\n    # Create LLM\n    llm = create_llm(provider, model)\n\n    # Create Soul with ExecuteSql tool (uses psycopg for read-only queries)\n    soul = await create_psql_soul(llm, conninfo_str)\n\n    # Start psql process (only for user's PSQL mode)\n    psql_process = PsqlProcess(psql_args)\n    psql_process.start()\n\n    # Create and run shell\n    shell = PsqlShell(soul, psql_process)\n    await shell.run()\n\n\nif __name__ == \"__main__\":\n    app()\n"
  },
  {
    "path": "examples/kimi-psql/pyproject.toml",
    "content": "[project]\nname = \"kimi-psql\"\nversion = \"0.1.0\"\ndescription = \"AI-assisted PostgreSQL interactive terminal\"\nreadme = \"README.md\"\nrequires-python = \">=3.13\"\ndependencies = [\"kimi-cli\", \"kosong\", \"typer\", \"rich\", \"psycopg[binary]>=3.0\"]\n\n[tool.uv.sources]\nkimi-cli = { path = \"../../\" }\n"
  },
  {
    "path": "examples/sample-plugin/SKILL.md",
    "content": "---\nname: sample-plugin\ndescription: |\n  Sample plugin demonstrating the Skills + Tools model.\n  Includes a Python tool (greeting) and a TypeScript tool (calculator).\n---\n\n# Sample Plugin\n\nA demo plugin with two tools in different languages, showing that plugin tools are language-agnostic.\n\n## Tools\n\n| Tool | Language | Description |\n|------|----------|-------------|\n| `py_greet` | Python | Generate a greeting in en/zh/ja |\n| `ts_calc` | TypeScript | Evaluate a math expression |\n\n## Usage\n\n- \"greet Alice in Chinese\" -> use `py_greet` with name=\"Alice\", lang=\"zh\"\n- \"what is 42 * 17 + 3\" -> use `ts_calc` with expression=\"42 * 17 + 3\"\n"
  },
  {
    "path": "examples/sample-plugin/plugin.json",
    "content": "{\n  \"name\": \"sample-plugin\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Sample plugin demonstrating Skills + Tools with both Python and TypeScript tools\",\n  \"tools\": [\n    {\n      \"name\": \"py_greet\",\n      \"description\": \"Generate a greeting message (Python tool)\",\n      \"command\": [\"python3\", \"scripts/greet.py\"],\n      \"parameters\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\",\n            \"description\": \"Name to greet\"\n          },\n          \"lang\": {\n            \"type\": \"string\",\n            \"enum\": [\"en\", \"zh\", \"ja\"],\n            \"description\": \"Language: en=English, zh=Chinese, ja=Japanese\"\n          }\n        },\n        \"required\": [\"name\"]\n      }\n    },\n    {\n      \"name\": \"ts_calc\",\n      \"description\": \"Evaluate a math expression and return the result (TypeScript tool)\",\n      \"command\": [\"npx\", \"tsx\", \"scripts/calc.ts\"],\n      \"parameters\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"expression\": {\n            \"type\": \"string\",\n            \"description\": \"Math expression to evaluate, e.g. '2 + 3 * 4'\"\n          }\n        },\n        \"required\": [\"expression\"]\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "examples/sample-plugin/scripts/calc.ts",
    "content": "#!/usr/bin/env npx tsx\n/** TypeScript tool: evaluate a math expression. */\n\nconst chunks: Buffer[] = [];\nprocess.stdin.on(\"data\", (chunk) => chunks.push(chunk));\nprocess.stdin.on(\"end\", () => {\n  const raw = Buffer.concat(chunks).toString(\"utf-8\").trim();\n  const params = raw ? JSON.parse(raw) : {};\n  const expression: string = params.expression ?? \"0\";\n\n  // Safe evaluation: only allow numbers and basic operators\n  if (!/^[\\d\\s+\\-*/.()]+$/.test(expression)) {\n    console.error(`Invalid expression: ${expression}`);\n    process.exit(1);\n  }\n\n  try {\n    const result = Function(`\"use strict\"; return (${expression})`)();\n    console.log(`${expression} = ${result}`);\n  } catch (e) {\n    console.error(`Evaluation error: ${e}`);\n    process.exit(1);\n  }\n});\n"
  },
  {
    "path": "examples/sample-plugin/scripts/greet.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Python tool: generate a greeting message.\"\"\"\n\nimport json\nimport sys\n\nGREETINGS = {\n    \"en\": \"Hello, {name}! Welcome!\",\n    \"zh\": \"你好，{name}！欢迎！\",\n    \"ja\": \"こんにちは、{name}さん！ようこそ！\",\n}\n\nparams = json.loads(sys.stdin.read()) if not sys.stdin.isatty() else {}\nname = params.get(\"name\", \"World\")\nlang = params.get(\"lang\", \"en\")\n\ntemplate = GREETINGS.get(lang, GREETINGS[\"en\"])\nprint(template.format(name=name))\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"kimi-cli flake\";\n  inputs = {\n    nixpkgs.url = \"github:nixos/nixpkgs?ref=nixpkgs-unstable\";\n    systems.url = \"github:nix-systems/default\";\n    pyproject-nix = {\n      url = \"github:pyproject-nix/pyproject.nix\";\n      inputs.nixpkgs.follows = \"nixpkgs\";\n    };\n    uv2nix = {\n      url = \"github:pyproject-nix/uv2nix\";\n      inputs.pyproject-nix.follows = \"pyproject-nix\";\n      inputs.nixpkgs.follows = \"nixpkgs\";\n    };\n    pyproject-build-systems = {\n      url = \"github:pyproject-nix/build-system-pkgs\";\n      inputs.pyproject-nix.follows = \"pyproject-nix\";\n      inputs.uv2nix.follows = \"uv2nix\";\n      inputs.nixpkgs.follows = \"nixpkgs\";\n    };\n  };\n  outputs =\n    {\n      self,\n      nixpkgs,\n      systems,\n      pyproject-nix,\n      uv2nix,\n      pyproject-build-systems,\n    }:\n    let\n      allSystems = import systems;\n      forAllSystems =\n        f:\n        nixpkgs.lib.genAttrs allSystems (\n          system:\n          let\n            pkgs = import nixpkgs {\n              inherit system;\n              config.allowUnfree = true;\n            };\n          in\n          f { inherit system pkgs; }\n        );\n    in\n    {\n      packages = forAllSystems (\n        { pkgs, ... }:\n        let\n          kimi-cli =\n            let\n              inherit (pkgs)\n                lib\n                callPackage\n                python313\n                runCommand\n                ripgrep\n                stdenvNoCC\n                makeWrapper\n                versionCheckHook\n                ;\n              python = python313;\n              pyproject = lib.importTOML ./pyproject.toml;\n              workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };\n              overlay = workspace.mkPyprojectOverlay {\n                sourcePreference = \"wheel\";\n              };\n              extraBuildOverlay = final: prev: {\n                # Add setuptools build dependency for ripgrepy\n                ripgrepy = prev.ripgrepy.overrideAttrs (old: {\n                  nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];\n                });\n                # Replace README symlink with real file for Nix builds.\n                \"kimi-code\" = prev.\"kimi-code\".overrideAttrs (old: {\n                  postPatch = (old.postPatch or \"\") + ''\n                    rm -f README.md\n                    cp ${./README.md} README.md\n                  '';\n                });\n              };\n              pythonSet = (callPackage pyproject-nix.build.packages { inherit python; }).overrideScope (\n                lib.composeManyExtensions [\n                  pyproject-build-systems.overlays.wheel\n                  overlay\n                  extraBuildOverlay\n                ]\n              );\n              kimiCliPackage = pythonSet.mkVirtualEnv \"kimi-cli-virtual-env-${pyproject.project.version}\" workspace.deps.default;\n            in\n            stdenvNoCC.mkDerivation ({\n              pname = \"kimi-cli\";\n              version = pyproject.project.version;\n\n              dontUnpack = true;\n\n              nativeBuildInputs = [ makeWrapper ];\n              buildInputs = [ ripgrep ];\n\n              installPhase = ''\n                runHook preInstall\n\n                mkdir -p $out/bin\n                makeWrapper ${kimiCliPackage}/bin/kimi $out/bin/kimi \\\n                  --prefix PATH : ${lib.makeBinPath [ ripgrep ]} \\\n                  --set KIMI_CLI_NO_AUTO_UPDATE \"1\"\n\n                runHook postInstall\n              '';\n\n              nativeInstallCheckInputs = [\n                versionCheckHook\n              ];\n              versionCheckProgramArg = \"--version\";\n              doInstallCheck = true;\n\n              meta = {\n                description = \"Kimi Code CLI is a new CLI agent that can help you with your software development tasks and terminal operations\";\n                license = lib.licenses.asl20;\n                sourceProvenance = with lib.sourceTypes; [ fromSource ];\n                maintainers = with lib.maintainers; [\n                  xiaoxiangmoe\n                ];\n                mainProgram = \"kimi\";\n              };\n            });\n        in\n        {\n          inherit kimi-cli;\n          default = kimi-cli;\n        }\n      );\n      formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree);\n    };\n}\n"
  },
  {
    "path": "kimi.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\n\nimport os\nfrom kimi_cli.utils.pyinstaller import datas, hiddenimports\n\n# Read codesign identity from environment variable (for macOS signing in CI)\ncodesign_identity = os.environ.get(\"APPLE_SIGNING_IDENTITY\", None)\n\n# Read build mode from environment variable (onedir mode for directory-based distribution)\nonedir_mode = os.environ.get(\"PYINSTALLER_ONEDIR\", \"0\") == \"1\"\n\na = Analysis(\n    [\"src/kimi_cli/cli/__main__.py\"],\n    pathex=[],\n    binaries=[],\n    datas=datas,\n    hiddenimports=hiddenimports,\n    hookspath=[],\n    hooksconfig={},\n    runtime_hooks=[],\n    excludes=[],\n    noarchive=False,\n    optimize=0,\n)\npyz = PYZ(a.pure)\n\nif onedir_mode:\n    # one-dir mode: EXE contains only scripts, binaries/datas collected separately\n    # Use a different name for EXE to avoid conflict with COLLECT directory\n    exe = EXE(\n        pyz,\n        a.scripts,\n        exclude_binaries=True,\n        name=\"kimi-exe\",\n        debug=False,\n        bootloader_ignore_signals=False,\n        strip=False,\n        upx=True,\n        upx_exclude=[],\n        runtime_tmpdir=None,\n        console=True,\n        disable_windowed_traceback=False,\n        argv_emulation=False,\n        target_arch=None,\n        codesign_identity=codesign_identity,\n        entitlements_file=None,\n    )\n    coll = COLLECT(\n        exe,\n        a.binaries,\n        a.datas,\n        strip=False,\n        upx=True,\n        upx_exclude=[],\n        name=\"kimi\",\n    )\nelse:\n    # one-file mode (default): all binaries/datas bundled into single executable\n    exe = EXE(\n        pyz,\n        a.scripts,\n        a.binaries,\n        a.datas,\n        [],\n        name=\"kimi\",\n        debug=False,\n        bootloader_ignore_signals=False,\n        strip=False,\n        upx=True,\n        upx_exclude=[],\n        runtime_tmpdir=None,\n        console=True,\n        disable_windowed_traceback=False,\n        argv_emulation=False,\n        target_arch=None,\n        codesign_identity=codesign_identity,\n        entitlements_file=None,\n    )\n"
  },
  {
    "path": "klips/.pre-commit-config.yaml",
    "content": "orphan: true\n\n# Docs changes do not need pre-commit hooks.\nrepos: []\n"
  },
  {
    "path": "klips/klip-0-klip.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-07\nStatus: Implemented\n---\n\n# KLIP-0: Kimi CLI Improvement Proposal\n\n## Kimi CLI 的前世今生\n\nKimi CLI 起源于 2025 年 9 月 1 日晚上开始的一个 side project——「Ensoul」。Ensoul 是一个命令行程序，功能是加载指定的 agent 文件（其中包含 system prompt 和要启用的 mshtools 中的 tool list），进入 REPL 接收用户 prompt，对用户 prompt 运行 agent loop。项目名字叫「Ensoul」是因为这个过程很像在给一个「死的」agent 文件「赋予灵魂」，让它「活起来」。\n\nEnsoul 最初的目标是让不懂代码的 PM 能够利用当时已有的内部 agent 开发框架——「YAMAHA」。YAMAHA 是硬凑出来的名字，全称是「Yet Another Moonshot Agent, Hallucination Avoided」，它是更早已存在的专用于跑 GAIA benchmark 的「YAMA」的重写版。重写后的 YAMAHA 发展成了一个更为通用的 agent 开发框架，提供一些 agent 的构建单元，比如「ChatProvider」「Message」「Context」「Tool」「Toolset」——「Kosong」即脱胎于此。\n\nKosong 在马来语的意思是「空」，如此命名是希望它只提供「机制」，不提供「策略」，它不含有任何「实际的东西」，却又什么都蕴含了。「空即是色，色即是空」。当 Ensoul 逐渐取代 YAMAHA 的位置，又进而演变成 Kimi CLI 时，YAMAHA 中最通用的那部分东西，沉淀到了 Kosong。现在的 Kosong 包含 LLM 抽象层和 agent 开发原语（其中最为关键的是 `step` 函数），是 Kimi CLI 最关键的基石。它的存在使得 Kimi CLI 的核心 agent loop——「KimiSoul」的实现只需要 400 行 Python 代码。\n\n现在回到 Kimi CLI。CLI 的全称是「Command Line Interface」，是所有运行在终端的命令行界面程序的统称，类似于所有图形界面的程序都称为「GUI」程序，所有运行在浏览器的程序都称为「Web」程序。当意识到 Ensoul「就是」Kimi CLI 时，我们把命令的名字改成了 `kimi`。它从一开始就不只是一个 coding agent，而是运行在命令行界面的 Kimi 智能助理，人们应该期待它可以做任何事，以命令行界面的形式。\n\n那么它应该长什么样？「没有人想在终端里用聊天界面」是我们的早期共识。在 Claude Code 之前，人们只会在终端里用 shell，以及用 shell 运行其他命令行程序，如 `npm` `python` `rclone`；而一般大众则更是从来没有打开过终端。我们认为 Claude Code 把 chat UI 放到终端里完全是因为这样开发起来最快。GUI 是需要时间的，而且需要项目有更多人力资源，终端的 chat UI 似乎是一种可以很快推出的、谁都不想要但谁都能勉强用的形式。我们在最开始就认为，人们需要三种形式的 agent——面向大众的图形界面 agent、面向程序员的 AI-shell、面向程序员的 IDE 集成 agent。Kimi CLI 的第一步，是成为 AI-shell，至少长得像个 AI-shell。\n\n但 UI 不是本质问题。无论表现为什么形态，内核是一样的。CLI 程序是一个非常理想的提供 agent 内核的形式。就像 MCP 工具最广泛使用的形式是通过 `npx` 运行并在 stdio 上通过 JSON-RPC 通信，Kimi CLI 在 shell UI 之外，提供了 Print 模式和 Wire 模式，可以在 stdio 上通过特定的格式接受用户 prompt 和推送 agent 行为事件。基于 Wire 模式，我们有了内部的 Web UI，和正在开发的 VS Code 扩展。除此之外，我们通过 ACP 模式提供 ACP 服务端（同样走 stdio 通信），支持接入任何 ACP 客户端，这使得 Kimi CLI 可以接入 JetBrains 和 Zed 等 IDE，也可以接入 DeepChat、Alma 这样的本地通用 agent 客户端。我们最开始所畅想的三种形式，正在一一出现并变得可用。\n\n仅仅如此还不够，从 Ensoul 的第一天开始，它就是支持定制化的。Kimi CLI 内核的能力不仅限于提供一个预定义好的 agent。像诞生第一天那样，Kimi CLI 支持通过 agent 文件定制 system prompt 和 tool list。同时，我们也支持了通过 MCP tools 和 skills 扩展 Kimi CLI 的能力，使每个用户可以以独特的方式使用 Kimi CLI。除了使用 `kimi` 命令，还可以把 Kimi CLI 安装为 Python 依赖，直接使用其中模块解耦良好的 agent kernel 和 UI 组件，构建上层应用程序。下一步，我们将会对 Kimi CLI 的 Wire 模式做进一步封装，形成 Kimi Agent SDK，使得用 Python、Nodejs、Go 等各种语言的用户可以更方便地构建 agent 应用。\n\n「Lead, don't follow」是我们收到的最好的鼓励。鉴于我们更年轻，不可避免地落后于 Claude Code、OpenCode 等优秀项目，但我们绝不盲目 follow 它们。Kimi CLI 所有的想法、功能都是从零开始自然发生的，所有架构都是从零思考的。对于其中的许多部分，我们发现它与先驱产品不谋而合，比如 Wire 模式和 ACP 非常接近，Kimi Agent SDK 与 Claude Agent SDK 的架构也非常相似，但这不影响我们从第一性原理思考事情的本质。我们相信最终有一天我们可以 lead 一些事情。\n\n## Kimi CLI Improvement Proposal\n\nKimi CLI 内核的大厦已经初具稳定的形状，现在我觉得是时候引入一个机制让 Kimi CLI 的开发以更 scalable 的方式进行，同时也是作为我们对下一代软件开发范式的探索。\n\nCode is cheap，这已经是所有人的共识了。提出 pull request 现在已经没有成本，完全不需要人的思考，就可以写出几百上千行代码，可以完成功能，也能通过所有测试。但这不代表价值，无脑地堆砌 agent 的代码只会造成不可控的屎山。当代码本身变得没有价值，代码架构、可扩展性、稳定性、产品决策的重要性反而更为凸显。这其实并不是现在才应该认识到的，Linux kernel 创始人 Linus Torvalds 有句著名的说法「Bad programmers worry about the code. Good programmers worry about data structures and their relationships.」就是这意思。当我们有了良好的数据结构和关系，功能代码会自动生长出来，这时候 agent 写的代码也会是美的。\n\n因此，KLIP 应该强调数据结构和关系的变化。未来，对于稍大的功能，Kimi CLI 的一个典型工作流程应该是：\n\n1. 无脑给 agent 提出需求，看看会写出什么\n   1. 可以迭代或重写获得一个足够证明思路可行的东西\n2. 与此同时，程序员思考此功能所需的「本质修改」，也就是对架构、数据类、协议、模块接口的修改\n3. 程序员和 agent 共同撰写和迭代 KLIP，详细描述所有「本质修改」\n   1. 应尽量使用伪代码和图示，既不空中楼阁，也不追求细化到每一行代码的变化\n4. 让其他人 review KLIP，根据反馈，调整 KLIP 和 feature 分支可能已经存在的原型代码\n   1. 要保持 KLIP 更新，始终反映「本质修改」\n5. 从 KLIP，用 agent 生成具体的代码实现\n   1. 代码实现也可能在迭代 KLIP 的过程中就已经成熟了，这没问题\n6. 用最少的精力 review 具体的代码变更，合并\n\n这其实和过去大型软件的迭代过程非常类似，区别在于，KLIP 和代码可以同时迭代，当 KLIP 被 accept 时，代码几乎已经可用了，而不会出现（最好是不会），KLIP 想得很好，但实现出来跟想象差别很大的情况。实际上这和 Linux kernel、CPython、C++ 这类更严肃的分布式开发的超大型软件是一致的，这些软件的贡献者在提出提案时，往往已经写好了一个可以工作的原型。Agent 的辅助可以让我们更好地实践这个高标准的流程。\n\n让我们看看会发生什么。\n"
  },
  {
    "path": "klips/klip-1-kimi-cli-monorepo.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2025-12-29\nStatus: Implemented\n---\n\n# KLIP-1: Move Kosong and PyKAOS to Kimi CLI Monorepo\n\n下面是一份「可执行的操作计划」，把我们前面确定的方案全部串起来，并加入你新补充的 tag 规则（`kosong-0.20.0`；`pykaos-0.2.0`；`kimi-cli` 仍是 `0.68`/`0.68.1` 这种纯数字）。\n\n## 1. 确定目标目录与命名\n\n先定死，后面所有脚本/CI 都依赖它。\n\n1. monorepo（目标仓库）仍然叫 `kimi-cli`，且 `kimi-cli` 包仍放在仓库根目录（保持你现在的结构/习惯）。\n2. `kosong` 放到 `packages/kosong`，`pykaos` 放到 `packages/kaos`（目录叫 `kaos`，但 Python 包/发行名是 `pykaos`）。\n3. 三个包的 `project.name`（PyPI 包名）分别是：`kimi-cli`、`kosong`、`pykaos`。\n4. tag 约定：\n    - `kimi-cli`：`0.68` / `0.68.1`（纯数字开头）\n    - `kosong`：`kosong-0.20.0`（无 v）\n    - `pykaos`：`pykaos-0.2.0`（无 v）\n\n## 2. 把 uv workspace 配好\n\n开发时三包联动；发布时仍是三个独立包。\n\n1. 在仓库根 `pyproject.toml`（`kimi-cli`）里开启 workspace：`tool.uv.workspace.members = [\"packages/kosong\", \"packages/kaos\"]`。\n2. 在仓库根 `pyproject.toml`（`kimi-cli`）里加 `tool.uv.sources`，把依赖映射到 workspace member：\n    - `kosong = { workspace = true }`\n    - `pykaos = { workspace = true }`\n3. `kimi-cli` 的对外依赖（`project.dependencies`）写「发布后要生效的版本范围」，例如依赖 `kosong`、`pykaos` 的范围约束（你们自己决定兼容策略，上界不要省略）。开发时 uv 会用 workspace 里的本地包覆盖同名依赖；发布时用户仍会从 PyPI 拉对应版本。\n\n## 3. 把 kosong、kaos 迁入 monorepo\n\n同时保留 commit 历史但不带 tags。\n\n对每个源仓库（`kosong`、`kaos`）都按下面流程做（在 `kimi-cli` 仓库里操作）：\n\n1. 添加 remote。\n2. 禁用该 remote 的 tags 拉取：`git config remote.<name>.tagOpt --no-tags`。\n3. fetch 只抓默认分支（main/master）且不抓 tags：`git fetch --no-tags <remote> <branch>`。\n4. 用 `git subtree add` 把它导入到指定目录：\n    - `kosong`：`--prefix=packages/kosong`\n    - `kaos`：`--prefix=packages/kaos`\n\n这样 commit 历史会进入 monorepo，但你不会把原仓库的 tags 带进来（因为你根本不抓 tags，也不推 tags）。\n\n## 4. 迁移后对代码做「最小必要改动」\n\n确保三包仍能独立构建与发布。\n\n1. 确认 `packages/kosong/pyproject.toml` 的 `[project]` 配置仍完整（name/version/dependencies 等）。\n2. 确认 `packages/kaos/pyproject.toml` 的 `[project].name` 是 `pykaos`（不是 `kaos`）。目录名可以是 `kaos`，不影响发行名。\n3. 如果 `kimi-cli` 里原来是通过相对路径/本地 editable 依赖来引用 `kosong`/`kaos`，把它们改为正常依赖（`kosong`、`pykaos`），并靠 `tool.uv.sources` 在本地走 workspace。\n4. 在 monorepo 根做一次全量自检：\n    - `uv sync`（或你们现在用的等价命令）\n    - `uv run -m pytest` / `uv run kimi-cli` 的基本命令（按你们项目实际）\n\n目标是：workspace 内能同时开发运行。\n\n## 5. 把 CI/Release workflow 拆成三个\n\n让 tag 触发互斥，且只有 `kimi-cli` 创建 GitHub Release。\n\n核心原则：只有「纯数字 tag」触发 `kimi-cli` release；只有 `kosong-` 触发 `kosong` 发布；只有 `pykaos-` 触发 `pykaos` 发布。这样用户在 `kimi-cli` 仓库的 GitHub Releases 页只会看到 `kimi-cli` 的 release。\n\n### 5.1 kimi-cli release workflow\n\n保留你现有行为：tag 触发 -> build -> publish -> create GitHub Release。\n\n- **触发**：`on push tags` 仅匹配数字开头（例如 `[0-9]*`）。\n- **版本校验**：tag 本身就是版本号，必须等于根 `pyproject.toml` 的 `[project].version`，不一致直接 fail。\n- **构建**：`uv build --package kimi-cli`，并且建议发布路径上用 `--no-sources` 做一次「发布语义」构建，避免 workspace sources 掩盖问题。\n- **发布**：继续用你当前的 PyPI publish 方式。\n- **Release**：继续用你当前的「基于 tag 创建 GitHub Release」的步骤（保持对用户体验不变）。\n\n### 5.2 kosong 发布 workflow\n\n不创建 GitHub Release，只发 PyPI + 生成 docs。\n\n- **触发**：`on push tags` 匹配 `kosong-*`。\n- **版本校验**：从 tag 去掉前缀 `kosong-` 得到版本号，必须等于 `packages/kosong/pyproject.toml` 的 `[project].version`。\n- **构建**：`uv build --package kosong`（注意 package 名是 `project.name`，不是目录名），输出到 `dist/kosong`。\n- **发布**：只把 `dist/kosong` 下的产物发 PyPI。\n- **不创建 GitHub Release**：workflow 里不要调用任何 release 创建 action。\n\n### 5.3 pykaos 发布 workflow\n\n不创建 GitHub Release，只发 PyPI。\n\n- **触发**：`on push tags` 匹配 `pykaos-*`。\n- **版本校验**：从 tag 去掉前缀 `pykaos-` 得到版本号，必须等于 `packages/kaos/pyproject.toml` 的 `[project].version`。\n- **构建**：`uv build --package pykaos`，输出到 `dist/pykaos`。\n- **发布**：只把 `dist/pykaos` 下的产物发 PyPI。\n- **不创建 GitHub Release**。\n\n## 6. 发版时的「tag 与版本一致」校验实现方式\n\n建议统一为一个可复用脚本。\n\n1. 在仓库里加一个小脚本，例如 `scripts/check_version_tag.py`：\n    - 输入：包的 pyproject 路径 + 期望版本（由 tag 派生）。\n    - 逻辑：读 `tomllib` -> `project.version` -> 比较 -> 不一致 `exit 1`。\n2. 三个 workflow 在 build 前都调用它：\n    - `kimi-cli`：期望版本 = `${GITHUB_REF_NAME}`，pyproject = `./pyproject.toml`\n    - `kosong`：期望版本 = 去掉 `kosong-`，pyproject = `packages/kosong/pyproject.toml`\n    - `pykaos`：期望版本 = 去掉 `pykaos-`，pyproject = `packages/kaos/pyproject.toml`\n\n## 7. kosong 文档 URL 不变的实现\n\n关键：保留旧仓库作为 Pages 承载。\n\n你要「搬代码但 URL 不变」，实际等价于：`MoonshotAI/kosong` 这个仓库必须继续存在并继续作为 GitHub Pages 的站点源；只是文档内容不再在那边构建，而是在 monorepo 构建后推送过去。\n\n具体落地步骤：\n\n1. 在旧的 `MoonshotAI/kosong` 仓库里，把它转为「承载站点的空壳仓库」：\n    - main 分支可以只留 README（指向新 monorepo），并建议归档/锁写入，避免误提交。\n2. 把该仓库的 GitHub Pages 设置为「从 gh-pages 分支发布」（Deploy from a branch）。\n3. 在 monorepo 的 `release-kosong` workflow 里新增 docs 部署步骤：\n    - 在 monorepo 里按你原 workflow 的方式生成 docs（你现在是 `uv run pdoc … -o docs`，并创建 `docs/.nojekyll`）。\n    - 把生成的 docs 内容推送到旧仓库 `MoonshotAI/kosong` 的 gh-pages 分支（覆盖更新）。\n4. 权限：给 monorepo 配一个能写 `MoonshotAI/kosong` 的凭据：\n    - 推荐 fine-grained PAT（仅对该仓库 `contents:write`），作为 monorepo 的 secret（例如 `KOSONG_PAGES_TOKEN`）。\n\n这样访问 URL 仍然是原来的 URL，但文档内容来自 monorepo 的发版构建产物。\n\n## 8. 最终切换/上线顺序\n\n降低风险的执行顺序。\n\n1. 在 `kimi-cli` 仓库开迁移分支，先完成 workspace 配置与 subtree 导入，跑通本地开发与测试。\n2. 把三个 release workflow 都先改成「仅在特定 tag 前缀触发」，并在 PR 环境用手工 dispatch 或临时 tag 在测试 PyPI/私有 index 验证（如果你们有的话；没有就用 dry-run 构建检查）。\n3. 先发布一个 `pykaos-` 与 `kosong-` 的小版本（哪怕只是 patch），验证：\n    - tag -> 版本校验能挡住错误\n    - PyPI 包发布产物正确\n    - kosong 文档被成功推到旧仓库且 URL 不变\n4. 最后按原方式发布 `kimi-cli` 的数字 tag（`0.68.1` 之类），验证 GitHub Release 页仍只出现 `kimi-cli`。\n\n---\n\n如果你希望我把「最终三个 workflow 的 YAML 骨架」直接写出来（包含：tag 解析、版本校验脚本调用、`uv build --package`、发布、以及 kosong docs 推送到旧仓库 gh-pages），我可以按你们现有的 `release.yml` 结构（你给的 `MoonshotAI/kosong` 版本）做「最小改动迁移版」，确保你们维护成本最低。\n"
  },
  {
    "path": "klips/klip-10-agent-flow.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-20\nStatus: Implemented\n---\n\n# KLIP-10: Agent Flow (Agent Skill 扩展)\n\n## 背景\n\n当前 Kimi CLI 只能通过交互式输入或 `--command` 单次输入驱动对话。希望支持一种\n\"agent flow\"，让用户用 Mermaid 或 D2 flowchart 描述流程，每个节点对应一次对话轮次，\n并能根据分支节点的选择继续走向不同的下一节点。Agent Flow 作为 Agent Skill 的扩展，\n通过 `SKILL.md` 中的元数据声明类型，并从流程图代码块解析得到。\n\n示例见 `flowchart.mmd`：用 `BEGIN`/`END` 包住流程，中间节点为 prompt，分支节点用\n出边 label 表示分支值。\n\n## 目标\n\n- Agent Skill 支持 `type: standard | flow` 元数据（默认 standard）。\n- flow 类型 skill 从 `SKILL.md` 中的第一个 Mermaid/D2 代码块解析流程。\n- Flow 作为 `Skill.flow` 存储，并在 `KimiSoul` 中通过 `/flow:<name>` 触发执行。\n- standard 类型 skill 仍使用 `/skill:<name>`，system prompt 中继续列出 name/description/path。\n- 分支节点会在 user input 中补充可选分支值，要求 LLM 在回复末尾输出\n  `<choice>{值}</choice>`，并据此选择下一节点。\n- 在同一 session/context 中持续推进，直到抵达 `END`。\n\n## 非目标\n\n- 不支持完整 Mermaid/D2 语法，仅支持各自的最小子集。\n- 不引入新的 UI（依旧使用 shell UI 输出）。\n- 不处理子图、样式、链接、点击事件等 Mermaid 特性。\n\n## 设计概览\n\n### 1) Mermaid flowchart 最小子集\n\n仅支持以下语法（足够覆盖示例）：\n\n- Header：`flowchart TD` / `flowchart LR` / `graph TD`（其余方向忽略）。\n- 注释行：`%% ...`。\n- 节点：`ID[文本]` / `ID([文本])` / `ID{文本}`（形状仅用于携带 label，语义上忽略）。\n- 节点内容支持引号包裹：`ID[\"含特殊字符的文本\"]`，引号内可包含 `]`、`}`、`|` 等。\n- 边：`A --> B`、`A -->|label| B`、`A -- label --> B`。\n- 允许边上内联节点定义：`A([BEGIN]) --> B[...]`。\n\n其他样式与布局相关语法（如 `classDef`/`style`/`linkStyle`/`subgraph`）会被忽略，不报错。\n\n### 2) D2 flowchart 最小子集\n\n支持以下语法（足够覆盖示例）：\n\n- 注释行：`# ...`。\n- 节点：`ID: label`（label 省略时使用 ID）。\n- 边：`A -> B`、`A -> B: label`，允许链式 `A -> B -> C`（label 仅作用于最后一段）。\n- 节点 ID：字母数字或 `_` 开头，允许 `.` `/` `-`。\n\n忽略：属性路径（如 `foo.bar`）与 `{ ... }` 块。\n\n### 3) 图结构与校验\n\n数据结构（位于 `src/kimi_cli/skill/flow/__init__.py`，`PromptFlow` 更名为 `Flow`）：\n\n```python\nFlowNodeKind = Literal[\"begin\", \"end\", \"task\", \"decision\"]\n\n@dataclass(frozen=True, slots=True)\nclass FlowNode:\n    id: str\n    label: str | list[ContentPart]  # 支持富文本内容\n    kind: FlowNodeKind\n\n@dataclass(frozen=True, slots=True)\nclass FlowEdge:\n    src: str\n    dst: str\n    label: str | None\n\n@dataclass(slots=True)\nclass Flow:\n    nodes: dict[str, FlowNode]\n    outgoing: dict[str, list[FlowEdge]]\n    begin_id: str\n    end_id: str\n```\n\n异常层次结构：\n\n```python\nclass FlowError(ValueError):\n    \"\"\"Base error for flow parsing/validation.\"\"\"\n\nclass FlowParseError(FlowError):\n    \"\"\"Raised when flowchart parsing fails.\"\"\"\n\nclass FlowValidationError(FlowError):\n    \"\"\"Raised when a flowchart fails validation.\"\"\"\n```\n\n校验规则：\n\n- `BEGIN`/`END` 通过节点文本（label）匹配，大小写不敏感。\n- 必须且只能有一个 `BEGIN`、一个 `END`。\n- `BEGIN` 能连通到 `END`。\n- 如果某节点有多个出边，则每条边必须有非空 label，且 label 不能重复。\n- 单出边节点允许 label 缺失或为空（label 会被忽略）。\n- 未显式声明的节点允许隐式创建（label 默认使用节点 ID），以保持常见用法。\n\n### 4) Agent Flow 发现与加载\n\nAgent Flow 与 Agent Skill 复用同一套 discovery 逻辑，目录来源保持不变：\n\n- 内置技能：`src/kimi_cli/skills/`\n- 用户技能：`~/.config/agents/skills`（含历史兼容路径）\n- 项目技能：`<work_dir>/.agents/skills`（含历史兼容路径）\n\nskill 元数据：\n\n- `type: standard | flow`，默认 `standard`。\n- flow skill 会在 `SKILL.md` 中查找第一个 `mermaid` 或 `d2` fenced codeblock，\n  并解析为 `Flow` 存入 `Skill.flow`。\n- 未找到有效流程图或解析失败时，记录日志并将其作为普通 skill 处理。\n\n### 5) FlowRunner 与 KimiSoul 扩展\n\n提取独立的 `FlowRunner` 类处理 flow 执行逻辑，`KimiSoul` 通过持有 `_flow_runners`\n来支持 agent flow。同时重构 slash command 机制，将 skill commands 也改为实例级别\n（不再全局注册）。\n\n**FlowRunner 类**（位于 `src/kimi_cli/soul/kimisoul.py`）：\n\n```python\nclass FlowRunner:\n    def __init__(\n        self,\n        flow: Flow,\n        *,\n        name: str | None = None,\n        max_moves: int = DEFAULT_MAX_FLOW_MOVES,\n    ) -> None:\n        self._flow = flow\n        self._name = name\n        self._max_moves = max_moves\n\n    async def run(self, soul: KimiSoul, args: str) -> None:\n        \"\"\"执行 flow 遍历，通过 /flow:<name> 触发。\"\"\"\n        ...\n\n    async def _execute_flow_node(\n        self,\n        soul: KimiSoul,\n        node: FlowNode,\n        edges: list[FlowEdge],\n    ) -> tuple[str | None, int]:\n        \"\"\"执行单个节点，返回 (下一节点 ID, 使用的步数)。\"\"\"\n        ...\n\n    @staticmethod\n    def _build_flow_prompt(node: FlowNode, edges: list[FlowEdge]) -> str | list[ContentPart]:\n        \"\"\"构建节点 prompt，多出边节点会附加选择指引。\"\"\"\n        ...\n\n    @staticmethod\n    def _match_flow_edge(edges: list[FlowEdge], choice: str | None) -> str | None:\n        \"\"\"根据 choice 匹配出边。\"\"\"\n        ...\n\n    @staticmethod\n    def ralph_loop(\n        user_message: Message,\n        max_ralph_iterations: int,\n    ) -> FlowRunner:\n        \"\"\"创建 Ralph 模式的循环流程。\"\"\"\n        ...\n```\n\n**修改 KimiSoul**：\n\n```python\nclass KimiSoul:\n    def __init__(\n        self,\n        agent: Agent,\n        *,\n        context: Context,\n    ):\n        # ... 现有初始化 ...\n        # 在 init 时构造 slash commands，避免每次 run 重复构造\n        self._slash_commands = self._build_slash_commands()\n        self._slash_command_map = self._index_slash_commands(self._slash_commands)\n\n    def _build_slash_commands(self) -> list[SlashCommand[Any]]:\n        commands: list[SlashCommand[Any]] = list(soul_slash_registry.list_commands())\n        # 实例级别：skill commands（standard）\n        for skill in self._runtime.skills.values():\n            if skill.type != \"standard\":\n                continue\n            commands.append(SlashCommand(\n                name=f\"skill:{skill.name}\",\n                func=self._make_skill_runner(skill),\n                description=skill.description or \"\",\n                aliases=[],\n            ))\n        # 实例级别：/flow:<name>（flow skills）\n        for skill in self._runtime.skills.values():\n            if skill.type != \"flow\" or skill.flow is None:\n                continue\n            runner = FlowRunner(skill.flow, name=skill.name)\n            commands.append(SlashCommand(\n                name=f\"flow:{skill.name}\",\n                func=runner.run,\n                description=f\"Start the agent flow '{skill.name}'\",\n                aliases=[],\n            ))\n        return commands\n\n    def _find_slash_command(self, name: str) -> SlashCommand[Any] | None:\n        return self._slash_command_map.get(name)\n\n    @property\n    def available_slash_commands(self) -> list[SlashCommand[Any]]:\n        return self._slash_commands\n```\n\n运行规则：\n\n- `KimiSoul` 根据 `Skill.type` 生成 `/skill:<name>` 或 `/flow:<name>`。\n- `available_slash_commands` 统一返回：静态命令 + skill commands + flow commands。\n- `run` 方法查找实例命令（而非静态 registry），支持动态命令。\n- `/flow:<name>` 触发 `FlowRunner.run` 执行 flow 遍历。\n- 节点是否需要选择由出边数量决定（多出边即分支）。\n\n分支节点的 prompt 组装（示意）：\n\n```\n{node.label}\n\nAvailable branches:\n- 是\n- 否\n\nReply with a choice using <choice>...</choice>.\n```\n\n选择解析：\n\n- 从本次 run 后新增的最后一条 assistant message 读取文本。\n- 使用正则 `r\"<choice>([^<]*)</choice>\"` 抽取**最后一个** choice 标签的值，trim 后精确匹配出边 label。\n  - 不强制 choice 在末尾，因为 LLM 可能在 choice 后追加解释文字。\n  - 使用 `[^<]*` 而非 `.*?` 避免跨标签匹配。\n- 若缺失或无匹配：自动重试（追加\"必须按格式输出\"的提示）。\n\n为防止死循环，内置 `max_moves`（默认 1000）作为硬上限；到达上限则抛出 `MaxStepsReached`。\n\n### 6) Ralph 模式\n\nRalph 模式是一种特殊的自动迭代模式，通过 `--max-ralph-iterations` 参数启用。\n它会自动将用户输入包装成一个带 CONTINUE/STOP 分支的循环流程：\n\n```python\n@staticmethod\ndef ralph_loop(\n    user_message: Message,\n    max_ralph_iterations: int,\n) -> FlowRunner:\n    \"\"\"\n    创建 Ralph 模式的循环流程：\n    BEGIN → R1(执行用户 prompt) → R2(决策节点) → CONTINUE(回到 R2) / STOP → END\n    \"\"\"\n    ...\n```\n\n在 `KimiSoul.run` 中，如果启用了 Ralph 模式，会自动创建 Ralph 循环流程：\n\n```python\nif self._loop_control.max_ralph_iterations != 0:\n    runner = FlowRunner.ralph_loop(\n        user_message,\n        self._loop_control.max_ralph_iterations,\n    )\n    await runner.run(self, \"\")\n    return\n```\n\n### 7) CLI 集成\n\nAgent Flow 通过 skill discovery 自动加载，不新增 CLI 参数。只要 `SKILL.md` 中声明\n`type: flow` 并包含流程图代码块，即可通过 `/flow:<name>` 使用。\n\n### 8) 错误处理与用户反馈\n\n- 解析错误：通过 `FlowParseError` 指出 Mermaid/D2 语法问题（包含行号）。\n- 校验错误：通过 `FlowValidationError` 指出图结构问题。\n- flow skill 无有效流程图：记录日志并降级为普通 skill。\n- 运行时错误：日志记录当前节点、分支选择失败原因。\n- choice 无效：自动重试，追加提示要求按格式输出。\n- 输出日志：`logger.info`/`logger.warning` 记录节点推进与选择结果，便于调试。\n\n## 兼容性与边界\n\n- 仅支持 flowchart，且只解析上述最小子集。\n- `BEGIN`/`END` 只通过 label 识别；如果用户用其它词，需要显式改名。\n- 允许循环图；但会受到 `max_moves` 限制。\n- flow 名称与 skill 名称一致。\n- 分支 label 要求短且稳定；建议避免多行或包含特殊字符。\n- `FlowNode.label` 支持 `str | list[ContentPart]`，可用于 Ralph 模式等内部场景。\n\n## 关键参考位置\n\n- CLI 入口：`src/kimi_cli/cli/__init__.py`\n- Skill 解析：`src/kimi_cli/skill/__init__.py`\n- Flow 解析：`src/kimi_cli/skill/flow/mermaid.py` / `src/kimi_cli/skill/flow/d2.py`\n- Flow 数据结构：`src/kimi_cli/skill/flow/__init__.py`\n- `KimiSoul` 与 `FlowRunner`：`src/kimi_cli/soul/kimisoul.py`\n- `SlashCommand`：`src/kimi_cli/utils/slashcmd.py`\n- 静态 soul commands：`src/kimi_cli/soul/slash.py`\n- Shell UI：`src/kimi_cli/ui/shell/__init__.py`\n- Mermaid 示例：`flowchart.mmd`\n"
  },
  {
    "path": "klips/klip-11-kimi-code-rename.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-26\nStatus: Implemented\n---\n\n# KLIP-11: Rebrand Kimi CLI -> Kimi Code CLI (Docs + UI Copy)\n\n## 背景\n\n- 项目仓库与 PyPI 主包仍为 `kimi-cli`，Python 导入路径为 `kimi_cli`。\n- 已存在 `kimi-code` 包作为薄包装以保留名称，但不计划切换主包名。\n- 当前需求是最小化改动：仅更新用户可见文案与模型提示词中的品牌为「Kimi Code CLI」。\n\n## 目标\n\n- 用户文档与 README 统一品牌为 **Kimi Code CLI**。\n- Shell UI 与 ACP/Wire 相关的用户可见文案统一品牌为 **Kimi Code CLI**。\n- 默认 system prompt 与内置技能提示词统一品牌为 **Kimi Code CLI**。\n- 保持命令与包名不变：`kimi` 命令、`kimi-cli` 包、`kimi_cli` 导入路径继续使用。\n- `kimi-code` 继续维护以防名称被占用，但不作为主安装路径。\n\n## 非目标/约束\n\n- 不更改包名/导入路径/命令名。\n- 不更改 User-Agent、更新 URL、二进制路径。\n- 不更改仓库名、文档站点 URL、构建/发布流程。\n- 不改历史变更记录中的事实表述（如旧包名迁移说明）。\n\n## 仓库扫描（用户可见文案）\n\n需要改名的文档主要集中在以下位置（均含大量 `Kimi CLI` 文案）：\n\n- **顶层文档**：`README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`\n- **文档站点配置/入口**：`docs/.vitepress/config.ts`, `docs/index.md`,\n  `docs/en/index.md`, `docs/zh/index.md`, `docs/package.json`\n- **文档内容**：`docs/en/**`, `docs/zh/**`, `docs/AGENTS.md`\n- **示例说明**：`examples/*/README.md`\n- **Shell UI**：`src/kimi_cli/ui/shell/*`\n- **运行时品牌名称**：`src/kimi_cli/constant.py`, `src/kimi_cli/acp/server.py`,\n  `src/kimi_cli/cli/__init__.py`, `src/kimi_cli/wire/server.py`\n- **系统提示词**：`src/kimi_cli/agents/default/system.md`\n- **内置技能提示词**：`src/kimi_cli/skills/*/SKILL.md`\n- **测试快照**：`tests/core/test_default_agent.py`\n\n## 文案规则（避免误导）\n\n- 正文、标题用 **Kimi Code CLI**。\n- 命令/包名保持现状：`kimi`, `kimi-cli`, `zsh-kimi-cli` 等不要改。\n- 与实际输出绑定的字段名/命令保持不变，例如 `kimi_cli_version`、`uv tool upgrade kimi-cli`。\n- 历史说明保留真实名称（如 “rename package name `ensoul` to `kimi-cli`”）。\n\n## 已完成\n\n- README 与文档站点入口统一品牌为 Kimi Code CLI（保留 `kimi-cli` 的 repo/徽章/链接）。\n- 文档正文（`docs/en/**`, `docs/zh/**`, `docs/AGENTS.md`）与示例 README 完成文案替换，\n  代码块/输出示例中保留实际命令与字段名。\n- Shell UI 文案与 ACP/Wire 可见文案完成替换（欢迎语、提示语、setup、更新提示）。\n- 默认 system prompt 与内置 skills 提示词完成替换，避免模型沿用旧品牌回复。\n- 相关测试快照同步更新（默认 agent prompt）。\n- 站点同步脚本与项目级 AGENTS 文案同步更新。\n- `packages/kimi-code/` 作为薄包装包已存在，随 `kimi-cli` 版本发布。\n\n## 已确认\n\n- 文档站点 URL 继续保留 `moonshotai.github.io/kimi-cli`。\n- 用户文档不提 `kimi-code` 包名，仅在内部维护该占位包。\n"
  },
  {
    "path": "klips/klip-12-wire-initialize-external-tools.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-14\nStatus: Implemented\n---\n\n# KLIP-12: Wire 初始化协商与外部工具调用\n\n## Summary\n\n为 Wire 模式引入 client-to-server 的 `initialize` 握手，支持 client 提交 `external_tools`\n定义、server 回传 soul-level `slash_commands` 列表，并扩展 `request` 方法以承载\n`ToolCallRequest`（外部工具调用请求）。新增 `ApprovalResponse` 类型，与 `ToolResult`\n对称，统一 `request` 的响应语义。\n\n## 背景与动机\n\n当前 Wire 协议（`docs/zh/customization/wire-mode.md` + `src/kimi_cli/wire/*`,\n`src/kimi_cli/ui/wire/*`）只包含：\n\n- `prompt`/`cancel`（client -> server）\n- `event`/`request`（server -> client；`request` 仅用于审批）\n\n缺口：\n\n- 缺少初始化协商：client 无法在会话开始时提交能力与扩展信息。\n- 外部工具无法接入：client 自带的工具（例如 IDE 内部工具）不能注册给模型使用。\n- Slash commands 无法被外部 UI 感知：client 只能硬编码或忽略，无法展示/补全。\n- `request` 返回结构不统一：审批返回是一个特化结构，无法复用给 tool 请求。\n\n因此需要一个结构化的初始化协商和对称的 request/response 模型。\n\n## 目标\n\n- 新增 `initialize` 请求，支持 client 提供 `external_tools`，server 返回 soul-level\n  `slash_commands`。\n- 将 server -> client 的 tool 调用请求标准化为 `request` 方法，params 为 `ToolCallRequest`。\n- 引入 `ApprovalResponse` 类型（必要时重命名现有 Response literal），让\n  `request` 的返回类型统一为 `ApprovalResponse | ToolResult`。\n- 保持向后兼容：旧 client 仍可直接 `prompt`。\n\n## 非目标\n\n- 不改变 `ToolCall`/`ToolResult` 的核心结构。\n- 不引入新的传输通道（仍为 JSON-RPC over stdio）。\n- 不讨论外部工具的权限或安全策略（由 client 自行处理）。\n\n## 设计概览\n\n### 1) `initialize` 握手\n\n新增 client -> server 的 JSON-RPC 请求 `initialize`。它是可选但推荐的握手：\n\n- client 提交 `external_tools`、`protocol_version`。\n- server 返回协商后的 `protocol_version`、`slash_commands`（仅 soul-level）等。\n\n若 client 不发送 `initialize`，服务端行为保持现状：不注册 external tools，也不推送\nslash command 列表。\n\n### 2) ExternalToolCall 请求\n\n扩展 `request` 方法语义：\n\n- 现状：`request` 仅携带 `ApprovalRequest`，响应为审批结果。\n- 目标：`request` 可携带 `ApprovalRequest | ToolCallRequest`。\n  - `ApprovalRequest` 表示审批。\n  - `ToolCallRequest` 表示 ExternalToolCall（server 请求 client 执行外部工具）。\n\n响应类型统一为：`ApprovalResponse | ToolResult`。\n\n### 3) ApprovalResponse 类型\n\n将审批响应抽象为 `ApprovalResponse`，与 `ToolResult` 对称：\n\n- `ApprovalResponse` 对应 `ApprovalRequest`。\n- `ToolResult` 对应 `ToolCall`/`ToolCallRequest`。\n\n如果需要消除命名冲突，现有 `Response` literal 可改名为 `ApprovalResponseKind`。\n\n## 协议变更细节\n\n### `initialize` 请求\n\n#### Request\n\n```json\n{\n  \"jsonrpc\": \"2.0\",\n  \"method\": \"initialize\",\n  \"id\": \"init-1\",\n  \"params\": {\n    \"protocol_version\": \"1.1\",\n      \"client\": {\"name\": \"my-ui\", \"version\": \"0.3.0\"},\n      \"external_tools\": [\n        {\n          \"name\": \"open_in_ide\",\n          \"description\": \"Open file in IDE\",\n          \"parameters\": {\n            \"type\": \"object\",\n            \"properties\": {\"path\": {\"type\": \"string\"}},\n            \"required\": [\"path\"]\n          }\n        }\n      ]\n  }\n}\n```\n\n#### Types (TS 风格)\n\n```ts\ninterface InitializeParams {\n  protocol_version: string\n  client?: { name: string; version?: string }\n  external_tools?: ExternalTool[]\n}\n\ninterface ExternalTool {\n  name: string\n  description: string\n  parameters: object // JSON Schema\n}\n```\n\n### `initialize` 响应\n\n```json\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": \"init-1\",\n  \"result\": {\n    \"protocol_version\": \"1.1\",\n    \"server\": {\"name\": \"kimi-cli\", \"version\": \"0.68.0\"},\n    \"slash_commands\": [\n      {\"name\": \"init\", \"description\": \"Analyze the codebase ...\", \"aliases\": []},\n      {\"name\": \"compact\", \"description\": \"Compact the context\", \"aliases\": []}\n    ],\n    \"external_tools\": {\n      \"accepted\": [\"open_in_ide\"],\n      \"rejected\": [{\"name\": \"shell\", \"reason\": \"conflicts with builtin tool\"}]\n    }\n  }\n}\n```\n\n#### Types\n\n```ts\ninterface InitializeResult {\n  protocol_version: string\n  server: { name: string; version: string }\n  slash_commands: SlashCommand[]\n  external_tools?: {\n    accepted: string[]\n    rejected: { name: string; reason: string }[]\n  }\n}\n\ninterface SlashCommand {\n  name: string\n  description: string\n  aliases: string[]\n}\n```\n\n备注：\n\n- `slash_commands` 仅包含 soul-level 命令：\n  `src/kimi_cli/soul/slash.py` registry + 动态 skills（`KimiSoul._register_skill_commands`）。\n- `external_tools` 的接受/拒绝结果可选，用于反馈命名冲突或 schema 校验失败。\n\n## ExternalToolCall 与 ApprovalResponse\n\n### Wire 请求类型扩展\n\n```ts\ntype Request = ApprovalRequest | ToolCallRequest\n// ToolCallRequest 在 request 语境下即 ExternalToolCall\n\ninterface ToolCallRequest {\n  id: string\n  name: string\n  arguments?: string | null // JSON string\n}\n```\n\n### 请求响应类型\n\n```ts\ntype RequestResult = ApprovalResponse | ToolResult\n\ninterface ApprovalResponse {\n  request_id: string\n  response: ApprovalResponseKind\n}\n\ntype ApprovalResponseKind = \"approve\" | \"approve_for_session\" | \"reject\"\n```\n\n### ExternalToolCall 示例\n\nServer -> Client:\n\n```json\n{\"jsonrpc\":\"2.0\",\"method\":\"request\",\"id\":\"tc-1\",\"params\":{\n  \"type\":\"ToolCallRequest\",\n  \"payload\":{\"id\":\"tc-1\",\"name\":\"open_in_ide\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}\n}}\n```\n\nClient -> Server:\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":\"tc-1\",\"result\":{\n  \"tool_call_id\":\"tc-1\",\n  \"return_value\":{\n    \"is_error\":false,\n    \"output\":\"Opened\",\n    \"message\":\"Opened README.md\",\n    \"display\":[]\n  }\n}}\n```\n\n### ApprovalRequest 示例（保持兼容）\n\nServer -> Client:\n\n```json\n{\"jsonrpc\":\"2.0\",\"method\":\"request\",\"id\":\"req-1\",\"params\":{\n  \"type\":\"ApprovalRequest\",\n  \"payload\":{\"id\":\"req-1\",\"tool_call_id\":\"tc-9\",\"sender\":\"Shell\",\"action\":\"run shell\",\n    \"description\":\"Run command `ls`\",\"display\":[]}\n}}\n```\n\nClient -> Server:\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":\"req-1\",\"result\":{\n  \"request_id\":\"req-1\",\n  \"response\":\"approve\"\n}}\n```\n\n## Server 侧行为\n\n### 初始化协商\n\n- `WireOverStdio` 新增 `_handle_initialize`：\n  - 解析 `external_tools`。\n  - 将外部工具注册到 `KimiToolset`（新增 `WireExternalTool`）。\n  - 若同名外部工具已存在，则按最新 schema/描述覆盖更新。\n  - 采集 `KimiSoul.available_slash_commands` 生成 `slash_commands`。\n  - 返回协商结果。\n\n### 外部工具执行\n\n- `WireExternalTool` 以工具代理的形式加入 toolset。\n- 当模型触发该工具：\n  - server 通过 Wire `request` 发送 `ToolCallRequest` 给 client。\n  - 等待 client 返回 `ToolResult`。\n  - 将 `ToolResult.return_value` 作为 tool 执行结果回传给模型。\n\n### 事件流\n\n- `ToolCall` 和 `ToolResult` 仍可作为 `event` 对 UI 可视化输出。\n- External tool 的执行结果同时参与 `event` 流与 `request` 响应，可用于录像/回放。\n\n## Client 侧变化\n\n### 启动流程\n\n1. 建立 stdio 连接。\n2. 发送 `initialize`：\n   - 提交 `external_tools`。\n   - 可携带 client 名称与版本。\n3. 接收 `slash_commands`：\n   - 用于 UI 展示与自动补全。\n4. 进入交互阶段（`prompt`/`cancel`）。\n\n### `request` 处理逻辑\n\n收到 `request` 时根据 params 类型分派：\n\n- `ApprovalRequest` -> 弹出审批 UI -> 返回 `ApprovalResponse`。\n- `ToolCallRequest` -> 执行 external tool -> 返回 `ToolResult`。\n\n对未知类型返回 JSON-RPC error 并记录日志。\n\n## 兼容性与降级策略\n\n- 旧 client：不发 `initialize`，协议维持 v1.0 行为。\n- 新 client + 旧 server：`initialize` 可能返回 JSON-RPC method not found（-32601），\n  client 应自动降级并继续使用 v1.0。\n- 若 `external_tools` 校验失败或重名，server 在 `initialize` result 中标记为 rejected，\n  并忽略该工具。\n- 旧类型名 `ApprovalRequestResolved` 在反序列化时仍可被识别。\n\n## 实施步骤（建议）\n\n1. 协议与类型层\n   - `src/kimi_cli/wire/types.py`：\n     - `Request = ApprovalRequest | ToolCallRequest`。\n     - 新增 `ApprovalResponse`（保留旧 `ApprovalRequestResolved` 类型名兼容）。\n   - `src/kimi_cli/wire/serde.py` 无需改动（由 Envelope 支持新类型）。\n2. JSON-RPC 层\n   - `src/kimi_cli/ui/wire/jsonrpc.py`：\n     - 添加 `JSONRPCInitializeMessage`。\n     - `JSONRPCInMessage`/`OutMessage` 增加 `initialize`。\n3. Wire 服务端\n   - `src/kimi_cli/ui/wire/__init__.py`：\n     - 实现 `_handle_initialize`。\n     - 增强 `_pending_requests` 以支持 `ToolCallRequest`。\n4. 工具层\n   - `src/kimi_cli/soul/toolset.py`：\n     - 新增 `WireExternalTool`，内部通过 Wire 请求执行。\n5. 协议版本与文档\n   - `src/kimi_cli/ui/wire/protocol.py` 提升协议版本。\n   - 更新 `docs/zh/customization/wire-mode.md` 并新增 external tools 章节。\n\n## 最终效果与用法\n\n- external tools 成为 Wire session 可协商的能力，client 可以把自己的工具直接暴露给模型。\n- 外部 UI 可以动态展示 soul-level slash commands，不再硬编码。\n- `request` 方法在语义与类型上统一（审批与外部工具调用共用一套请求框架）。\n- 旧 client 无需修改即可继续工作。\n\n## 关键参考位置\n\n- Wire 协议与类型：`src/kimi_cli/wire/types.py`, `src/kimi_cli/wire/serde.py`\n- Wire JSON-RPC：`src/kimi_cli/ui/wire/jsonrpc.py`, `src/kimi_cli/ui/wire/__init__.py`\n- Slash commands：`src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/slashcmd.py`\n- Wire 文档：`docs/zh/customization/wire-mode.md`\n"
  },
  {
    "path": "klips/klip-14-kimi-code-oauth-login.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-24\nStatus: Implemented\n---\n\n# KLIP-14: Kimi Code OAuth /login\n\n## 背景与现状\n\n* `/setup` 位于 `src/kimi_cli/ui/shell/setup.py`：选择平台 -> 输入 API key -> 拉取模型 ->\n  写入 `config.providers` / `config.models` / `default_model`，并在 Kimi Code 平台时自动配置\n  `services.moonshot_search` / `services.moonshot_fetch`。\n* Kimi Code 平台在 `src/kimi_cli/auth/platforms.py` 中定义，`base_url` 为\n  `https://api.kimi.com/coding/v1`。\n* 现有配置以 API key 作为 `Authorization: Bearer <api_key>`，`/usage` 也依赖该 Bearer。\n\n## 目标\n\n* 为 Kimi Code 平台提供基于 OAuth 的 `/login` 斜杠命令，替代手动 API key 输入。\n* 提供 `/logout` 与 `kimi logout`，清理 OAuth 凭据并撤销本地授权状态。\n* OAuth 流程基于 Device Authorization Grant（后端现有实现），CLI 轮询 token\n  endpoint 获取 access_token；如后续支持，可扩展为 Authorization Code + PKCE。\n* 登录成功后与 `/setup` 一致：拉取模型、写入托管 provider/model、设置默认模型和\n  search/fetch 服务。\n* Token 可自动刷新，过期后尽量无感恢复。\n\n## 非目标\n\n* 不支持 Moonshot Open Platform 等其他平台。\n* 不替代 `/setup` 或移除 API key 方案。\n* 不实现完整账户管理或多账号切换。\n\n## 设计概览\n\n### 1) Kimi Code OAuth 端点与要求（Device Authorization Grant）\n\n后端当前提供 Device Authorization Grant（RFC 8628），CLI 需要对接实际端点：\n\n* OAuth host（可配置）：\n  * 默认：`https://auth.kimi.com`\n  * 可用环境变量覆盖：`KIMI_CODE_OAUTH_HOST` 或 `KIMI_OAUTH_HOST`\n* Public client：\n  * `client_id`: `17e5f671-d194-4dfb-9706-5516cb48c098`\n  * 不需要 client secret\n* 端点：\n  * `POST /api/oauth/device_authorization`\n  * `POST /api/oauth/token`（device_code + refresh_token）\n* Scope（若后端要求）：\n  * 当前实现仅发送 `client_id`，未携带 scope\n* 典型返回字段：\n  * `user_code` / `device_code`\n  * `verification_uri` / `verification_uri_complete`\n  * `expires_in` / `interval`\n\n**请求头（真实后端要求）**\n\n所有 token 相关请求需要附带设备信息头（示例值按实际环境生成）：\n\n```python\nfrom kimi_cli.constant import VERSION\nimport platform\nimport socket\n\nCOMMON_HEADERS = {\n    \"X-Msh-Platform\": \"kimi_cli\",\n    \"X-Msh-Version\": VERSION,\n    \"X-Msh-Device-Name\": platform.node() or socket.gethostname(),\n    \"X-Msh-Device-Model\": \"<os-name + version + arch>\",\n    \"X-Msh-Os-Version\": platform.version(),\n    \"X-Msh-Device-Id\": \"<stable-uuid>\",\n}\n```\n\n* `X-Msh-Platform` 固定为 `kimi_cli`。\n* `X-Msh-Version` 使用 `kimi_cli.constant.VERSION`（实际版本号）。\n* `X-Msh-Device-Name` 使用设备名（`platform.node()` / `socket.gethostname()`）。\n* `X-Msh-Device-Model` 使用系统名 + 版本号 + 架构（如 `Windows 11 AMD64`、\n  `macOS 15.1.1 arm64`）。\n* `X-Msh-Os-Version` 使用 `platform.version()`（与 `Environment.os_version` 一致）。\n* `X-Msh-Device-Id` 为稳定 UUID，首次生成后持久化，建议存放于 `~/.kimi/device_id`\n  并设置权限 `0600`。\n\n### 2) /login UX 流程\n\n1. `/login` 与 `kimi login` 仅支持 Kimi Code 平台；若不是默认 config location 则直接拒绝。\n2. `POST /api/oauth/device_authorization` 获取 `verification_uri_complete` 与 `user_code`。\n3. 直接 `webbrowser.open(verification_uri_complete)`，同时打印 Verification URL\n   （`verification_uri_complete` 通常已包含 user_code）。\n4. 按 `interval` 轮询 `POST /api/oauth/token`，\n   `grant_type=urn:ietf:params:oauth:grant-type:device_code`。\n   * 仅特判 `expired_token` -> 重新发起 `/login`\n   * 其他错误 -> 继续按 interval 等待（不特殊处理 `slow_down`）\n5. 交换成功 -> 保存 tokens，拉取模型，写入托管 provider/model，设置默认模型和\n   search/fetch 服务（流程同 `/setup`），access_token 同时用于 LLM/search/fetch。\n6. Shell `/login` 成功后触发 `Reload`；`kimi login` 仅执行登录流程并退出。\n\n### 3) 用户授权提示\n\nCLI 提示用户打开浏览器并输入 user code，不再需要本地回调或手动拷贝 code：\n\n```\nPlease visit the following URL and enter the user code to authorize:\nVerification URL: {verification_uri_complete}\n```\n\n注意：`ApproveDeviceGrant` 是 Web 侧的审批接口，仅用于测试，CLI 不应调用。\n\n### 4) /logout UX 流程\n\n1. `/logout` 与 `kimi logout` 仅支持 Kimi Code 平台；若不是默认 config location 则直接拒绝。\n2. 清理凭据存储：\n   * keychain：删除 `service=kimi-code` + `key=oauth/kimi-code`\n   * 文件：删除 `~/.kimi/credentials/kimi-code.json`\n3. 更新 `config.toml`（仅默认位置）：\n   * 删除 `providers.\"managed:kimi-code\"` 整体配置\n   * 删除 `models` 中所有 `provider = \"managed:kimi-code\"` 的条目\n   * 若 `default_model` 指向被删除的模型，则清空 `default_model`\n   * `services.moonshot_search = None`\n   * `services.moonshot_fetch = None`\n4. Shell `/logout` 成功后触发 `Reload`；`kimi logout` 仅执行退出流程并退出。\n\n### 5) Token 与凭据存储（最佳实践）\n\n优先使用系统凭据存储，避免将 access_token / refresh_token 明文落盘：\n\n* 首选：OS keychain（`keyring`）\n  * service: `kimi-code`\n  * key: `oauth/kimi-code`\n  * value: JSON（access_token、refresh_token、expires_at、scope、token_type）\n* 兜底：`~/.kimi/credentials/kimi-code.json`，权限 `0600`\n\n`config.toml` 仅保存非敏感元信息与引用，不直接写入 token。`expires_at` 与 `scope` 也放在\n凭据存储中以避免重复更新。provider 与 services 都使用同一套 oauth 引用，运行时通过\n`runtime.oauth` 读取 access_token 并注入调用路径（内存态），不支持退化为写入\n`config.toml`：\n\n```toml\n[providers.\"managed:kimi-code\"]\ntype = \"kimi\"\nbase_url = \"https://api.kimi.com/coding/v1\"\napi_key = \"\"\noauth = { storage = \"keyring\", key = \"oauth/kimi-code\" } # keyring 不可用时为 file\n\n[services.moonshot_search]\nbase_url = \"https://api.kimi.com/coding/v1/search\"\napi_key = \"\"\noauth = { storage = \"keyring\", key = \"oauth/kimi-code\" } # keyring 不可用时为 file\n\n[services.moonshot_fetch]\nbase_url = \"https://api.kimi.com/coding/v1/fetch\"\napi_key = \"\"\noauth = { storage = \"keyring\", key = \"oauth/kimi-code\" } # keyring 不可用时为 file\n```\n\n`api_key` 为空字符串仅作为占位，运行时注入 access_token。\n若 keychain 不可用，使用 `~/.kimi/credentials/kimi-code.json`；不允许写入 `config.toml`。\n\n### 6) Token 刷新策略\n\n* 每次用户 prompt 触发时，在后台读取凭据存储中的 `expires_at` 并尽量刷新：\n  * 若已过期则强制刷新；若剩余时间 < 5 分钟则后台刷新\n  * 挂载点：`KimiSoul.run(...)` 开始时触发 `ensure_fresh`\n* 刷新流程（带上上面的设备信息 headers）：\n  * `grant_type=refresh_token`\n  * `refresh_token`, `client_id`\n* 刷新成功：\n  * 更新凭据存储中的 access_token / refresh_token / expires_at\n  * 更新内存中的 `api_key`（仅对 `Kimi` provider 生效）\n* 刷新失败：\n  * 仅记录日志警告，不触发 UI 提示或 `Reload`\n\n### 7) LLM 与工具的热更新策略\n\n* 目标：刷新 token 后不打断用户输入与对话。\n* LLM 热更新：\n  * 当前实现直接更新 `Kimi` chat provider 的 `client.api_key`，不触发重建或 Reload。\n* 搜索/抓取：\n  * `SearchWeb` / `FetchURL` 每次调用从 `runtime.oauth.resolve_api_key(...)` 获取 token，\n    不缓存 api_key，刷新后立即生效。\n\n### 8) 与 /setup 的关系\n\n* `/setup` 仍保留 API key 交互，OAuth 仅通过 `/login`。\n* `/login` 使用与 `/setup` 相同的托管命名空间：\n  * provider key: `managed:kimi-code`\n  * model key: `kimi-code/<model-id>`\n* 可选：未来在 `/setup` 中提供 “Login with browser (OAuth)” 入口，但非本次目标。\n\n## 边界与兼容性\n\n* 如果用户使用 `--config` / `--config-file`，直接拒绝 `/login`（避免凭据落在非默认路径）。\n* 只要平台提供 `search_url` / `fetch_url` 就会写入 `services` 配置。\n* OAuth 模型和 API 兼容性与当前 Bearer key 完全一致。\n\n## 待确认事项\n\n* Device Authorization 是否强制要求 `scope`，以及 scope 的最终命名（当前实现未发送）。\n\n## 关键参考位置\n\n* `/setup` 入口：`src/kimi_cli/ui/shell/setup.py`\n* 平台定义：`src/kimi_cli/auth/platforms.py`\n* 配置结构：`src/kimi_cli/config.py`\n* Kimi provider：`packages/kosong/src/kosong/chat_provider/kimi.py`\n"
  },
  {
    "path": "klips/klip-15-kagent-sidecar-integration.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-26\nStatus: Draft\n---\n\n# KLIP-15: kagent Rust kernel 以 sidecar 方式接入 kimi-cli\n\n## 背景与现状\n\n* Python 版 kimi-cli 的 Agent kernel 由 `KimiSoul` 驱动（`src/kimi_cli/soul/kimisoul.py`）。\n* UI（shell/print）与 ACP server 通过 Wire 事件与 kernel 交互。\n* Wire 协议已稳定，详见 `docs/zh/customization/wire-mode.md`（JSON-RPC 2.0 + stdio）。\n* Rust 版 kagent 已实现相同协议与核心逻辑，目标是替换 Python kernel，但**保留 Python UI/ACP**。\n\n## 目标\n\n* 在 **不删除** Python kernel 的前提下，引入 Rust kagent 作为默认或可选 kernel。\n* Python 侧仍负责：UI（shell/print）、ACP server、配置/会话/技能发现。\n* kernel 实现通过 **stdio wire 协议** 与 Python 通讯，保持与现有外部 Wire 客户端一致。\n* 支持 **fallback**：Rust kernel 启动失败或运行异常时回退到 Python kernel。\n* 打包/发布流程支持多平台（Linux/macOS/Windows，含 Linux ARM），并能在 wheel 中携带 kagent 二进制。\n\n## 非目标\n\n* 不把 Rust kernel 直接嵌入 Python 进程（不做 Pyo3 绑定）。\n* 不移除 Python kernel 代码；仅在运行时切换。\n* 不修改 wire 协议。\n\n## 方案概览（sidecar + stdio wire）\n\n将 Rust kagent 视为 **实现 wire 协议的外部 server**，Python 通过一个 `WireBackedSoul`（代理 Soul）启动子进程并转发消息：\n\n```\nPython UI/ACP  <->  Python Wire  <->  WireBackedSoul  <->  stdio  <->  kagent\n```\n\n* Python UI/ACP 仍只感知本地 `Wire`，无需改动。\n* `WireBackedSoul` 实现 Soul 接口（`run()`/`status`/`available_slash_commands`），用 Rust 进程替代 `KimiSoul` 执行。\n* 所有 Approval/ToolCall/StatusUpdate 事件由 Rust 发送，Python 仅做**消息转发与本地 UI 适配**。\n\n## 详细设计\n\n### 1) 新增 WireBackedSoul\n\n**职责**\n* 启动/管理 Rust kagent 进程（`kagent --wire`）。\n* 通过 stdio 与 Rust kernel 进行 JSON-RPC 交互。\n* 将 Rust 发来的 `event` 透传为 `wire_send(...)`（送到 Python UI/ACP）。\n* 将 Rust 发来的 `request`（Approval/ToolCall）映射为本地 Wire 请求，收集 UI 响应，再回写给 Rust。\n\n**最小行为**\n* `initialize`：可选握手，获取 slash commands / server info。\n* `prompt`：触发一轮执行，Rust 侧持续发送事件与请求，直到返回 `PromptResult`。\n* `cancel`：转发取消请求给 Rust。\n\n**对象模型**\n* `WireBackedSoul` 持有：\n  - `process`（subprocess handle）\n  - `client`（wire client，负责 JSON-RPC 的 request/response）\n  - `status`（来自 StatusUpdate 事件）\n  - `slash_commands`（来自 initialize result）\n\n### 2) Approval/ToolCall 转发\n\nRust -> Python：\n* Rust 通过 wire `request` 发送 `ApprovalRequest` / `ToolCallRequest`。\n* Python 侧创建本地 `ApprovalRequest`/`ToolCallRequest` 对象，`wire_send` 到 UI。\n* UI resolve 后，Python 将结果作为 JSON-RPC response 回写给 Rust。\n\n关键点：不复制/重建 Rust-side pending future，而是**用本地 wire 作为交互表面**，保证 UI 行为与现有一致。\n\n### 3) 进程生命周期与容错\n\n* 启动：`kagent --wire`（必要时附加 `--config` 或环境变量）\n* 退出：\n  - 正常：Rust 自行退出；Python 处理 EOF 并结束 run\n  - 异常：Python 检测 stderr/exit code，回退到 Python kernel 或报错\n* 取消：调用 wire `cancel` 请求\n* 失败回退：可配置 `kernel = rust` 或 `kernel = python`；当 rust 失败时自动 fallback\n\n### 4) 运行时选择与配置\n\n建议增加运行时切换方式（优先级从高到低）：\n1. CLI flag：`--kernel rust|python`（默认可为 `rust`）\n2. 环境变量：`KIMI_KERNEL=rust|python`\n3. 配置文件：`[runtime] kernel = \"rust\"`\n\n在 `KimiCLI.create` 中选择 `KimiSoul` 或 `WireBackedSoul`。\n\n### 5) 打包与分发（maturin）\n\n**策略**：maturin 构建 Python package + 打包 sidecar 二进制。\n\n* 产物：\n  - Python wheel（包含 `kagent` 可执行文件）\n  - Python 代码负责定位并调用该二进制\n* 运行时查找优先级：\n  1) `KIMI_KERNEL_BIN` 环境变量\n  2) package 内嵌二进制路径\n  3) 系统 PATH\n\n**平台矩阵**\n* Linux x86_64 / ARM64\n* macOS ARM64\n* Windows x86_64\n\n### 6) 兼容与迁移策略\n\n* Python kernel 保留并可显式启用。\n* Rust kernel 失败可自动 fallback。\n* 既有 wire/client 协议不变。\n* e2e 测试通过 `KIMI_E2E_WIRE_CMD` 指定 Rust kernel。\n\n## 测试与验证\n\n* Rust：`cargo fmt` / `cargo check` / `cargo test`\n* Python：现有 UI/ACP 测试继续\n* e2e：`KIMI_E2E_WIRE_CMD=... uv run pytest tests_e2e`\n* CI：增加多平台 Rust + e2e 覆盖\n\n## 替代方案（不选）\n\n**Pyo3 绑定（in-process Rust kernel）**\n* 优点：更低延迟、无进程管理。\n* 缺点：绑定维护成本高、生命周期/async 与 GIL 复杂、隔离性差。\n\n结论：sidecar 模式更符合现有 wire 设计与业界实践（binary + Python wrapper）。\n\n## 开放问题\n\n* Rust kernel 是否需要从 Python 侧注入更多 runtime 信息（如 workdir listing / skills）？\n* 是否需要在 wire `initialize` 中扩展 metadata（如 kernel capabilities / feature flags）？\n* 失败回退是否应默认启用，还是仅在 `kernel=auto` 时启用？\n"
  },
  {
    "path": "klips/klip-2-acpkaos.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2025-12-29\nStatus: Implemented\n---\n\n# KLIP-2: ACPKaos, a LocalKaos variant that redirects operations to ACP clients\n\n## Summary\n\nBuild ACPKaos as a near-drop-in LocalKaos variant. It behaves like LocalKaos for almost everything, but redirects the few operations that let ACP clients observe what the agent did: file reads/writes and terminal commands. This keeps tool behavior unchanged while making ACP the execution backend.\n\n## Motivation\n\n* We want ACP clients (e.g. Zed) to observe file edits and command execution.\n* Tool-level ACP replacements are functional but not the most fundamental design.\n* KAOS already abstracts OS operations; ACP fits naturally as a KAOS backend.\n* Implementing ACP behavior as tools duplicates logic already present in core tools; ACPKaos eliminates that repetition by moving the integration down a layer.\n\n## Constraints and references\n\n* Use ACP only for methods the client explicitly advertises: `fs/read_text_file`, `fs/write_text_file`, and `terminal/*`. See [ACP initialization](https://agentclientprotocol.com/protocol/initialization), [ACP file system](https://agentclientprotocol.com/protocol/file-system), and [ACP terminals](https://agentclientprotocol.com/protocol/terminals).\n* All other operations should pass through to LocalKaos.\n* Keep behavior of existing tools (`Shell`, `ReadFile`, `WriteFile`, `StrReplaceFile`) unchanged.\n* Capability flags are independent: `readTextFile` and `writeTextFile` may be enabled separately. The implementation must not call unsupported ACP methods.\n\n## Current baseline (no new behavior assumed)\n\n* KAOS is a contextvar-based abstraction with LocalKaos as default.\n* Tools call KAOS:\n  * `Shell` -> `kaos.exec`.\n  * `ReadFile` -> `KaosPath.exists/is_file/read_lines`.\n  * `WriteFile` / `StrReplaceFile` -> `KaosPath.read_text/write_text/append_text`.\n* ACP integration today is tool-level (terminal replacement); an ACP-backed file tool swap has been experimented with locally but is not merged.\n\n## Design: ACPKaos in one page\n\nACPKaos wraps LocalKaos and overrides only the minimal surface needed by tools. Everything else delegates to LocalKaos.\n\n### Minimal overrides\n\n* `exec` -> ACP terminal operations.\n* `readtext` -> ACP `fs/read_text_file`.\n* `writetext` -> ACP `fs/write_text_file` (append uses ACP only when both read+write are supported; otherwise fall back to LocalKaos).\n* `readlines` -> optional: implement ACP paging, or update `ReadFile` to use `readtext` and split lines.\n* `stat` -> keep LocalKaos (optional ACP fallback if unsaved buffers matter).\n\n### Known limitation (unsaved buffers)\n\nACP `fs/read_text_file` can expose editor buffers that are not yet saved on disk. However, the current tool chain checks `KaosPath.exists/is_file` before reading; those checks use LocalKaos and will return false for buffer-only files. For now, we accept this limitation and keep `stat/exists/is_file` local. If we later want unsaved buffers to work end-to-end, we must revisit these checks.\n\n### Pseudo-code (intent, not syntax)\n\n```Plain\nACPKaos {\n  init(client, session_id, caps, fallback=local_kaos)\n    # bind ACP vs local functions once, based on caps\n    self._readtext = caps.fs.readTextFile ? acp_readtext : fallback.readtext\n    self._writetext = caps.fs.writeTextFile ? acp_writetext : fallback.writetext\n    self._exec = caps.terminal ? acp_exec : fallback.exec\n    self._appendtext = (caps.fs.readTextFile && caps.fs.writeTextFile)\n      ? acp_appendtext\n      : fallback_appendtext   # implemented via fallback.writetext(mode=\"a\")\n\n  # pass-throughs\n  pathclass/normpath/gethome/getcwd/chdir/stat/iterdir/glob/readbytes/writebytes/mkdir\n    -> fallback\n\n  readtext(path):\n    return self._readtext(abs(path))\n\n  readlines(path):\n    # split readtext into lines (keeps ReadFile behavior unchanged)\n    text = self._readtext(abs(path))\n    return text.splitlines(keepends=True)\n\n  writetext(path, data, mode):\n    if mode == \"a\":\n      return self._appendtext(abs(path), data)\n    return self._writetext(abs(path), data, mode)\n\n  exec(args...):\n    return self._exec(args...)\n}\n```\n\n### ACPProcess (terminal adapter, intent only)\n\n```Plain\nACPProcess (implements KaosProcess) {\n  # Required because Shell expects a KaosProcess-compatible object.\n  spawn(args):\n    terminal_id = client.create_terminal(command, args, session_id, cwd=abs(cwd), outputByteLimit=limit)\n    start background poll (terminal_output) to refresh output\n\n  stdout/stderr:\n    ACP has no stderr split; choose stdout-only and document it.\n\n  wait():\n    concurrently:\n      wait_for_exit for authoritative status\n      terminal_output for incremental output\n    handle truncation:\n      if truncated or output no longer contains last_seen_tail -> reset delta base and note truncation\n    finally:\n      terminal/release (MUST), even on error/cancel; release kills running commands\n\n  kill():\n    client.terminal/kill\n}\n```\n\n## Integration points\n\n* Create ACPKaos per ACP session, holding `client`, `session_id`, and `client_capabilities`.\n* Set `current_kaos` for the ACP session run (contextvars are task-local); do this inside `prompt` so it covers a full turn.\n* Keep `kaos.chdir` behavior intact; ACPKaos should delegate `chdir` to LocalKaos.\n* Decide on tool-level replacements: preferred is to skip replacements when ACPKaos is active; transitional is to leave replacements as fallback for environments without ACPKaos.\n* ACP calls must use absolute paths to avoid `chdir` surprises.\n\n## Validation\n\n* Unit tests for ACPKaos:\n  * read/write calls hit ACP when caps allow.\n  * append uses read + write.\n  * exec returns output and exit codes.\n* Integration tests: run `Shell`, `ReadFile`, `WriteFile`, `StrReplaceFile` with ACPKaos active.\n* Manual test in Zed: read unsaved buffer, write changes, run command and confirm UI updates.\n* Tests will need a mocked ACP client; we can mirror patterns from the ACP Python SDK tests when implementing.\n"
  },
  {
    "path": "klips/klip-3-kimi-cli-user-docs.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2025-12-30\nStatus: Implemented\n---\n\n# KLIP-3: Kimi CLI User Documentation\n\n以下为后续文档大纲的层级约定：\n\n* `##` 二级标题：文档主导航 tab 链接（顶层主题）。\n* `###` 三级标题：侧边栏链接（进入具体页面或分组）。\n* 一级无序列表：该页面/分组下的内容块或子主题。\n* 二级无序列表：内容要点与写作提示，不要求一一对应页内小标题；其中 `参考代码` 统一列出该一级条目需要参考的代码位置。\n\n## 指南 / Guides\n\n### 开始使用 / Getting Started\n\n* Kimi CLI 是什么 / What is Kimi CLI\n  * 适用场景\n  * 技术预览状态说明\n  * 参考代码: `src/kimi_cli/app.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/soul/`, `src/kimi_cli/ui/`, `src/kimi_cli/tools/`, `README.md`, `src/kimi_cli/tools/file/`, `src/kimi_cli/tools/shell/`, `src/kimi_cli/tools/web/`, `src/kimi_cli/soul/toolset.py`, `CHANGELOG.md`, `src/kimi_cli/constant.py`, `src/kimi_cli/utils/changelog.py`\n* 安装与升级 / Install and upgrade\n  * 系统要求 / System requirements\n    * Python 3.13+\n    * 推荐使用 uv\n    * 参考代码: `pyproject.toml`, `README.md`, `Makefile`\n  * 安装 / Installation\n    * 参考代码: `README.md`, `pyproject.toml`, `scripts/`\n  * 升级 / Upgrade\n    * 参考代码: `README.md`, `src/kimi_cli/ui/shell/update.py`, `src/kimi_cli/ui/shell/__init__.py`\n  * 卸载 / Uninstall\n    * 参考代码: `README.md`\n* 第一次运行 / First run\n  * 启动 Kimi CLI / Launch Kimi CLI\n    * 在项目目录运行 `kimi`\n    * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `pyproject.toml`, `README.md`\n  * 配置平台与模型 / Configure platform and model\n    * 使用 `/setup` 配置\n    * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/app.py`, `src/kimi_cli/ui/shell/slash.py`\n  * 发现更多用法 / Discover more usage\n    * 使用 `/help` 查看\n    * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/slashcmd.py`\n\n### 常见使用案例 / Common Use Cases\n\n* 实现新功能 / Implement new feature\n  * 读 → 改 → 验证\n  * 参考代码: `src/kimi_cli/tools/file/`, `src/kimi_cli/tools/shell/`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/tools/file/read.py`, `src/kimi_cli/tools/file/write.py`, `src/kimi_cli/tools/shell/__init__.py`\n* 修复 bug / Fix bugs\n  * 参考代码: `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/tools/file/`, `src/kimi_cli/ui/shell/debug.py`, `src/kimi_cli/ui/shell/usage.py`\n* 理解项目 / Understand the codebase\n  * 参考代码: `src/kimi_cli/tools/file/glob.py`, `src/kimi_cli/tools/file/grep_local.py`, `src/kimi_cli/tools/file/read.py`, `src/kimi_cli/utils/path.py`\n* 自动化小任务 / Automate small tasks\n  * 参考代码: `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/tools/todo/`, `src/kimi_cli/tools/multiagent/task.py`, `src/kimi_cli/soul/toolset.py`\n* 自动化通用任务 / Automate general tasks\n  * 通用 topic 的 deep research 任务\n  * 数据分析任务\n\n### 交互与输入 / Interaction and input\n\n* Agent 与 Shell 模式 / Agent vs Shell mode\n  * Ctrl-X 切换模式\n  * Shell 模式运行本地命令\n  * 参考代码: `src/kimi_cli/ui/shell/__init__.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/utils/environment.py`, `src/kimi_cli/tools/shell/powershell.md`\n* Thinking 模式 / Thinking mode\n  * Tab 或 `--thinking` 切换\n  * 需模型支持\n  * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/cli.py`\n* 多行输入 / Multi-line input\n  * Ctrl-J 或 Alt-Enter\n  * 参考代码: `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/ui/shell/keyboard.py`\n* 剪贴板与图片粘贴 / Clipboard and image paste\n  * Ctrl-V 粘贴\n  * 需模型支持 `image_in`\n  * 参考代码: `src/kimi_cli/utils/clipboard.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/config.py`\n* 斜杠命令 / Slash commands\n  * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/slashcmd.py`\n* @ 路径补全 / @ path completion\n  * 参考代码: `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/utils/path.py`, `src/kimi_cli/tools/file/glob.py`\n* 审批与确认 / Approvals\n  * 一次 / 本会话 / 拒绝\n  * `--yolo` 或 `/yolo`\n  * 参考代码: `src/kimi_cli/soul/approval.py`, `src/kimi_cli/ui/shell/visualize.py`, `src/kimi_cli/tools/file/write.py`, `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/ui/shell/slash.py`\n\n### 会话与上下文 / Sessions and context\n\n* 会话续接 / Session resuming\n  * `--continue`、`--session`、`/sessions`\n  * 启动回放\n  * 参考代码: `src/kimi_cli/session.py`, `src/kimi_cli/metadata.py`, `src/kimi_cli/ui/shell/replay.py`, `src/kimi_cli/share.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/wire/serde.py`\n* 清空与压缩 / Clear and compact\n  * `/clear`（别名 `/reset`）\n  * `/compact`\n  * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/compaction.py`, `src/kimi_cli/soul/context.py`\n\n### 在 IDE 中使用 / Using in IDEs\n\n* 在 Zed 中使用 / Use in Zed\n  * `--acp` 参数\n  * IDE 配置\n  * 参考代码: `src/kimi_cli/acp/`, `src/kimi_cli/ui/acp/__init__.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/acp/AGENTS.md`, `README.md`, `src/kimi_cli/app.py`\n* 在 JetBrains IDE 中使用 / Use in JetBrains IDEs\n  * 同上 / Same as above\n\n### 集成到工具 / Integrations with tools\n\n* Zsh 插件 / Zsh plugin\n  * 快捷切换\n  * 参考代码: `README.md`, `src/kimi_cli/ui/shell/keyboard.py`\n\n## 定制化 / Customization\n\n### Model Context Protocol / Model Context Protocol\n\n* MCP 是什么 / What is MCP\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/acp/mcp.py`, `src/kimi_cli/tools/`\n* `kimi mcp` 子命令 / `kimi mcp` subcommands\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/cli.py`\n* MCP 配置文件 / MCP config files\n  * `~/.kimi/mcp.json`\n  * `--mcp-config-file`\n  * `--mcp-config`\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/share.py`, `src/kimi_cli/cli.py`\n* 安全性 / Security\n  * 审批请求\n  * 工具提示词注入风险\n  * 参考代码: `src/kimi_cli/soul/approval.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/tools/utils.py`, `src/kimi_cli/tools/file/`, `src/kimi_cli/ui/shell/visualize.py`\n\n### Agent Skills\n\n* Agent Skills 是什么 / What are Agent Skills\n  * 参考代码: `src/kimi_cli/skill.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/utils/frontmatter.py`\n* Skill 发现 / Skill discovery\n  * `~/.kimi/skills`\n  * 回退 `~/.claude/skills`\n  * `--skills-dir`\n  * 参考代码: `src/kimi_cli/skill.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/share.py`, `src/kimi_cli/cli.py`\n\n### Agent 与子 Agent / Agents and subagents\n\n* 内置 Agent / Built-in agents\n  * `default`\n  * `okabe`\n  * 参考代码: `src/kimi_cli/agents/`, `src/kimi_cli/agentspec.py`, `src/kimi_cli/agents/default/agent.yaml`, `src/kimi_cli/agents/okabe/agent.yaml`\n* 自定义 Agent 文件 / Custom agent file\n  * YAML 格式\n  * `extend` 与 `exclude_tools`\n  * 参考代码: `src/kimi_cli/agentspec.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/agents/`, `src/kimi_cli/soul/toolset.py`\n* 系统提示词内置参数 / System prompt built-in parameters\n  * `KIMI_NOW`\n  * `KIMI_WORK_DIR`\n  * `KIMI_WORK_DIR_LS`\n  * `KIMI_AGENTS_MD`\n  * `KIMI_SKILLS`\n  * 参考代码: `src/kimi_cli/soul/agent.py`, `src/kimi_cli/tools/file/read.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/skill.py`, `src/kimi_cli/utils/datetime.py`, `src/kimi_cli/utils/path.py`\n* 在 Agent 文件中定义子 Agent / Define subagents in agent file\n  * 参考代码: `src/kimi_cli/agents/default/sub.yaml`, `src/kimi_cli/agentspec.py`\n* 动态子 Agent 与任务调度 / Dynamic subagents and task scheduling\n  * `CreateSubagent` 工具\n  * 参考代码: `src/kimi_cli/tools/multiagent/task.py`, `src/kimi_cli/tools/multiagent/create.py`, `src/kimi_cli/soul/agent.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/agents/default/sub.yaml`\n\n### Print 模式 / Print Mode\n\n* 无交互运行 / Non-interactive run\n  * `--print` + `--command` 或 stdin\n  * 隐式开启 `--yolo`\n  * 参考代码: `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/ui/print/visualize.py`, `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `src/kimi_cli/soul/approval.py`\n* Stream JSON 格式 / Stream JSON format\n  * `--input-format=stream-json`\n  * `--output-format=stream-json`\n  * JSONL Message\n  * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/ui/print/visualize.py`, `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py`, `src/kimi_cli/ui/print/__init__.py`\n\n### Wire 模式 / Wire Mode\n\n* Wire 是什么 / What is Wire\n  * 参考代码: `src/kimi_cli/wire/`, `src/kimi_cli/ui/wire/__init__.py`\n* Wire 协议 / Wire protocol\n  * JSON-RPC\n  * Method 等\n  * 参考代码: `src/kimi_cli/ui/wire/jsonrpc.py`, `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py`\n* Wire 消息 / Wire messages\n  * 完整类型与 schema\n  * 参考代码: `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py`\n\n## 配置 / Configuration\n\n### 配置文件 / Config files\n\n* 配置文件位置 / Config file location\n  * `~/.kimi/config.toml`\n  * 参考代码: `src/kimi_cli/config.py`, `src/kimi_cli/share.py`, `README.md`\n* 配置项 / Config items\n  * providers\n  * models\n  * loop control\n  * services\n  * MCP client\n  * 参考代码: `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/tools/web/`\n* JSON 支持与迁移 / JSON support and migration\n  * `config.json` 迁移\n  * `--config`/`--config-file` 仍可以用 JSON\n  * 参考代码: `src/kimi_cli/config.py`, `src/kimi_cli/cli.py`\n\n### 平台与模型 / Providers and models\n\n* 平台选择 / Platform selection\n  * `/setup`\n  * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/slash.py`\n* Provider 类型 / Provider types\n  * `kimi`\n  * `openai_legacy`\n  * `openai_responses`\n  * `anthropic`\n  * `gemini/google_genai`\n  * `vertexai`\n  * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/setup.py`\n* 模型能力与限制 / Model capabilities and limits\n  * thinking\n  * image\\_in\n  * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/soul/kimisoul.py`, `src/kimi_cli/soul/message.py`, `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/config.py`\n* 搜索/抓取服务 / Search and fetch services\n  * 启用条件\n  * 参考代码: `src/kimi_cli/tools/web/search.py`, `src/kimi_cli/tools/web/fetch.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/setup.py`\n\n### 配置覆盖 / Config overrides\n\n* CLI 参数与配置文件 / CLI flags vs config\n  * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/config.py`\n* 环境变量覆盖 / Environment overrides\n  * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/llm.py`\n\n### 环境变量 / Environment variables\n\n* Kimi 环境变量 / Kimi environment variables\n  * `KIMI_BASE_URL`\n  * `KIMI_API_KEY`\n  * `KIMI_MODEL_NAME`\n  * `KIMI_MODEL_MAX_CONTEXT_SIZE`\n  * `KIMI_MODEL_CAPABILITIES`\n  * `KIMI_MODEL_TEMPERATURE`\n  * `KIMI_MODEL_TOP_P`\n  * `KIMI_MODEL_MAX_TOKENS`\n  * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`\n* OpenAI 兼容环境变量 / OpenAI-compatible environment variables\n  * `OPENAI_BASE_URL`\n  * `OPENAI_API_KEY`\n  * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/config.py`\n* 其他环境变量 / Other environment variables\n  * `KIMI_CLI_NO_AUTO_UPDATE`\n  * 参考代码: `src/kimi_cli/utils/envvar.py`, `src/kimi_cli/ui/shell/update.py`\n\n### 数据路径 / Data locations\n\n* 配置与元数据 / Config and metadata\n  * `~/.kimi/config.toml`\n  * `~/.kimi/kimi.json`\n  * `~/.kimi/mcp.json`\n  * 参考代码: `src/kimi_cli/share.py`, `src/kimi_cli/metadata.py`, `src/kimi_cli/config.py`, `src/kimi_cli/mcp.py`\n* 会话数据 / Session data\n  * `~/.kimi/sessions/.../context.jsonl`\n  * `~/.kimi/sessions/.../wire.jsonl`\n  * 参考代码: `src/kimi_cli/session.py`, `src/kimi_cli/wire/serde.py`, `src/kimi_cli/soul/context.py`, `src/kimi_cli/wire/message.py`\n* 输入历史 / Input history\n  * `~/.kimi/user-history/...`\n  * 参考代码: `src/kimi_cli/ui/shell/prompt.py`, `src/kimi_cli/share.py`\n* 日志 / Logs\n  * `~/.kimi/logs/kimi.log`\n  * 参考代码: `src/kimi_cli/utils/logging.py`, `src/kimi_cli/app.py`, `src/kimi_cli/share.py`\n\n## 参考手册 / Reference\n\n### `kimi` 命令 / `kimi` command\n\n* 全局参数 / Global flags\n  * `--version`、`--help`、`--verbose`、`--debug`\n  * `--agent`、`--agent-file`\n  * `--config`、`--config-file`\n  * `--model`\n  * `--work-dir`\n  * `--continue`、`--session`\n  * `--command` / `--query`\n  * `--print`、`--input-format`、`--output-format`\n  * `--acp`、`--wire`\n  * `--mcp-config-file`、`--mcp-config`\n  * `--yolo` / `--auto-approve` / `--yes`\n  * `--thinking` / `--no-thinking`\n  * `--skills-dir`\n  * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `src/kimi_cli/constant.py`, `src/kimi_cli/agentspec.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`, `src/kimi_cli/session.py`, `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/ui/acp/__init__.py`, `src/kimi_cli/ui/wire/__init__.py`, `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/approval.py`, `src/kimi_cli/skill.py`\n\n### `kimi acp` 命令 / `kimi acp` command\n\n* 启动 ACP multi-session 服务器，现在还没有被 ACP 客户端广泛支持 / Start an ACP multi-session server, which is not widely supported by ACP clients yet.\n  * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/acp/__init__.py`, `src/kimi_cli/ui/acp/__init__.py`\n\n### `kimi mcp` 子命令 / `kimi mcp` subcommands\n\n* 服务器管理 / Server management\n  * `add`、`list`、`remove`\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/cli.py`\n* 认证与测试 / Auth and test\n  * `auth`、`reset-auth`、`test`\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/acp/mcp.py`\n\n### 斜杠命令 / Slash commands\n\n* 帮助与信息 / Help and info\n  * `/help`、`/version`、`/release-notes`、`/feedback`\n  * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/utils/changelog.py`\n* 配置与调试 / Config and debug\n  * `/setup`、`/reload`、`/debug`、`/usage`\n  * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/ui/shell/debug.py`, `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/ui/shell/usage.py`\n* 会话管理 / Session management\n  * `/clear`（别名 `/reset`）\n  * `/sessions`（别名 `/resume`）\n  * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/session.py`, `src/kimi_cli/soul/context.py`\n* 其他 / Others\n  * `/mcp`、`/init`、`/compact`、`/yolo`\n  * 参考代码: `src/kimi_cli/ui/shell/slash.py`, `src/kimi_cli/soul/slash.py`, `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/compaction.py`, `src/kimi_cli/soul/approval.py`\n\n### 内置工具 / Built-in tools\n\n* 默认启用工具 / Default tools\n  * `Task`、`SetTodoList`、`Shell`、`ReadFile`、`Glob`、`Grep`、`WriteFile`、`StrReplaceFile`、`SearchWeb`、`FetchURL`\n  * 参考代码: `src/kimi_cli/agents/default/agent.yaml`, `src/kimi_cli/tools/`, `src/kimi_cli/tools/utils.py`\n* 可选工具 / Optional tools\n  * `Think`、`SendDMail`、`CreateSubagent`\n  * 需在 Agent 文件中启用\n  * 参考代码: `src/kimi_cli/agents/default/sub.yaml`, `src/kimi_cli/tools/`, `src/kimi_cli/tools/think/`, `src/kimi_cli/tools/dmail/`, `src/kimi_cli/tools/multiagent/create.py`, `src/kimi_cli/agents/default/agent.yaml`, `src/kimi_cli/agentspec.py`\n* 工具安全边界与审批 / Tool security and approvals\n  * 工作目录限制\n  * diff 预览\n  * 参考代码: `src/kimi_cli/soul/approval.py`, `src/kimi_cli/soul/toolset.py`, `src/kimi_cli/tools/file/`, `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/utils/path.py`, `src/kimi_cli/tools/file/write.py`, `src/kimi_cli/tools/file/diff_utils.py`, `src/kimi_cli/ui/shell/visualize.py`\n\n### 退出码与失败模式 / Exit codes and failure modes\n\n* 退出码语义与触发条件（正常结束、配置错误、运行中断等）\n  * 与 UI/模式相关的失败场景说明（Shell/Print/Wire/ACP）\n  * 参考代码: `src/kimi_cli/cli.py`, `src/kimi_cli/app.py`, `src/kimi_cli/exception.py`, `src/kimi_cli/soul/__init__.py`\n\n### 键盘快捷键 / Keyboard shortcuts\n\n* Ctrl-X：切换模式\n  * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/ui/shell/__init__.py`\n* Tab：切换 thinking\n  * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/llm.py`\n* Ctrl-J / Alt-Enter：换行\n  * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/ui/shell/prompt.py`\n* Ctrl-V：粘贴\n  * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/utils/clipboard.py`\n* Ctrl-D：退出\n  * 参考代码: `src/kimi_cli/ui/shell/keyboard.py`, `src/kimi_cli/ui/shell/__init__.py`\n\n## 常见问题 / FAQ\n\n### 安装与鉴权 / Setup and auth\n\n* 模型列表为空\n  * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/llm.py`\n* API key 无效\n  * 参考代码: `src/kimi_cli/ui/shell/setup.py`, `src/kimi_cli/config.py`, `src/kimi_cli/utils/envvar.py`\n* 会员过期\n  * 参考代码: `src/kimi_cli/ui/shell/usage.py`, `src/kimi_cli/ui/shell/setup.py`\n\n### 交互问题 / Interaction issues\n\n* Shell 模式 `cd` 无效\n  * 参考代码: `src/kimi_cli/tools/shell/__init__.py`, `src/kimi_cli/utils/environment.py`, `src/kimi_cli/tools/shell/bash.md`\n* Thinking 模式不可用\n  * 参考代码: `src/kimi_cli/llm.py`, `src/kimi_cli/config.py`, `src/kimi_cli/ui/shell/prompt.py`\n\n### ACP 问题 / ACP issues\n\n* 连接失败\n  * 参考代码: `src/kimi_cli/acp/server.py`, `src/kimi_cli/acp/session.py`, `src/kimi_cli/ui/acp/__init__.py`\n* 工作目录不一致\n  * 参考代码: `src/kimi_cli/acp/session.py`, `src/kimi_cli/session.py`, `src/kimi_cli/share.py`\n\n### MCP 问题 / MCP issues\n\n* 服务启动失败\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py`\n* OAuth 授权失败\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/acp/mcp.py`\n* Header 格式错误\n  * 参考代码: `src/kimi_cli/mcp.py`, `src/kimi_cli/soul/toolset.py`\n\n### Print/Wire 模式问题 / Print/Wire mode issues\n\n* JSONL 输入无效\n  * 参考代码: `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/wire/serde.py`\n* 无输出\n  * 参考代码: `src/kimi_cli/ui/print/__init__.py`, `src/kimi_cli/ui/wire/__init__.py`\n* 格式不匹配\n  * 参考代码: `src/kimi_cli/wire/message.py`, `src/kimi_cli/wire/serde.py`\n\n### 更新与升级 / Updates\n\n* macOS 首次运行变慢\n  * 参考代码: `src/kimi_cli/ui/shell/update.py`, `src/kimi_cli/tools/file/grep_local.py`\n* uv 升级步骤\n  * 参考代码: `README.md`, `src/kimi_cli/ui/shell/update.py`\n\n## 发布说明 / Release Notes\n\n### 变更记录 / Changelog\n\n* 版本号、发布日期、变更内容 / Version number, release date, changes\n  * 参考代码: `CHANGELOG.md`, `src/kimi_cli/utils/changelog.py`, `src/kimi_cli/constant.py`, `README.md`\n\n### 破坏性变更与迁移说明 / Breaking changes and migration\n\n* 破坏性变更清单与迁移指引\n  * 受影响范围、替代方案、回滚提示\n  * 参考代码: `CHANGELOG.md`, `src/kimi_cli/utils/changelog.py`\n"
  },
  {
    "path": "klips/klip-6-setup-auto-refresh-models.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-07\nStatus: Implemented\n---\n\n# KLIP-6: /setup 平台模型自动刷新与托管命名空间\n\n## 背景与现状（最终实现）\n\n* `/setup` 位于 `src/kimi_cli/ui/shell/setup.py`：选择平台、输入 API key、调用 `list_models(platform, api_key)` 获取并过滤模型，然后写入 `config.providers` 与 `config.models`，并设置 `default_model` 为用户选中的模型。\n* `Config` 定义在 `src/kimi_cli/config.py`：`providers` 与 `models` 平级，`default_model` 必须指向 `models` 中的键，且每个 `LLMModel.provider` 必须存在于 `providers`。\n* `/setup` 使用托管命名空间（`managed:`）写入 provider/model，避免覆盖用户自定义配置。\n\n## 目标\n\n* 在 `/model` 斜杠命令触发时自动刷新 `/setup` 所配置平台的模型列表，并写回配置文件。\n* 自动刷新只覆盖“/setup 管理的模型”，不影响用户自行配置的 provider/model。\n* 保持 CLI 可用性：默认模型仍可正常加载，`/model` 列表可用。\n* 适用于所有启动方式：只要使用 `/model` 命令且配置中存在 `/setup` 托管 provider，并且使用默认配置文件位置，才会自动刷新。\n\n## 设计概览\n\n### 1) 托管命名空间（区分自动管理与用户自定义）\n\n为 `/setup` 管理的 provider/model 引入保留命名空间，避免和用户配置冲突：\n\n* provider key：`managed:<platform-id>`\n* model key：`<platform-id>/<model-id>`\n\n模型条目仍保留真实 `model` 字段（API 端模型名），`provider` 字段指向上述 provider key。\n\n示例：\n\n```toml\n[providers.\"managed:moonshot-cn\"]\ntype = \"kimi\"\nbase_url = \"https://api.moonshot.cn/v1\"\napi_key = \"sk-xxx\"\n\n[models.\"moonshot-cn/kimi-k2-thinking-turbo\"]\nprovider = \"managed:moonshot-cn\"\nmodel = \"kimi-k2-thinking-turbo\"\nmax_context_size = 262144\n```\n\n这样可以做到：\n\n* `/setup` 管理的模型可以被“强制覆盖”。\n* 用户仍可自由定义 `providers.moonshot-cn`、`models.kimi-k2-thinking-turbo` 等同名项，不会被覆盖。\n\n### 2) 识别“/setup 平台”的最小信息源\n\n将 `/setup` 平台清单抽到公共模块（例如 `src/kimi_cli/auth/platforms.py`），提供：\n\n* `id`、`name`、`base_url`\n* `search_url`、`fetch_url`（可选）\n* `allowed_prefixes`（过滤模型前缀）\n\n`/setup` 与自动刷新都基于同一份平台定义。\n\n### 3) 自动刷新机制（/model 触发）\n\n在 `/model` 命令（`src/kimi_cli/ui/shell/slash.py`）触发刷新逻辑，仅在默认配置文件位置时启用：\n\n1. 仅当 `config.is_from_default_location` 为真时继续，否则直接跳过刷新。\n2. 扫描 `providers` 中以 `managed:` 开头的条目，视为托管平台；若没有托管 provider，则不刷新。\n3. 对每个平台调用 `{base_url}/models`，并在 `list_models` 内按 `allowed_prefixes` 过滤。\n4. 生成/更新 `models` 中对应的 `<platform-id>/...` 条目：\n   * 更新 `max_context_size`\n   * 移除已经下线的模型条目\n5. 若发生变化：写回 config 文件，并同步更新内存中的 `runtime.config`，使 `/model` 立即可见。\n\n写回策略：\n\n* 自动刷新仅在默认配置路径启用，因此写回总是落到默认 config 文件。\n* 非默认配置（`--config` / `--config-file`）不会触发自动刷新。\n\n错误处理：网络/鉴权失败时记录日志并跳过该平台，`/model` 继续展示已有配置。\n\n### 4) `/setup` 行为调整\n\n`/setup` 写入配置时使用托管命名空间，并写入全部过滤后的模型：\n\n* provider：`managed:<platform-id>`\n* model：`<platform-id>/<model-id>`\n* 将过滤后的模型全量写入 `models`（同一 provider 下旧模型先清理）\n* `default_model` 指向托管 model key（用户选择的模型 `selected_model_id`）\n* `services.moonshot_search` / `services.moonshot_fetch` 保持现有行为\n\n### 5) UI 展示优化（可选）\n\n`/model` 列表可以显示更友好的 label：\n\n* 显示 `model.model` 作为主名字\n* 将 `managed:` 的 provider 显示为平台名（直接使用 `Platform.name`）\n* 选择时仍用真实 key，避免破坏现有逻辑\n* 说明：`/model` 的持久切换只在默认配置文件可写时生效（现有约束），与“仅默认位置自动刷新”的策略一致\n\n## 迁移策略（不做）\n\n为了保持简单与低风险，不做任何自动迁移。仅对通过新版 `/setup` 写入的托管 provider/model 生效。\n\n## 兼容性与边界\n\n* 如果用户显式使用 `--config`（字符串）或 `--config-file` 指定文件，自动刷新不会触发。\n* 若 `default_model` 指向的托管模型被 API 下线，自动回退到该平台列表中的第一个模型。\n* 仅影响 `/setup` 平台；自定义 provider/model 不受影响。\n\n## 实施步骤（建议）\n\n1. 抽出平台定义模块，供 `/setup` 与自动刷新共享。\n2. 调整 `/setup` 写入逻辑（命名空间 + default_model）。\n3. 在 `/model` 触发自动刷新逻辑。\n4. `/model` 展示逻辑优化（仅 UI 层）。\n5. （可选）测试覆盖刷新与写入逻辑。\n\n## 关键参考位置\n\n* `/setup`：`src/kimi_cli/ui/shell/setup.py`\n* 配置结构：`src/kimi_cli/config.py`\n* 平台与模型刷新：`src/kimi_cli/auth/platforms.py`\n* `/model`：`src/kimi_cli/ui/shell/slash.py`\n"
  },
  {
    "path": "klips/klip-7-kimi-sdk.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-08\nStatus: Implemented\n---\n\n# KLIP-7: Kimi SDK (thin wrapper around Kosong)\n\n## Summary\n\nAdd `sdks/kimi-sdk` as a lightweight Python SDK for Kimi. It provides the Kimi provider and\nagent building blocks (`generate/step`, message, tooling) in a flat module. The first version is\na thin re-export to keep risk low and ship fast. Docs publishing is deferred for v1.\n\n## Goals\n\n- Provide an OpenAI-SDK-like entry point: `from kimi_sdk import Kimi, generate, step, Message`.\n- Keep only Kosong's Kimi provider and agent primitives; no other providers.\n- Minimal implementation and maintenance: re-export, no behavior changes.\n- Export all content parts supported by the Kimi chat provider, plus display blocks.\n\n## Non-goals\n\n- No new HTTP client layer; reuse Kosong's Kimi provider as-is.\n- No changes to Kimi request/response semantics.\n- No Kosong split or refactor in the first version.\n\n## Package layout (flat module)\n\n```\nsdks/kimi-sdk/\n  pyproject.toml\n  README.md\n  CHANGELOG.md\n  LICENSE / NOTICE\n  src/kimi_sdk/\n    __init__.py\n    py.typed\n```\n\n### Module responsibilities\n\n- `kimi_sdk.__init__`\n  - Re-export the full public surface (`Kimi`, `KimiStreamedMessage`, `generate`, `step`,\n    `GenerateResult`, `Message`, `SimpleToolset`, tooling types, provider errors, content parts,\n    display blocks).\n  - Provide an explicit `__all__` grouped by category to keep the surface Kimi-focused.\n  - Include a minimal agent loop example in the module docstring.\n  - No `kimi_sdk.*` submodules; all public API lives at the top level.\n\nNote: `kimi_sdk` does not expose `kosong.contrib` or other providers, even via re-export.\n\n## Public API (top-level)\n\nExports (grouped in `__all__`):\n\n```python\nfrom kimi_sdk import (\n    # providers\n    Kimi,\n    KimiStreamedMessage,\n    StreamedMessagePart,\n    ThinkingEffort,\n    # provider errors\n    APIConnectionError,\n    APIEmptyResponseError,\n    APIStatusError,\n    APITimeoutError,\n    ChatProviderError,\n    # messages and content parts\n    Message,\n    Role,\n    ContentPart,\n    TextPart,\n    ThinkPart,\n    ImageURLPart,\n    AudioURLPart,\n    VideoURLPart,\n    ToolCall,\n    ToolCallPart,\n    # tooling\n    Tool,\n    CallableTool,\n    CallableTool2,\n    Toolset,\n    SimpleToolset,\n    ToolReturnValue,\n    ToolOk,\n    ToolError,\n    ToolResult,\n    ToolResultFuture,\n    # display blocks\n    DisplayBlock,\n    BriefDisplayBlock,\n    UnknownDisplayBlock,\n    # generation\n    generate,\n    step,\n    GenerateResult,\n    StepResult,\n    TokenUsage,\n)\n```\n\nExample usage:\n\n```python\nfrom kimi_sdk import Kimi, Message, generate\n\nkimi = Kimi(\n    base_url=\"https://api.moonshot.ai/v1\",\n    api_key=\"sk-xxx\",\n    model=\"kimi-k2-turbo-preview\",\n)\n\nhistory = [Message(role=\"user\", content=\"Who are you?\")]\nresult = await generate(chat_provider=kimi, system_prompt=\"You are a helper.\", tools=[], history=history)\n```\n\n## Dependency strategy\n\n### Phase 1 (MVP: direct dependency on Kosong)\n\n- `kimi-sdk` is a thin wrapper that depends on `kosong` with a strict upper bound.\n- Pros: minimal code, consistent behavior.\n- Cons: it pulls Kosong's provider dependencies too (acceptable for v1).\n\nSuggested dependency range:\n\n```\ndependencies = [\n  \"kosong>=0.37.0,<0.38.0\"\n]\n```\n\nNo lockstep requirement. `kimi-sdk` releases independently; the dependency upper bound ensures\ncompatibility while allowing Kosong updates that are unrelated to Kimi (e.g. contrib providers).\n\n## Versioning & Release\n\n### Version strategy\n\n- Independent semver for `kimi-sdk`.\n- Compatibility is enforced by the `kosong` dependency range rather than lockstep versioning.\n\n### Tag naming\n\nAdd a new tag prefix:\n\n- `kimi-sdk-0.1.0`\n\n### Release workflow\n\nAdd `.github/workflows/release-kimi-sdk.yml`:\n\n- Trigger: tags `kimi-sdk-*`\n- Version validation: `scripts/check_version_tag.py`\n- Build: `make build-kimi-sdk`\n- Publish: `pypa/gh-action-pypi-publish`\n- No docs publish in v1.\n\nUpdate Makefile with:\n\n- `build-kimi-sdk`\n- `check-kimi-sdk`\n- `format-kimi-sdk`\n- `test-kimi-sdk`\n\n## Testing\n\n### Unit tests (sdks/kimi-sdk)\n\nBasic behavior smoke test:\n\n- `tests/test_smoke.py`\n  - Use `respx` or `httpx.MockTransport` to stub Kimi responses\n  - Ensure `generate/step` returns `Message` and `TokenUsage`\n\n### CI\n\nAdd `ci-kimi-sdk.yml`:\n\n- Reuse Makefile targets:\n  - `make check-kimi-sdk`\n  - `make test-kimi-sdk`\n- Structure should mirror `ci-kosong.yml`.\n\n## Documentation\n\n- `sdks/kimi-sdk/README.md` with usage examples using `kimi_sdk` imports.\n- `kimi_sdk/__init__.py` docstring includes a minimal agent loop example; rely on underlying\n  Kosong docstrings for detailed API descriptions.\n- Docs publishing is deferred for v1.\n\n## Migration & Compatibility\n\n- Migration from `kosong` is only import path changes.\n- Environment variables keep the same semantics (`KIMI_API_KEY`, `KIMI_BASE_URL`).\n\n## Decisions\n\n- Keep `kimi-sdk` thin (no Kosong split).\n- No `python -m kimi_sdk` demo entry for v1.\n- Docs repo name: `MoonshotAI/kimi-sdk`.\n- Skip docs publishing for v1.\n"
  },
  {
    "path": "klips/klip-8-config-and-skills-layout.md",
    "content": "---\nAuthor: \"@xxchan\"\nUpdated: 2026-01-14\nStatus: Implemented\n---\n\n# KLIP-8: Unified Skills Discovery\n\n## Motivation\n\n> \"Skills should not need vendor-specific directory layouts, duplicate copies, or symlink hacks to be usable across clients.\"\n\nCoding agent ecosystems are fragmented with vendor-specific layouts. Users must duplicate skills or maintain symlinks.\n\nThis proposal unifies skill discovery to be compatible with existing tools.\n\n## Scope\n\n- Skills discovery\n- Future: `mcp.json` (not this KLIP)\n\n## Non-goals\n\n- `~/.kimi/config.toml` and other Kimi-specific config\n- `~/.local/share/kimi/` data directories\n\n## Skills Discovery\n\nTwo-level logic:\n\n1. **Layered merge**: builtin → user → project all loaded; same-name skills overridden by later layers\n2. **Directory lookup**: within each layer, check candidates by priority; stop at first existing directory\n\n**User level** (by priority):\n- `~/.config/agents/skills/` — canonical, recommended\n- `~/.kimi/skills/` — legacy fallback\n- `~/.claude/skills/` — legacy fallback\n\n**Project level**:\n- `.agents/skills/`\n\nBuilt-in skills load only when the KAOS backend is `LocalKaos` or `ACPKaos`.\n\n`--skills-dir` overrides user/project discovery; only specified directory is used (built-ins still load when supported).\n\n## References\n\n- [agentskills#15](https://github.com/agentskills/agentskills/issues/15): proposal to standardize `.agents/skills/`\n- [Amp](https://ampcode.com/manual#agent-skills): `~/.config/agents/`, `.agents/skills/`\n"
  },
  {
    "path": "klips/klip-9-shell-ui-flicker-mitigation.md",
    "content": "---\nAuthor: \"@stdrc\"\nUpdated: 2026-01-19\nStatus: Implemented\n---\n\n# KLIP-9: Shell UI 闪烁缓解 — Pager 展开方案\n\n## 问题背景\n\n### 终端渲染的根本限制\n\n终端有两个区域：**viewport**（可见区域，可原地更新）和 **scrollback**（历史区域，不可变）。\n\n当 Live display 内容高度超过 viewport：\n1. 顶部内容被推入 scrollback\n2. Scrollback 不可变，光标无法定位\n3. 任何更新都需要清除整个 scrollback 并重绘 → **闪烁**\n\n### 当前问题\n\n1. **Approval Request 过高**：Shell tool 的命令直接放在 `description` 中，长命令导致 panel 过高\n2. **Display 字段未渲染**：`ApprovalRequest.display` 字段（包含 DiffDisplayBlock）在 UI 中**完全没有渲染**\n3. **无法查看完整内容**：用户无法看到被截断的完整信息\n\n## 方案设计\n\n### 核心思路\n\n1. **统一行预算**：所有内容共享固定行数预算（4 行），按顺序渲染直到预算用完\n2. **Ctrl+E 展开到 Pager**：使用 Rich 的 `console.pager(styles=True)` 显示完整内容\n3. **修复 display 字段渲染**：正确显示 DiffDisplayBlock 和 ShellDisplayBlock\n\n### 为什么用 Pager\n\n1. **已有实践**：项目在 `/help`、`/context`、`/debug history` 中已使用 `console.pager()`\n2. **Alternate Screen**：Pager（less）使用 alternate screen，与 Live display 完全隔离\n3. **零闪烁**：退出 pager 后，终端恢复到之前状态，Live display 继续工作\n4. **功能丰富**：支持搜索（/）、滚动（j/k）、翻页（Space）等\n\n### UI 设计\n\n#### 截断显示（默认）\n\n无边框设计，内容区最多显示 4 行：\n\n```\n  ⚠ shell is requesting approval to Run command:\n\n    pip install requests pandas numpy matplotlib \\\n        scikit-learn tensorflow torch transformers \\\n        fastapi uvicorn sqlalchemy alembic pytest\n    ... (truncated, ctrl-e to expand)\n\n  → Approve once\n    Approve for this session\n    Reject, tell Kimi CLI what to do instead\n```\n\n#### 文件编辑的 Diff 显示\n\n同一文件多个 hunk 时，后续 hunk 使用 `⋮` 表示省略的中间行：\n\n```\n  ⚠ str_replace is requesting approval to Edit file:\n\n    src/main.ts\n    @@ -10,3 +10,5 @@\n     import { foo } from './foo';\n    -import { bar } from './bar';\n    ... (truncated, ctrl-e to expand)\n\n  → Approve once\n    ...\n```\n\n多个 hunk 完整显示时（pager 内）：\n\n```\n  src/main.ts\n  @@ -10,3 +10,5 @@\n   import { foo } from './foo';\n  -import { bar } from './bar';\n  +import { bar, baz } from './bar';\n  +import { qux } from './qux';\n\n  ⋮\n  @@ -50,3 +52,4 @@\n   export function main() {\n  -    const result = foo() + bar();\n  +    const result = foo() + bar() + baz() + qux();\n```\n\n#### Pager 全屏视图（Ctrl+E）\n\n按 Ctrl+E 后进入系统 pager（通常是 less），复用预渲染的内容，显示完整信息。\n\n## 实现细节\n\n### 1. 新增 ShellDisplayBlock\n\n```python\n# tools/display.py\n\nclass ShellDisplayBlock(DisplayBlock):\n    \"\"\"Display block describing a shell command.\"\"\"\n\n    type: str = \"shell\"\n    language: str\n    command: str\n```\n\n### 2. 预渲染内容块\n\n使用 NamedTuple 存储预渲染的内容块及其行数：\n\n```python\nclass _ApprovalContentBlock(NamedTuple):\n    \"\"\"A pre-rendered content block for approval request with line count.\"\"\"\n\n    text: str\n    lines: int\n    style: str = \"\"\n    lexer: str = \"\"\n```\n\n在 `_ApprovalRequestPanel.__init__` 中预渲染所有内容：\n\n```python\nclass _ApprovalRequestPanel:\n    def __init__(self, request: ApprovalRequest):\n        # Pre-render all content blocks with line counts\n        self._content_blocks: list[_ApprovalContentBlock] = []\n        last_diff_path: str | None = None\n\n        # Handle display blocks\n        for block in request.display:\n            if isinstance(block, DiffDisplayBlock):\n                # File path or ellipsis for same-file hunks\n                if block.path != last_diff_path:\n                    self._content_blocks.append(\n                        _ApprovalContentBlock(text=block.path, lines=1, style=\"bold\")\n                    )\n                    last_diff_path = block.path\n                else:\n                    self._content_blocks.append(\n                        _ApprovalContentBlock(text=\"⋮\", lines=1, style=\"dim\")\n                    )\n                # Diff content\n                diff_text = format_unified_diff(...).rstrip(\"\\n\")\n                self._content_blocks.append(\n                    _ApprovalContentBlock(\n                        text=diff_text, lines=diff_text.count(\"\\n\") + 1, lexer=\"diff\"\n                    )\n                )\n            elif isinstance(block, ShellDisplayBlock):\n                text = block.command.rstrip(\"\\n\")\n                self._content_blocks.append(\n                    _ApprovalContentBlock(\n                        text=text, lines=text.count(\"\\n\") + 1, lexer=block.language\n                    )\n                )\n            # ...\n\n        self._total_lines = sum(b.lines for b in self._content_blocks)\n        self.has_expandable_content = self._total_lines > MAX_PREVIEW_LINES\n```\n\n### 3. 统一行预算渲染\n\n```python\ndef render(self) -> RenderableType:\n    content_lines: list[RenderableType] = [\n        Text.from_markup(\n            \"[yellow]⚠ \"\n            f\"{escape(self.request.sender)} is requesting approval to \"\n            f\"{escape(self.request.action)}:[/yellow]\"\n        )\n    ]\n    content_lines.append(Text(\"\"))\n\n    # Render content with line budget\n    remaining = MAX_PREVIEW_LINES\n    for block in self._content_blocks:\n        if remaining <= 0:\n            break\n        content_lines.append(self._render_block(block, remaining))\n        remaining -= min(block.lines, remaining)\n\n    if self.has_expandable_content:\n        content_lines.append(\n            Text(\"... (truncated, ctrl-e to expand)\", style=\"dim italic\")\n        )\n\n    # ... menu options ...\n    return Padding(Group(*lines), 1)\n```\n\n### 4. Pager 复用预渲染内容\n\n```python\ndef render_full(self) -> list[RenderableType]:\n    \"\"\"Render full content for pager (no truncation).\"\"\"\n    return [self._render_block(block) for block in self._content_blocks]\n\n\ndef _show_approval_in_pager(panel: _ApprovalRequestPanel) -> None:\n    \"\"\"Show the full approval request content in a pager.\"\"\"\n    with console.screen(), console.pager(styles=True):\n        # Header\n        console.print(\n            Text.from_markup(\n                \"[yellow]⚠ \"\n                f\"{escape(panel.request.sender)} is requesting approval to \"\n                f\"{escape(panel.request.action)}:[/yellow]\"\n            )\n        )\n        console.print()\n\n        # Render full content (no truncation)\n        for renderable in panel.render_full():\n            console.print(renderable)\n```\n\n### 5. KeyboardListener 支持 Pause/Resume\n\n为了在 pager 活动时暂停键盘监听，新增 `KeyboardListener` 类：\n\n```python\nclass KeyboardListener:\n    async def start(self) -> None: ...\n    async def stop(self) -> None: ...\n    async def pause(self) -> None: ...\n    async def resume(self) -> None: ...\n    async def get(self) -> KeyEvent: ...\n```\n\n键盘处理中使用 pause/resume：\n\n```python\nasync def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None:\n    if event == KeyEvent.CTRL_E:\n        if (\n            self._current_approval_request_panel\n            and self._current_approval_request_panel.has_expandable_content\n        ):\n            await listener.pause()\n            live.stop()\n            try:\n                _show_approval_in_pager(self._current_approval_request_panel)\n            finally:\n                self._reset_live_shape(live)\n                live.start()\n                live.update(self.compose(), refresh=True)\n                await listener.resume()\n        return\n    # ... handle other events ...\n```\n\n## 变更范围\n\n| 文件 | 变更 |\n|------|------|\n| `tools/display.py` | 新增 `ShellDisplayBlock` |\n| `ui/shell/visualize.py` | 预渲染内容块、统一行预算、pager 展开、无边框设计 |\n| `ui/shell/keyboard.py` | 新增 `KeyboardListener` 类支持 pause/resume、添加 `CTRL_E` 事件 |\n| `tools/shell/__init__.py` | 使用 `ShellDisplayBlock` 传递命令 |\n| `utils/diff.py` | 新增 `format_unified_diff` 函数 |\n| `utils/rich/syntax.py` | 新增 `KimiSyntax` 支持自定义主题 |\n\n## 设计决策\n\n1. **Ctrl+E 而非 Ctrl+O**：E 代表 Expand，更直观\n2. **无边框设计**：移除 Panel 边框，使用 Padding，更简洁\n3. **统一行预算**：所有内容共享 4 行预算，避免多个 block 导致高度爆炸\n4. **简化截断提示**：只显示 `... (truncated, ctrl-e to expand)`，不显示具体行数\n5. **预渲染复用**：preview 和 pager 共享预渲染的内容块，避免重复计算\n6. **同文件多 hunk**：使用 `⋮` 表示省略的中间行，而非重复显示文件名\n\n## 边界情况\n\n1. **短内容**：如果内容不需要截断，不显示截断提示，`has_expandable_content` 为 False\n2. **无 display**：如果只有 description 没有 display blocks，也正确处理\n3. **多个 DiffDisplayBlock**：统一行预算，可能只显示第一个 block 的部分内容\n4. **Pager 不可用**：Rich 会 fallback 到直接输出\n\n## 测试计划\n\n1. 短命令的 approval request（不截断）\n2. 长命令的 approval request（截断 + Ctrl+E 展开）\n3. 文件编辑的 approval request（diff 显示 + Ctrl+E 展开）\n4. 同一文件多个 hunk（显示 `⋮`）\n5. 从 pager 返回后 Live display 正常工作\n6. 在 pager 中按 q 退出、按 / 搜索等\n"
  },
  {
    "path": "packages/kaos/.pre-commit-config.yaml",
    "content": "orphan: true\n\nrepos:\n  - repo: local\n    hooks:\n      - id: make-format-pykaos\n        name: make format-pykaos\n        entry: make -C ../.. format-pykaos\n        language: system\n        pass_filenames: false\n      - id: make-check-pykaos\n        name: make check-pykaos\n        entry: make -C ../.. check-pykaos\n        language: system\n        pass_filenames: false\n"
  },
  {
    "path": "packages/kaos/CHANGELOG.md",
    "content": "# Changelog\n\n## Unreleased\n\n## 0.7.0 (2026-02-06)\n\n- Add `env` parameter to `exec()` method for passing environment variables to subprocesses\n\n## 0.6.0 (2026-01-09)\n\n- Add optional `n` parameter to `readbytes` to read only the first n bytes\n\n## 0.5.4 (2026-01-06)\n\n- Relax `aiofiles` dependency version to `>=24.0,<26.0`\n\n## 0.5.3 (2025-12-29)\n\n- Add `host` property to `SSHKaos`\n\n## 0.5.2 (2025-12-17)\n\n- Fix `SSHKaos.Process.wait` to not drain stdout/stderr buffers\n- Return 1 as return code if `SSHKaos.Process.wait` does not get a return code\n\n## 0.5.1 (2025-12-15)\n\n- Fix unhandled exception thrown by `SSHKaos.stat` when the file does not exist\n- Fix `SSHKaos.exec` without CWD\n- Fix `SSHKaos.iterdir` to return `KaosPath`\n\n## 0.5.0 (2025-12-12)\n\n- Move `KaosProcess` to `Kaos.Process`\n- Add `AsyncReadable` and `AsyncWritable` protocols\n- Add `SSHKaos` implementation\n- Lower the required Python version to 3.12\n\n## 0.4.0 (2025-12-06)\n\n- Add `Kaos.exec` method for executing commands\n- Add `StepResult` as the return type for `Kaos.stat`\n\n## 0.3.0 (2025-12-03)\n\n- Change `iterdir`, `glob` and `read_lines` to sync function returning `AsyncIterator`\n\n## 0.2.0 (2025-12-01)\n\n- Initial release with `Kaos` protocol, `LocalKaos` implementation, and `KaosPath` for convenient file operations\n"
  },
  {
    "path": "packages/kaos/LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "packages/kaos/NOTICE",
    "content": "PyKAOS\nCopyright 2025 Moonshot AI\n\nThis product includes software developed at\nMoonshot AI (https://www.moonshot.ai/)."
  },
  {
    "path": "packages/kaos/README.md",
    "content": "# PyKAOS\n\nPyKAOS is a lightweight Python library providing an abstraction layer for agents to interact with operating systems. File operations and command executions via KAOS can be easily switched between local environment and remote systems over SSH.\n"
  },
  {
    "path": "packages/kaos/pyproject.toml",
    "content": "[project]\nname = \"pykaos\"\nversion = \"0.7.0\"\ndescription = \"\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\ndependencies = [\n    \"aiofiles>=24.0,<26.0\",\n    \"asyncssh==2.21.1\",\n]\n\n[dependency-groups]\ndev = [\n    \"inline-snapshot[black]>=0.31.1\",\n    \"pyright>=1.1.407\",\n    \"ty>=0.0.7\",\n    \"pytest>=9.0.2\",\n    \"pytest-asyncio>=1.3.0\",\n    \"ruff>=0.14.9\",\n]\n\n[build-system]\nrequires = [\"uv_build>=0.8.5,<0.9.0\"]\nbuild-backend = \"uv_build\"\n\n[tool.uv.build-backend]\nmodule-name = [\"kaos\"]\nsource-exclude = [\"tests/**/*\"]\n\n[tool.ruff]\nline-length = 100\n\n[tool.ruff.lint]\nselect = [\n    \"E\",   # pycodestyle\n    \"F\",   # Pyflakes\n    \"UP\",  # pyupgrade\n    \"B\",   # flake8-bugbear\n    \"SIM\", # flake8-simplify\n    \"I\",   # isort\n]\n\n[tool.pyright]\ntypeCheckingMode = \"strict\"\npythonVersion = \"3.14\"\ninclude = [\n    \"src/**/*.py\",\n    \"tests/**/*.py\",\n]\n\n[tool.ty.environment]\npython-version = \"3.14\"\n\n[tool.ty.src]\ninclude = [\n    \"src/**/*.py\",\n    \"tests/**/*.py\",\n]\n"
  },
  {
    "path": "packages/kaos/src/kaos/__init__.py",
    "content": "from __future__ import annotations\n\nimport contextvars\nfrom collections.abc import AsyncGenerator, AsyncIterator, Iterable, Mapping\nfrom dataclasses import dataclass\nfrom pathlib import PurePath\nfrom typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable\n\nif TYPE_CHECKING:\n    from asyncio import StreamReader, StreamWriter\n\n    from asyncssh.stream import SSHReader, SSHWriter\n\n    from kaos.path import KaosPath\n\n    def type_check(\n        stream_reader: StreamReader,\n        stream_writer: StreamWriter,\n        ssh_reader: SSHReader[bytes],\n        ssh_writer: SSHWriter[bytes],\n    ):\n        _reader: AsyncReadable = stream_reader\n        _reader = ssh_reader\n        _writer: AsyncWritable = stream_writer\n        _writer = ssh_writer\n\n\ntype StrOrKaosPath = str | KaosPath\n\n\n@runtime_checkable\nclass AsyncReadable(Protocol):\n    \"\"\"Protocol describing readable async byte streams.\"\"\"\n\n    def __aiter__(self) -> AsyncIterator[bytes]:\n        \"\"\"Yield chunks (typically lines) as they arrive.\"\"\"\n        ...\n\n    def at_eof(self) -> bool:\n        \"\"\"Return True when the stream has reached EOF and buffer is empty.\"\"\"\n        ...\n\n    def feed_data(self, data: bytes) -> None:\n        \"\"\"Inject data into the stream; mainly for testing or adapters.\"\"\"\n        ...\n\n    def feed_eof(self) -> None:\n        \"\"\"Signal end-of-file to the stream.\"\"\"\n        ...\n\n    async def read(self, n: int = -1) -> bytes:\n        \"\"\"Read up to n bytes; -1 reads until EOF.\"\"\"\n        ...\n\n    async def readline(self) -> bytes:\n        \"\"\"Read a single line ending with newline or EOF.\"\"\"\n        ...\n\n    async def readexactly(self, n: int) -> bytes:\n        \"\"\"Read exactly n bytes or raise IncompleteReadError.\"\"\"\n        ...\n\n    async def readuntil(self, separator: bytes) -> bytes:\n        \"\"\"Read until separator is encountered, including the separator.\"\"\"\n        ...\n\n\n@runtime_checkable\nclass AsyncWritable(Protocol):\n    \"\"\"Protocol describing writable async byte streams.\"\"\"\n\n    def can_write_eof(self) -> bool:\n        \"\"\"Return True if write_eof() is supported.\"\"\"\n        ...\n\n    def close(self) -> None:\n        \"\"\"Schedule closing of the underlying transport.\"\"\"\n        ...\n\n    async def drain(self) -> None:\n        \"\"\"Block until the internal write buffer is flushed.\"\"\"\n        ...\n\n    def is_closing(self) -> bool:\n        \"\"\"Return True once the stream has been closed or is closing.\"\"\"\n        ...\n\n    async def wait_closed(self) -> None:\n        \"\"\"Wait until the closing handshake completes.\"\"\"\n        ...\n\n    def write(self, data: bytes) -> None:\n        \"\"\"Write raw bytes to the stream.\"\"\"\n        ...\n\n    def writelines(self, data: Iterable[bytes], /) -> None:\n        \"\"\"Write an iterable of byte chunks to the stream.\"\"\"\n        ...\n\n    def write_eof(self) -> None:\n        \"\"\"Send EOF to the underlying transport if supported.\"\"\"\n        ...\n\n\n@runtime_checkable\nclass KaosProcess(Protocol):\n    \"\"\"Process interface exposed by KAOS `exec` implementations.\"\"\"\n\n    stdin: AsyncWritable\n    stdout: AsyncReadable\n    stderr: AsyncReadable\n\n    @property\n    def pid(self) -> int:\n        \"\"\"Get the process ID.\"\"\"\n        ...\n\n    @property\n    def returncode(self) -> int | None:\n        \"\"\"Get the process return code, or None if it is still running.\"\"\"\n        ...\n\n    async def wait(self) -> int:\n        \"\"\"Wait for the process to complete and return the exit code.\"\"\"\n        ...\n\n    async def kill(self) -> None:\n        \"\"\"Kill the process.\"\"\"\n        ...\n\n\n@runtime_checkable\nclass Kaos(Protocol):\n    \"\"\"Kimi Agent Operating System (KAOS) interface.\"\"\"\n\n    name: str\n    \"\"\"The name of the KAOS implementation.\"\"\"\n\n    def pathclass(self) -> type[PurePath]:\n        \"\"\"Get the path class used under `KaosPath`.\"\"\"\n        ...\n\n    def normpath(self, path: StrOrKaosPath) -> KaosPath:\n        \"\"\"Normalize path, eliminating double slashes, etc.\"\"\"\n        ...\n\n    def gethome(self) -> KaosPath:\n        \"\"\"Get the home directory path.\"\"\"\n        ...\n\n    def getcwd(self) -> KaosPath:\n        \"\"\"Get the current working directory path.\"\"\"\n        ...\n\n    async def chdir(self, path: StrOrKaosPath) -> None:\n        \"\"\"Change the current working directory.\"\"\"\n        ...\n\n    async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult:\n        \"\"\"Get the stat result for a path.\"\"\"\n        ...\n\n    def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]:\n        \"\"\"Iterate over the entries in a directory.\"\"\"\n        ...\n\n    def glob(\n        self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True\n    ) -> AsyncGenerator[KaosPath]:\n        \"\"\"Search for files/directories matching a pattern in the given path.\"\"\"\n        ...\n\n    async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes:\n        \"\"\"Read the entire file contents as bytes, or the first n bytes if provided.\"\"\"\n        ...\n\n    async def readtext(\n        self,\n        path: StrOrKaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> str:\n        \"\"\"Read the entire file contents as text.\"\"\"\n        ...\n\n    def readlines(\n        self,\n        path: StrOrKaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> AsyncGenerator[str]:\n        \"\"\"Iterate over the lines of the file.\"\"\"\n        ...\n\n    async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int:\n        \"\"\"Write bytes data to the file.\"\"\"\n        ...\n\n    async def writetext(\n        self,\n        path: StrOrKaosPath,\n        data: str,\n        *,\n        mode: Literal[\"w\", \"a\"] = \"w\",\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> int:\n        \"\"\"Write text data to the file, returning the number of characters written.\"\"\"\n        ...\n\n    async def mkdir(\n        self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False\n    ) -> None:\n        \"\"\"Create a directory at the given path.\"\"\"\n        ...\n\n    async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess:\n        \"\"\"\n        Execute a command with arguments and return the running process.\n\n        Args:\n            *args: Command and its arguments.\n            env: Environment variables for the subprocess. If None, inherits\n                 from the parent process.\n        \"\"\"\n        ...\n\n\n@dataclass\nclass StatResult:\n    \"\"\"KAOS stat result data class.\"\"\"\n\n    st_mode: int\n    st_ino: int\n    st_dev: int\n    st_nlink: int\n    st_uid: int\n    st_gid: int\n    st_size: int\n    st_atime: float\n    st_mtime: float\n    st_ctime: float\n\n\ndef get_current_kaos() -> Kaos:\n    \"\"\"Get the current KAOS instance.\"\"\"\n    from kaos._current import current_kaos\n\n    return current_kaos.get()\n\n\ndef set_current_kaos(kaos: Kaos) -> contextvars.Token[Kaos]:\n    \"\"\"Set the current KAOS instance.\"\"\"\n    from kaos._current import current_kaos\n\n    return current_kaos.set(kaos)\n\n\ndef reset_current_kaos(token: contextvars.Token[Kaos]) -> None:\n    \"\"\"Reset the current KAOS instance.\"\"\"\n    from kaos._current import current_kaos\n\n    current_kaos.reset(token)\n\n\ndef pathclass() -> type[PurePath]:\n    return get_current_kaos().pathclass()\n\n\ndef normpath(path: StrOrKaosPath) -> KaosPath:\n    return get_current_kaos().normpath(path)\n\n\ndef gethome() -> KaosPath:\n    return get_current_kaos().gethome()\n\n\ndef getcwd() -> KaosPath:\n    return get_current_kaos().getcwd()\n\n\nasync def chdir(path: StrOrKaosPath) -> None:\n    await get_current_kaos().chdir(path)\n\n\nasync def stat(path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult:\n    return await get_current_kaos().stat(path, follow_symlinks=follow_symlinks)\n\n\ndef iterdir(path: StrOrKaosPath) -> AsyncGenerator[KaosPath]:\n    return get_current_kaos().iterdir(path)\n\n\ndef glob(\n    path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True\n) -> AsyncGenerator[KaosPath]:\n    return get_current_kaos().glob(path, pattern, case_sensitive=case_sensitive)\n\n\nasync def readbytes(path: StrOrKaosPath, n: int | None = None) -> bytes:\n    return await get_current_kaos().readbytes(path, n=n)\n\n\nasync def readtext(\n    path: StrOrKaosPath,\n    *,\n    encoding: str = \"utf-8\",\n    errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n) -> str:\n    return await get_current_kaos().readtext(path, encoding=encoding, errors=errors)\n\n\ndef readlines(\n    path: StrOrKaosPath,\n    *,\n    encoding: str = \"utf-8\",\n    errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n) -> AsyncGenerator[str]:\n    return get_current_kaos().readlines(path, encoding=encoding, errors=errors)\n\n\nasync def writebytes(path: StrOrKaosPath, data: bytes) -> int:\n    return await get_current_kaos().writebytes(path, data)\n\n\nasync def writetext(\n    path: StrOrKaosPath,\n    data: str,\n    *,\n    mode: Literal[\"w\", \"a\"] = \"w\",\n    encoding: str = \"utf-8\",\n    errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n) -> int:\n    return await get_current_kaos().writetext(\n        path, data, mode=mode, encoding=encoding, errors=errors\n    )\n\n\nasync def mkdir(path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False) -> None:\n    return await get_current_kaos().mkdir(path, parents=parents, exist_ok=exist_ok)\n\n\nasync def exec(*args: str, env: Mapping[str, str] | None = None) -> KaosProcess:\n    return await get_current_kaos().exec(*args, env=env)\n"
  },
  {
    "path": "packages/kaos/src/kaos/_current.py",
    "content": "from contextvars import ContextVar\n\nfrom kaos import Kaos\nfrom kaos.local import local_kaos\n\ncurrent_kaos = ContextVar[Kaos](\"current_kaos\", default=local_kaos)\n"
  },
  {
    "path": "packages/kaos/src/kaos/local.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nfrom asyncio.subprocess import Process as AsyncioProcess\nfrom collections.abc import AsyncGenerator\nfrom pathlib import Path, PurePath\nfrom typing import TYPE_CHECKING, Literal\n\nif os.name == \"nt\":\n    import ntpath as pathmodule\n    from pathlib import PureWindowsPath as PurePathClass\nelse:\n    import posixpath as pathmodule\n    from pathlib import PurePosixPath as PurePathClass\n\nfrom collections.abc import Mapping\n\nimport aiofiles\nimport aiofiles.os\n\nfrom kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath\nfrom kaos.path import KaosPath\n\nif TYPE_CHECKING:\n\n    def type_check(local: LocalKaos) -> None:\n        _: Kaos = local\n\n\nclass LocalKaos:\n    \"\"\"\n    A KAOS implementation that directly interacts with the local filesystem.\n    \"\"\"\n\n    name: str = \"local\"\n\n    class Process:\n        \"\"\"Local KAOS process wrapper around asyncio.subprocess.Process.\"\"\"\n\n        def __init__(self, process: AsyncioProcess) -> None:\n            if process.stdin is None or process.stdout is None or process.stderr is None:\n                raise ValueError(\"Process must be created with stdin/stdout/stderr pipes.\")\n\n            self._process = process\n            self.stdin: AsyncWritable = process.stdin\n            self.stdout: AsyncReadable = process.stdout\n            self.stderr: AsyncReadable = process.stderr\n\n        @property\n        def pid(self) -> int:\n            return self._process.pid\n\n        @property\n        def returncode(self) -> int | None:\n            return self._process.returncode\n\n        async def wait(self) -> int:\n            return await self._process.wait()\n\n        async def kill(self) -> None:\n            self._process.kill()\n\n    def pathclass(self) -> type[PurePath]:\n        return PurePathClass\n\n    def normpath(self, path: StrOrKaosPath) -> KaosPath:\n        return KaosPath(pathmodule.normpath(str(path)))\n\n    def gethome(self) -> KaosPath:\n        return KaosPath.unsafe_from_local_path(Path.home())\n\n    def getcwd(self) -> KaosPath:\n        return KaosPath.unsafe_from_local_path(Path.cwd())\n\n    async def chdir(self, path: StrOrKaosPath) -> None:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        os.chdir(local_path)\n\n    async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        st = await aiofiles.os.stat(local_path, follow_symlinks=follow_symlinks)\n        return StatResult(\n            st_mode=st.st_mode,\n            st_ino=st.st_ino,\n            st_dev=st.st_dev,\n            st_nlink=st.st_nlink,\n            st_uid=st.st_uid,\n            st_gid=st.st_gid,\n            st_size=st.st_size,\n            st_atime=st.st_atime,\n            st_mtime=st.st_mtime,\n            st_ctime=st.st_ctime if os.name != \"nt\" else st.st_birthtime,\n        )\n\n    async def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        for entry in await aiofiles.os.listdir(local_path):\n            yield KaosPath.unsafe_from_local_path(local_path / entry)\n\n    async def glob(\n        self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True\n    ) -> AsyncGenerator[KaosPath]:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        entries = await asyncio.to_thread(\n            lambda: list(local_path.glob(pattern, case_sensitive=case_sensitive))\n        )\n        for entry in entries:\n            yield KaosPath.unsafe_from_local_path(entry)\n\n    async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        async with aiofiles.open(local_path, mode=\"rb\") as f:\n            return await f.read() if n is None else await f.read(n)\n\n    async def readtext(\n        self,\n        path: str | KaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> str:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:\n            return await f.read()\n\n    async def readlines(\n        self,\n        path: str | KaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> AsyncGenerator[str]:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:\n            async for line in f:\n                yield line\n\n    async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        async with aiofiles.open(local_path, mode=\"wb\") as f:\n            return await f.write(data)\n\n    async def writetext(\n        self,\n        path: str | KaosPath,\n        data: str,\n        *,\n        mode: Literal[\"w\"] | Literal[\"a\"] = \"w\",\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> int:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        async with aiofiles.open(local_path, mode=mode, encoding=encoding, errors=errors) as f:\n            return await f.write(data)\n\n    async def mkdir(\n        self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False\n    ) -> None:\n        local_path = path.unsafe_to_local_path() if isinstance(path, KaosPath) else Path(path)\n        await asyncio.to_thread(local_path.mkdir, parents=parents, exist_ok=exist_ok)\n\n    async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess:\n        if not args:\n            raise ValueError(\"At least one argument (the program to execute) is required.\")\n\n        process = await asyncio.create_subprocess_exec(\n            *args,\n            stdin=asyncio.subprocess.PIPE,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n            env=env,\n        )\n        return self.Process(process)\n\n\nlocal_kaos = LocalKaos()\n\"\"\"The default local KAOS instance.\"\"\"\n"
  },
  {
    "path": "packages/kaos/src/kaos/path.py",
    "content": "from __future__ import annotations\n\nfrom collections.abc import AsyncGenerator\nfrom pathlib import Path, PurePath\nfrom stat import S_ISDIR, S_ISREG\nfrom typing import Any, Literal\n\nimport kaos\n\n\nclass KaosPath:\n    \"\"\"\n    A path abstraction for KAOS filesystem.\n    \"\"\"\n\n    def __init__(self, *args: str) -> None:\n        self._path: PurePath = kaos.pathclass()(*args)\n\n    @classmethod\n    def unsafe_from_local_path(cls, path: Path) -> KaosPath:\n        \"\"\"\n        Create a `KaosPath` from a local `Path`.\n        Only use this if you are sure that `LocalKaos` is being used.\n        \"\"\"\n        return cls(str(path))\n\n    def unsafe_to_local_path(self) -> Path:\n        \"\"\"\n        Convert the `KaosPath` to a local `Path`.\n        Only use this if you are sure that `LocalKaos` is being used.\n        \"\"\"\n        return Path(str(self._path))\n\n    def __lt__(self, other: KaosPath) -> bool:\n        return self._path.__lt__(other._path)\n\n    def __le__(self, other: KaosPath) -> bool:\n        return self._path.__le__(other._path)\n\n    def __gt__(self, other: KaosPath) -> bool:\n        return self._path.__gt__(other._path)\n\n    def __ge__(self, other: KaosPath) -> bool:\n        return self._path.__ge__(other._path)\n\n    def __eq__(self, other: Any) -> bool:\n        if not isinstance(other, KaosPath):\n            return NotImplemented\n        return self._path.__eq__(other._path)\n\n    def __repr__(self) -> str:\n        return f\"KaosPath({repr(str(self._path))})\"\n\n    def __str__(self) -> str:\n        return str(self._path)\n\n    @property\n    def name(self) -> str:\n        \"\"\"Return the final component of the path.\"\"\"\n        return self._path.name\n\n    @property\n    def parent(self) -> KaosPath:\n        \"\"\"Return the parent directory of the path.\"\"\"\n        return KaosPath(str(self._path.parent))\n\n    def is_absolute(self) -> bool:\n        \"\"\"Return True if the path is absolute.\"\"\"\n        return self._path.is_absolute()\n\n    def joinpath(self, *other: str) -> KaosPath:\n        \"\"\"Join this path with other path components.\"\"\"\n        return KaosPath(str(self._path.joinpath(*other)))\n\n    def __truediv__(self, other: str | KaosPath) -> KaosPath:\n        \"\"\"Join this path with another path using the `/` operator.\"\"\"\n        p = other._path if isinstance(other, KaosPath) else other\n        ret = KaosPath()\n        ret._path = self._path.__truediv__(p)\n        return ret\n\n    def canonical(self) -> KaosPath:\n        \"\"\"\n        Make the path absolute, resolving all `.` and `..` in the path.\n        Unlike `pathlib.Path.resolve`, this method does not resolve symlinks.\n        \"\"\"\n        abs_path = self if self.is_absolute() else kaos.getcwd().joinpath(str(self._path))\n        # Normalize the path (handle . and ..) but preserve the format\n        normalized = kaos.normpath(abs_path)\n        # `normpath` might strip trailing slash, but we want to preserve it for directories\n        # However, since we don't access the filesystem, we can't know if it's a directory\n        # So we follow the pathlib behavior which doesn't preserve trailing slashes\n        return normalized\n\n    def relative_to(self, other: KaosPath) -> KaosPath:\n        \"\"\"Return the relative path from `other` to this path.\"\"\"\n        relative_path = self._path.relative_to(other._path)\n        return KaosPath(str(relative_path))\n\n    @classmethod\n    def home(cls) -> KaosPath:\n        \"\"\"Return the home directory as a KaosPath.\"\"\"\n        return kaos.gethome()\n\n    @classmethod\n    def cwd(cls) -> KaosPath:\n        \"\"\"Return the current working directory as a KaosPath.\"\"\"\n        return kaos.getcwd()\n\n    def expanduser(self) -> KaosPath:\n        \"\"\"Expand `~` to the backend home directory.\"\"\"\n        parts = self._path.parts\n        if not parts or parts[0] != \"~\":\n            return self\n\n        home = KaosPath.home()\n        if len(parts) == 1:\n            return home\n        return home.joinpath(*parts[1:])\n\n    async def stat(self, follow_symlinks: bool = True) -> kaos.StatResult:\n        \"\"\"Return an os.stat_result for the path.\"\"\"\n        return await kaos.stat(self, follow_symlinks=follow_symlinks)\n\n    async def exists(self, *, follow_symlinks: bool = True) -> bool:\n        \"\"\"Return True if the path points to an existing filesystem entry.\"\"\"\n        try:\n            await self.stat(follow_symlinks=follow_symlinks)\n            return True\n        except OSError:\n            return False\n\n    async def is_file(self, *, follow_symlinks: bool = True) -> bool:\n        \"\"\"Return True if the path points to a regular file.\"\"\"\n        try:\n            st = await self.stat(follow_symlinks=follow_symlinks)\n            return S_ISREG(st.st_mode)\n        except OSError:\n            return False\n\n    async def is_dir(self, *, follow_symlinks: bool = True) -> bool:\n        \"\"\"Return True if the path points to a directory.\"\"\"\n        try:\n            st = await self.stat(follow_symlinks=follow_symlinks)\n            return S_ISDIR(st.st_mode)\n        except OSError:\n            return False\n\n    def iterdir(self) -> AsyncGenerator[KaosPath]:\n        \"\"\"Return the direct children of the directory.\"\"\"\n        return kaos.iterdir(self)\n\n    def glob(self, pattern: str, *, case_sensitive: bool = True) -> AsyncGenerator[KaosPath]:\n        \"\"\"Return all paths matching the pattern under this directory.\"\"\"\n        return kaos.glob(self, pattern, case_sensitive=case_sensitive)\n\n    async def read_bytes(self, n: int | None = None) -> bytes:\n        \"\"\"Read the entire file contents as bytes, or the first n bytes if provided.\"\"\"\n        return await kaos.readbytes(self, n=n)\n\n    async def read_text(\n        self,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> str:\n        \"\"\"Read the entire file contents as text.\"\"\"\n        return await kaos.readtext(self, encoding=encoding, errors=errors)\n\n    def read_lines(\n        self,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> AsyncGenerator[str]:\n        \"\"\"Iterate over the lines of the file.\"\"\"\n        return kaos.readlines(self, encoding=encoding, errors=errors)\n\n    async def write_bytes(self, data: bytes) -> int:\n        \"\"\"Write bytes data to the file.\"\"\"\n        return await kaos.writebytes(self, data)\n\n    async def write_text(\n        self,\n        data: str,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> int:\n        \"\"\"Write text data to the file, returning the number of characters written.\"\"\"\n        return await kaos.writetext(\n            self,\n            data,\n            mode=\"w\",\n            encoding=encoding,\n            errors=errors,\n        )\n\n    async def append_text(\n        self,\n        data: str,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> int:\n        \"\"\"Append text data to the file, returning the number of characters written.\"\"\"\n        return await kaos.writetext(\n            self,\n            data,\n            mode=\"a\",\n            encoding=encoding,\n            errors=errors,\n        )\n\n    async def mkdir(self, parents: bool = False, exist_ok: bool = False) -> None:\n        \"\"\"Create a directory at this path.\"\"\"\n        return await kaos.mkdir(self, parents=parents, exist_ok=exist_ok)\n"
  },
  {
    "path": "packages/kaos/src/kaos/py.typed",
    "content": ""
  },
  {
    "path": "packages/kaos/src/kaos/ssh.py",
    "content": "from __future__ import annotations\n\nimport posixpath\nimport shlex\nimport stat\nfrom collections.abc import AsyncGenerator, Mapping\nfrom pathlib import PurePath, PurePosixPath\nfrom typing import TYPE_CHECKING, Literal\n\nimport asyncssh\nfrom asyncssh.constants import (\n    FILEXFER_TYPE_BLOCK_DEVICE,\n    FILEXFER_TYPE_CHAR_DEVICE,\n    FILEXFER_TYPE_DIRECTORY,\n    FILEXFER_TYPE_FIFO,\n    FILEXFER_TYPE_REGULAR,\n    FILEXFER_TYPE_SOCKET,\n    FILEXFER_TYPE_SYMLINK,\n)\n\nfrom kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath\nfrom kaos.path import KaosPath\n\nif TYPE_CHECKING:\n\n    def type_check(ssh: SSHKaos) -> None:\n        _: Kaos = ssh\n\n\n_FILEXFER_TYPE_TO_MODE = {\n    FILEXFER_TYPE_REGULAR: stat.S_IFREG,\n    FILEXFER_TYPE_DIRECTORY: stat.S_IFDIR,\n    FILEXFER_TYPE_SYMLINK: stat.S_IFLNK,\n    FILEXFER_TYPE_SOCKET: stat.S_IFSOCK,\n    FILEXFER_TYPE_CHAR_DEVICE: stat.S_IFCHR,\n    FILEXFER_TYPE_BLOCK_DEVICE: stat.S_IFBLK,\n    FILEXFER_TYPE_FIFO: stat.S_IFIFO,\n}\n\n\ndef _build_st_mode(attrs: asyncssh.SFTPAttrs) -> int:\n    \"\"\"Combine SFTP permissions and type information into st_mode.\"\"\"\n\n    perm_mode = attrs.permissions or 0\n    type_mode = _FILEXFER_TYPE_TO_MODE.get(attrs.type, 0)\n\n    if perm_mode:\n        if type_mode and stat.S_IFMT(perm_mode) == 0:\n            perm_mode |= type_mode\n        return perm_mode\n\n    return type_mode\n\n\ndef _sec_with_nanos(sec: int, ns: int | None) -> float:\n    if ns is None:\n        return float(sec)\n    return float(sec) + (ns / 1_000_000_000.0)\n\n\nclass SSHKaos:\n    \"\"\"\n    A KAOS implementation that interacts with a remote machine via SSH and SFTP.\n    \"\"\"\n\n    name: str = \"ssh\"\n\n    class Process:\n        \"\"\"KAOS process wrapper around asyncssh.SSHClientProcess.\"\"\"\n\n        def __init__(self, process: asyncssh.SSHClientProcess[bytes]) -> None:\n            self._process = process\n            self.stdin: AsyncWritable = process.stdin\n            self.stdout: AsyncReadable = process.stdout\n            self.stderr: AsyncReadable = process.stderr\n\n        @property\n        def pid(self) -> int:\n            # FIXME: SSHClientProcess does not have a pid attribute.\n            return -1\n\n        @property\n        def returncode(self) -> int | None:\n            return self._process.returncode\n\n        async def wait(self) -> int:\n            # asyncssh.SSHClientProcess.wait() drains stdout/stderr via communicate()\n            # which clears the internal receive buffers. Use wait_closed() so\n            # stdout/stderr remain readable after wait, matching LocalKaos.\n            await self._process.wait_closed()\n            return 1 if self._process.returncode is None else self._process.returncode\n\n        async def kill(self) -> None:\n            self._process.kill()\n\n    @classmethod\n    async def create(\n        cls,\n        host: str,\n        *,\n        port: int = 22,\n        username: str | None = None,\n        password: str | None = None,\n        key_paths: list[str] | None = None,\n        key_contents: list[str] | None = None,\n        cwd: str | None = None,\n        **extra_options: object,\n    ):\n        options = {\n            \"host\": host,\n            \"port\": port,\n            **extra_options,\n        }\n        if username:\n            options[\"username\"] = username\n        if password:\n            options[\"password\"] = password\n        client_keys: list[str | asyncssh.SSHKey] = []\n        if key_contents:\n            client_keys.extend([asyncssh.import_private_key(key) for key in key_contents])\n        if key_paths:\n            client_keys.extend(key_paths)\n        if client_keys:\n            options[\"client_keys\"] = client_keys\n        # Ensure encoding is None to read/write bytes\n        options[\"encoding\"] = None\n        # Known hosts is None to avoid the \"Host key is not trusted\" error\n        options[\"known_hosts\"] = None\n        # Connect to ssh\n        connection = await asyncssh.connect(**options)\n        sftp = await connection.start_sftp_client()\n        home_dir = await sftp.realpath(\".\")\n        if cwd is not None:\n            await sftp.chdir(cwd)\n            cwd = await sftp.realpath(\".\")\n        else:\n            cwd = home_dir\n        return cls(connection=connection, sftp=sftp, home=home_dir, cwd=cwd, host=host)\n\n    def __init__(\n        self,\n        *,\n        connection: asyncssh.SSHClientConnection,\n        sftp: asyncssh.SFTPClient,\n        home: str,\n        cwd: str,\n        host: str,\n    ) -> None:\n        self._connection = connection\n        self._sftp = sftp\n        self._home_dir = home\n        self._cwd = cwd\n        self._host = host\n\n    @property\n    def host(self) -> str:\n        return self._host\n\n    def pathclass(self) -> type[PurePath]:\n        return PurePosixPath\n\n    def normpath(self, path: StrOrKaosPath) -> KaosPath:\n        return KaosPath(posixpath.normpath(str(path)))\n\n    def gethome(self) -> KaosPath:\n        return KaosPath(self._home_dir)\n\n    def getcwd(self) -> KaosPath:\n        return KaosPath(self._cwd)\n\n    async def chdir(self, path: StrOrKaosPath) -> None:\n        await self._sftp.chdir(str(path))\n        self._cwd = await self._sftp.realpath(\".\")\n\n    async def stat(\n        self,\n        path: StrOrKaosPath,\n        *,\n        follow_symlinks: bool = True,\n    ) -> StatResult:\n        try:\n            st = await self._sftp.stat(str(path), follow_symlinks=follow_symlinks)\n        except asyncssh.SFTPError as e:\n            raise OSError from e\n\n        return StatResult(\n            st_mode=_build_st_mode(st),\n            st_uid=st.uid or 0,\n            st_gid=st.gid or 0,\n            st_size=st.size or 0,\n            st_atime=_sec_with_nanos(st.atime or 0, st.atime_ns),\n            st_mtime=_sec_with_nanos(st.mtime or 0, st.mtime_ns),\n            st_ctime=_sec_with_nanos(st.ctime or 0, st.ctime_ns),\n            st_ino=0,  # sftp does not support ino\n            st_dev=0,  # sftp does not support dev\n            st_nlink=st.nlink or 0,\n        )\n\n    async def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]:\n        kaos_path = KaosPath(path) if isinstance(path, str) else path\n        for entry in await self._sftp.listdir(str(path)):\n            # NOTE: sftp listdir gives . and ..\n            if entry in {\".\", \"..\"}:\n                continue\n            yield kaos_path / entry\n\n    async def glob(\n        self,\n        path: StrOrKaosPath,\n        pattern: str,\n        *,\n        case_sensitive: bool = True,\n    ) -> AsyncGenerator[KaosPath]:\n        if not case_sensitive:\n            raise ValueError(\"Case insensitive glob is not supported in current environment\")\n        real_path = await self._sftp.realpath(str(path))\n        for entry in await self._sftp.glob(f\"{real_path}/{pattern}\"):\n            yield KaosPath(await self._sftp.realpath(str(entry)))\n\n    async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes:\n        async with self._sftp.open(str(path), \"rb\") as f:\n            return await f.read() if n is None else await f.read(n)\n\n    async def readtext(\n        self,\n        path: str | KaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> str:\n        async with self._sftp.open(str(path), \"r\", encoding=encoding, errors=errors) as f:\n            return await f.read()\n\n    async def readlines(\n        self,\n        path: str | KaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> AsyncGenerator[str]:\n        # NOTE: readlines is not supported by SFTPClientFile\n        text = await self.readtext(path, encoding=encoding, errors=errors)\n        for line in text.splitlines():\n            yield line\n\n    async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int:\n        async with self._sftp.open(str(path), \"wb\") as f:\n            return await f.write(data)\n\n    async def writetext(\n        self,\n        path: str | KaosPath,\n        data: str,\n        *,\n        mode: Literal[\"w\"] | Literal[\"a\"] = \"w\",\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> int:\n        async with self._sftp.open(str(path), mode, encoding=encoding, errors=errors) as f:\n            return await f.write(data)\n\n    async def mkdir(\n        self,\n        path: StrOrKaosPath,\n        parents: bool = False,\n        exist_ok: bool = False,\n    ) -> None:\n        if parents:\n            await self._sftp.makedirs(str(path), exist_ok=exist_ok)\n        else:\n            existed = await self._sftp.exists(str(path))\n            if existed and not exist_ok:\n                raise FileExistsError(f\"{path} already exists\")\n            await self._sftp.mkdir(str(path))\n\n    async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess:\n        if not args:\n            raise ValueError(\"At least one argument (the program to execute) is required.\")\n        command = \" \".join(shlex.quote(arg) for arg in args)\n        # NOTE:\n        # - SFTP has its own concept of working directory; it does not affect SSH exec.\n        # - To make exec behave like other KAOS backends, we explicitly `cd` to our tracked\n        #   cwd before running the command.\n        #\n        # This is intentionally strict: if cwd doesn't exist, the command fails.\n        if self._cwd:\n            command = f\"cd {shlex.quote(self._cwd)} && {command}\"\n        process = await self._connection.create_process(command, encoding=None, env=env)\n        return self.Process(process)\n\n    async def unsafe_close(self) -> None:\n        \"\"\"Close the SSH connection. After that, SSHKaos will be unusable.\"\"\"\n        if self._sftp:\n            self._sftp.exit()\n        if self._connection:\n            self._connection.close()\n"
  },
  {
    "path": "packages/kaos/tests/test_kaos_path.py",
    "content": "from __future__ import annotations\n\nimport os\nfrom collections.abc import Generator\nfrom pathlib import Path\n\nimport pytest\n\nfrom kaos import reset_current_kaos, set_current_kaos\nfrom kaos.local import LocalKaos\nfrom kaos.path import KaosPath\n\n\n@pytest.fixture\ndef kaos_cwd(tmp_path: Path) -> Generator[KaosPath]:\n    \"\"\"Set LocalKaos as the current Kaos and switch cwd to a temp directory.\"\"\"\n    token = set_current_kaos(LocalKaos())\n    old_cwd = Path.cwd()\n    try:\n        os.chdir(tmp_path)\n        yield KaosPath.unsafe_from_local_path(tmp_path)\n    finally:\n        os.chdir(old_cwd)\n        reset_current_kaos(token)\n\n\ndef test_join_and_parent(kaos_cwd: KaosPath):\n    base = KaosPath(\"folder\")\n    child = base / \"data.txt\"\n\n    assert str(child) == str(Path(\"folder\") / \"data.txt\")\n    assert child.parent == KaosPath(\"folder\")\n    assert child.name == \"data.txt\"\n    assert not child.is_absolute()\n\n\ndef test_home_and_cwd(kaos_cwd: KaosPath):\n    assert str(KaosPath.home()) == str(Path.home())\n    assert str(KaosPath.cwd()) == str(kaos_cwd)\n\n\ndef test_expanduser(kaos_cwd: KaosPath):\n    home = KaosPath.home()\n    assert str(KaosPath(\"~\").expanduser()) == str(home)\n    assert str(KaosPath(\"~/docs\").expanduser()) == str(home / \"docs\")\n\n\ndef test_canonical_and_relative_to(kaos_cwd: KaosPath):\n    canonical = KaosPath(\"nested/../file.txt\").canonical()\n    assert str(canonical) == str(kaos_cwd / \"file.txt\")\n\n    base = KaosPath(str(kaos_cwd / \"base\"))\n    child = base / \"inner\" / \"note.txt\"\n    relative = child.relative_to(base)\n    assert str(relative) == str(KaosPath(\"inner\") / \"note.txt\")\n\n\nasync def test_exists_and_file_ops(kaos_cwd: KaosPath):\n    file_path = KaosPath(\"log.txt\")\n    assert not await file_path.exists()\n\n    await file_path.write_text(\"hello\")\n    assert await file_path.exists()\n    assert await file_path.is_file()\n    assert not await file_path.is_dir()\n\n    await file_path.append_text(\"\\nworld\")\n    assert await file_path.read_text() == \"hello\\nworld\"\n\n    dir_path = KaosPath(\"logs\")\n    await dir_path.mkdir()\n    assert await dir_path.exists()\n    assert await dir_path.is_dir()\n\n\nasync def test_iterdir_and_glob_from_kaos_path(kaos_cwd: KaosPath):\n    base_dir = KaosPath(\"data\")\n    await base_dir.mkdir()\n\n    await (base_dir / \"one.txt\").write_text(\"1\")\n    await (base_dir / \"two.md\").write_text(\"2\")\n    await (base_dir / \"three.txt\").write_text(\"3\")\n\n    entries = [entry.name async for entry in base_dir.iterdir()]\n    assert set(entries) == {\"one.txt\", \"two.md\", \"three.txt\"}\n\n    globbed = [entry.name async for entry in base_dir.glob(\"*.txt\")]\n    assert set(globbed) == {\"one.txt\", \"three.txt\"}\n\n\nasync def test_read_write_bytes(kaos_cwd: KaosPath):\n    file_path = KaosPath(\"data.bin\")\n    await file_path.write_bytes(b\"\\x00\\x01\\xff\")\n    assert await file_path.read_bytes() == b\"\\x00\\x01\\xff\"\n"
  },
  {
    "path": "packages/kaos/tests/test_local_kaos.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nfrom collections.abc import Generator\nfrom pathlib import Path, PurePosixPath, PureWindowsPath\n\nimport pytest\n\nfrom kaos import reset_current_kaos, set_current_kaos\nfrom kaos.local import LocalKaos\nfrom kaos.path import KaosPath\n\n\n@pytest.fixture\ndef local_kaos(tmp_path: Path) -> Generator[LocalKaos]:\n    \"\"\"Set LocalKaos as the current Kaos and switch cwd to a temp directory.\"\"\"\n    local = LocalKaos()\n    token = set_current_kaos(local)\n    old_cwd = Path.cwd()\n    try:\n        os.chdir(tmp_path)\n        yield local\n    finally:\n        os.chdir(old_cwd)\n        reset_current_kaos(token)\n\n\ndef test_pathclass_gethome_and_getcwd(local_kaos: LocalKaos):\n    path_class = local_kaos.pathclass()\n    if os.name == \"nt\":\n        assert issubclass(path_class, PureWindowsPath)\n    else:\n        assert issubclass(path_class, PurePosixPath)\n\n    assert str(local_kaos.gethome()) == str(Path.home())\n    assert str(local_kaos.getcwd()) == str(Path.cwd())\n\n\nasync def test_chdir_and_stat(local_kaos: LocalKaos):\n    new_dir = local_kaos.getcwd() / \"nested\"\n    await local_kaos.mkdir(new_dir)\n\n    await local_kaos.chdir(new_dir)\n    assert Path.cwd() == new_dir.unsafe_to_local_path()\n\n    file_path = new_dir / \"file.txt\"\n    await local_kaos.writetext(file_path, \"hello world\")\n\n    stat_result = await local_kaos.stat(file_path)\n    assert stat_result.st_size == len(\"hello world\")\n\n\nasync def test_iterdir_and_glob(local_kaos: LocalKaos):\n    tmp_path = local_kaos.getcwd()\n    await local_kaos.mkdir(tmp_path / \"alpha\")\n    await local_kaos.writetext(tmp_path / \"bravo.txt\", \"bravo\")\n    await local_kaos.writetext(tmp_path / \"charlie.TXT\", \"charlie\")\n\n    entries = [entry async for entry in local_kaos.iterdir(tmp_path)]\n    assert {entry.name for entry in entries} == {\"alpha\", \"bravo.txt\", \"charlie.TXT\"}\n    assert all(isinstance(entry, KaosPath) for entry in entries)\n\n    matched = [entry.name async for entry in local_kaos.glob(tmp_path, \"*.txt\")]\n    assert set(matched) == {\"bravo.txt\"}\n\n\nasync def test_read_write_and_append_text(local_kaos: LocalKaos):\n    tmp_path = local_kaos.getcwd()\n    file_path = tmp_path / \"note.txt\"\n\n    written = await local_kaos.writetext(file_path, \"line1\")\n    assert written == len(\"line1\")\n\n    content = await local_kaos.readtext(file_path)\n    assert content == \"line1\"\n\n    await local_kaos.writetext(file_path, \"\\nline2\", mode=\"a\")\n    lines = [line async for line in local_kaos.readlines(file_path)]\n    assert \"\".join(lines) == \"line1\\nline2\"\n\n\nasync def test_mkdir_with_parents(local_kaos: LocalKaos):\n    tmp_path = local_kaos.getcwd()\n    nested_dir = tmp_path / \"a\" / \"b\" / \"c\"\n\n    await local_kaos.mkdir(nested_dir, parents=True)\n    assert await nested_dir.is_dir()\n\n\nasync def test_read_write_bytes(local_kaos: LocalKaos):\n    tmp_path = local_kaos.getcwd()\n    file_path = tmp_path / \"data.bin\"\n    await local_kaos.writebytes(file_path, b\"\\x00\\x01\\xff\")\n    assert await local_kaos.readbytes(file_path) == b\"\\x00\\x01\\xff\"\n\n\ndef _python_code_args(code: str) -> tuple[str, str, str]:\n    return sys.executable, \"-c\", code\n\n\nasync def test_exec_runs_command_and_streams(local_kaos: LocalKaos):\n    code = \"import sys\\nsys.stdout.write('hello\\\\n')\\nsys.stderr.write('stderr line\\\\n')\\n\"\n\n    process = await local_kaos.exec(*_python_code_args(code))\n\n    assert process.stdin is not None\n    assert process.stdout is not None\n    assert process.stderr is not None\n\n    stdout_data, stderr_data = await asyncio.gather(process.stdout.read(), process.stderr.read())\n    assert await process.wait() == 0\n    assert stdout_data.decode(\"utf-8\").strip() == \"hello\"\n    assert stderr_data.decode(\"utf-8\").strip() == \"stderr line\"\n\n\nasync def test_exec_runs_command_wait_before_read(local_kaos: LocalKaos):\n    code = \"import sys\\nsys.stdout.write('hello\\\\n')\\nsys.stderr.write('stderr line\\\\n')\\n\"\n\n    process = await local_kaos.exec(*_python_code_args(code))\n\n    assert process.stdin is not None\n    assert process.stdout is not None\n    assert process.stderr is not None\n\n    assert await process.wait() == 0\n    stdout_data, stderr_data = await asyncio.gather(process.stdout.read(), process.stderr.read())\n    assert stdout_data.decode(\"utf-8\").strip() == \"hello\"\n    assert stderr_data.decode(\"utf-8\").strip() == \"stderr line\"\n\n\nasync def test_exec_non_zero_exit(local_kaos: LocalKaos):\n    process = await local_kaos.exec(*_python_code_args(\"import sys; sys.exit(7)\"))\n\n    exit_code = await process.wait()\n    assert exit_code == 7\n\n\nasync def test_exec_wait_timeout(local_kaos: LocalKaos):\n    process = await local_kaos.exec(*_python_code_args(\"import time; time.sleep(1)\"))\n    assert process.pid > 0\n\n    try:\n        with pytest.raises(asyncio.TimeoutError):\n            await asyncio.wait_for(process.wait(), timeout=0.01)\n    finally:\n        if process.returncode is None:\n            await process.kill()\n        await process.wait()\n"
  },
  {
    "path": "packages/kaos/tests/test_local_kaos_cmd.py",
    "content": "\"\"\"Tests for `kaos.exec` running commands via cmd.exe /c.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport platform\nfrom collections.abc import Generator\nfrom pathlib import Path\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nimport kaos\nfrom kaos import reset_current_kaos, set_current_kaos\nfrom kaos.local import LocalKaos\n\npytestmark = pytest.mark.skipif(\n    platform.system() != \"Windows\", reason=\"cmd.exe tests run only on Windows.\"\n)\n\n\n@pytest.fixture(autouse=True)\ndef local_kaos(tmp_path: Path) -> Generator[LocalKaos]:\n    \"\"\"Set LocalKaos as current KAOS and isolate cwd per test.\"\"\"\n    local = LocalKaos()\n    token = set_current_kaos(local)\n    old_cwd = Path.cwd()\n    os.chdir(tmp_path)\n    try:\n        yield local\n    finally:\n        os.chdir(old_cwd)\n        reset_current_kaos(token)\n\n\nasync def run_cmd(command: str) -> tuple[int, str, str]:\n    \"\"\"Execute a cmd.exe command through kaos.exec and collect exit code and streams.\"\"\"\n    process = await kaos.exec(\"cmd.exe\", \"/c\", f\"chcp 65001>nul & {command}\")\n    assert process.stdout is not None\n    assert process.stderr is not None\n\n    stdout_task = asyncio.create_task(process.stdout.read())\n    stderr_task = asyncio.create_task(process.stderr.read())\n    exit_code = await process.wait()\n    stdout_data, stderr_data = await asyncio.gather(stdout_task, stderr_task)\n    return exit_code, stdout_data.decode(\"utf-8\"), stderr_data.decode(\"utf-8\")\n\n\nasync def test_simple_command():\n    \"\"\"Ensure a basic cmd.exe command runs.\"\"\"\n    exit_code, stdout, stderr = await run_cmd(\"echo Hello Windows\")\n\n    assert exit_code == 0\n    assert stdout.strip() == snapshot(\"Hello Windows\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_with_error():\n    \"\"\"Failing commands should return a non-zero exit code.\"\"\"\n    exit_code, stdout, stderr = await run_cmd(\"exit /b 1\")\n\n    assert exit_code == 1\n    assert stdout == snapshot(\"\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_chaining():\n    \"\"\"Chaining commands with && should work.\"\"\"\n    exit_code, stdout, stderr = await run_cmd(\"echo First&& echo Second\")\n\n    assert exit_code == 0\n    assert stdout.replace(\"\\r\\n\", \"\\n\") == snapshot(\"First\\nSecond\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_file_operations():\n    \"\"\"Basic file write/read using cmd redirection.\"\"\"\n    file_path = Path(\"test_file.txt\")\n    exit_code, stdout, stderr = await run_cmd(f\"echo Test content> {file_path}\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"\")\n    assert stderr == snapshot(\"\")\n    assert file_path.is_file()\n\n    exit_code, stdout, stderr = await run_cmd(f\"type {file_path}\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"Test content\\r\\n\")\n    assert stderr == snapshot(\"\")\n"
  },
  {
    "path": "packages/kaos/tests/test_local_kaos_sh.py",
    "content": "\"\"\"Tests for `kaos.exec` running commands via /bin/sh -c.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport platform\nfrom collections.abc import Generator\nfrom pathlib import Path\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nimport kaos\nfrom kaos import reset_current_kaos, set_current_kaos\nfrom kaos.local import LocalKaos\n\npytestmark = pytest.mark.skipif(\n    platform.system() == \"Windows\", reason=\"/bin/sh is not available on Windows.\"\n)\n\n\n@pytest.fixture(autouse=True)\ndef local_kaos(tmp_path: Path) -> Generator[LocalKaos]:\n    \"\"\"Set LocalKaos as current KAOS and isolate cwd per test.\"\"\"\n    local = LocalKaos()\n    token = set_current_kaos(local)\n    old_cwd = Path.cwd()\n    os.chdir(tmp_path)\n    try:\n        yield local\n    finally:\n        os.chdir(old_cwd)\n        reset_current_kaos(token)\n\n\nasync def run_sh(\n    command: str, *, timeout: float | None = None, stdin_data: str | bytes | None = None\n) -> tuple[int, str, str]:\n    \"\"\"Execute a shell command through kaos.exec and collect exit code and streams.\"\"\"\n    process = await kaos.exec(\"/bin/sh\", \"-c\", command)\n    stdout_task = asyncio.create_task(process.stdout.read())\n    stderr_task = asyncio.create_task(process.stderr.read())\n\n    if stdin_data is not None:\n        input_bytes = stdin_data.encode(\"utf-8\") if isinstance(stdin_data, str) else stdin_data\n        process.stdin.write(input_bytes)\n        await process.stdin.drain()\n        process.stdin.close()\n        if hasattr(process.stdin, \"wait_closed\"):\n            await process.stdin.wait_closed()\n\n    try:\n        wait_coro = process.wait()\n        exit_code = (\n            await asyncio.wait_for(wait_coro, timeout=timeout) if timeout else await wait_coro\n        )\n    except TimeoutError:\n        await process.kill()\n        await process.wait()\n        await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)\n        raise\n\n    stdout_data, stderr_data = await asyncio.gather(stdout_task, stderr_task)\n    return exit_code, stdout_data.decode(\"utf-8\"), stderr_data.decode(\"utf-8\")\n\n\nasync def test_simple_command():\n    \"\"\"Test executing a simple command.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"echo 'Hello World'\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"Hello World\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_with_error():\n    \"\"\"Test executing a command that returns an error.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"ls /nonexistent/directory\")\n    assert exit_code != 0\n    assert stdout == snapshot(\"\")\n    assert \"No such file or directory\" in stderr\n\n\nasync def test_command_chaining():\n    \"\"\"Test command chaining with &&.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"echo 'First' && echo 'Second'\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"\"\"\\\nFirst\nSecond\n\"\"\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_sequential():\n    \"\"\"Test sequential command execution with ;.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"echo 'One'; echo 'Two'\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"\"\"\\\nOne\nTwo\n\"\"\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_conditional():\n    \"\"\"Test conditional command execution with ||.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"false || echo 'Success'\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"Success\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_pipe():\n    \"\"\"Test command piping.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"echo 'Hello World' | wc -w\")\n    assert exit_code == 0\n    assert stdout.strip() == snapshot(\"2\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_multiple_pipes():\n    \"\"\"Test multiple pipes in one command.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"printf '1\\\\n2\\\\n3\\\\n' | grep '2' | wc -l\")\n    assert exit_code == 0\n    assert stdout.strip() == snapshot(\"1\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_with_timeout():\n    \"\"\"Test command execution with an upper bound on runtime.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"sleep 0.1\", timeout=1)\n    assert exit_code == 0\n    assert stdout == snapshot(\"\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_timeout_expires():\n    \"\"\"Test command that times out.\"\"\"\n    with pytest.raises(TimeoutError):\n        await run_sh(\"sleep 2\", timeout=0.1)\n\n\nasync def test_environment_variables():\n    \"\"\"Test setting and using environment variables.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\n        \"TEST_VAR='test_value'; export TEST_VAR; echo \\\"$TEST_VAR\\\"\"\n    )\n    assert exit_code == 0\n    assert stdout == snapshot(\"test_value\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_file_operations():\n    \"\"\"Test basic file operations.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"echo 'Test content' > test_file.txt\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"\")\n    assert stderr == snapshot(\"\")\n\n    assert (Path.cwd() / \"test_file.txt\").is_file()\n\n    exit_code, stdout, stderr = await run_sh(\"cat test_file.txt\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"Test content\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_text_processing():\n    \"\"\"Test text processing commands.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"echo 'apple banana cherry' | sed 's/banana/orange/'\")\n    assert exit_code == 0\n    assert stdout == snapshot(\"apple orange cherry\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_substitution():\n    \"\"\"Test command substitution with a portable command.\"\"\"\n    exit_code, stdout, stderr = await run_sh('echo \"Result: $(echo hello)\"')\n    assert exit_code == 0\n    assert stdout == snapshot(\"Result: hello\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_arithmetic_substitution():\n    \"\"\"Test arithmetic substitution - more portable than date command.\"\"\"\n    exit_code, stdout, stderr = await run_sh('echo \"Answer: $((2 + 2))\"')\n    assert exit_code == 0\n    assert stdout == snapshot(\"Answer: 4\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_very_long_output():\n    \"\"\"Test command that produces very long output.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\"seq 1 100 | head -50\")\n\n    assert exit_code == 0\n    assert \"1\" in stdout\n    assert \"50\" in stdout\n    assert \"51\" not in stdout\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_reads_stdin():\n    \"\"\"Test passing data to stdin.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\n        \"read value; printf '%s\\\\n' \\\"$value\\\"\",\n        stdin_data=\"from stdin\\n\",\n    )\n    assert exit_code == 0\n    assert stdout == snapshot(\"from stdin\\n\")\n    assert stderr == snapshot(\"\")\n\n\nasync def test_command_reads_multiple_lines_from_stdin():\n    \"\"\"Test reading multiple lines through stdin.\"\"\"\n    exit_code, stdout, stderr = await run_sh(\n        \"count=0; while IFS= read -r _; do count=$((count+1)); done; printf '%s\\\\n' \\\"$count\\\"\",\n        stdin_data=\"alpha\\nbeta\\ngamma\\n\",\n    )\n    assert exit_code == 0\n    assert stdout.strip() == snapshot(\"3\")\n    assert stderr == snapshot(\"\")\n"
  },
  {
    "path": "packages/kaos/tests/test_ssh_kaos.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nimport platform\nimport stat\nfrom collections.abc import AsyncGenerator\nfrom pathlib import PurePosixPath\nfrom typing import Any\nfrom uuid import uuid4\n\nimport asyncssh\nimport pytest\nimport pytest_asyncio\n\nfrom kaos import reset_current_kaos, set_current_kaos\nfrom kaos.path import KaosPath\nfrom kaos.ssh import SSHKaos\n\npytestmark = pytest.mark.skipif(\n    platform.system() == \"Windows\",\n    reason=\"SSH tests run only on non-Windows.\",\n)\n\n\n@pytest.fixture(scope=\"module\")\ndef ssh_kaos_config() -> dict[str, Any]:\n    \"\"\"Collect SSH connection parameters from environment variables.\"\"\"\n    host = os.environ.get(\"KAOS_SSH_HOST\", \"127.0.0.1\")\n    username = os.environ.get(\"KAOS_SSH_USERNAME\")\n\n    config: dict[str, Any] = {\n        \"host\": host,\n        \"port\": int(os.environ.get(\"KAOS_SSH_PORT\", \"22\")),\n        \"username\": username,\n    }\n\n    password = os.environ.get(\"KAOS_SSH_PASSWORD\")\n    if password:\n        config[\"password\"] = password\n\n    key_paths = os.environ.get(\"KAOS_SSH_KEY_PATHS\")\n    if key_paths:\n        config[\"key_paths\"] = [path for path in key_paths.split(\",\") if path]\n\n    key_contents = os.environ.get(\"KAOS_SSH_KEY_CONTENTS\")\n    if key_contents:\n        config[\"key_contents\"] = [content for content in key_contents.split(\"|||\") if content]\n\n    return config\n\n\n@pytest_asyncio.fixture\nasync def ssh_kaos(ssh_kaos_config: dict[str, Any]) -> AsyncGenerator[SSHKaos]:\n    \"\"\"Create a shared SSH KAOS instance for integration tests.\"\"\"\n    try:\n        kaos = await SSHKaos.create(**ssh_kaos_config)\n    except (OSError, asyncssh.Error) as exc:\n        pytest.skip(f\"SSH connection failed: {exc}\")\n\n    try:\n        yield kaos\n    finally:\n        await kaos.unsafe_close()\n\n\n@pytest_asyncio.fixture\nasync def remote_base(ssh_kaos: SSHKaos) -> AsyncGenerator[str]:\n    \"\"\"Create and clean up an isolated remote directory for each test.\"\"\"\n    base = ssh_kaos.gethome().joinpath(f\".pykaos_test_{os.getpid()}_{uuid4().hex}\")\n    base_str = str(base)\n\n    await ssh_kaos.mkdir(base_str, parents=True, exist_ok=True)\n\n    try:\n        yield base_str\n    finally:\n        cleanup = await ssh_kaos.exec(\"rm\", \"-rf\", base_str)\n        await cleanup.wait()\n        await ssh_kaos.chdir(ssh_kaos.gethome())\n\n\n@pytest.fixture\ndef bind_current_kaos(ssh_kaos: SSHKaos):\n    \"\"\"Bind KAOS globals to the SSH backend for KaosPath helpers.\"\"\"\n    token = set_current_kaos(ssh_kaos)\n    try:\n        yield ssh_kaos\n    finally:\n        reset_current_kaos(token)\n\n\nasync def test_pathclass_home_and_cwd(ssh_kaos: SSHKaos):\n    home = ssh_kaos.gethome()\n    cwd = ssh_kaos.getcwd()\n\n    assert ssh_kaos.pathclass() is PurePosixPath\n    assert isinstance(home, KaosPath)\n    assert isinstance(cwd, KaosPath)\n    assert home.is_absolute()\n    assert cwd.is_absolute()\n    assert str(home) == str(cwd)\n\n\nasync def test_chdir_updates_real_path(ssh_kaos: SSHKaos, remote_base: str):\n    await ssh_kaos.chdir(remote_base)\n    assert str(ssh_kaos.getcwd()) == remote_base\n\n    await ssh_kaos.mkdir(\"child\", exist_ok=True)\n    await ssh_kaos.chdir(\"child\")\n    assert str(ssh_kaos.getcwd()) == os.path.join(remote_base, \"child\")\n\n    await ssh_kaos.chdir(\"..\")\n    assert str(ssh_kaos.getcwd()) == remote_base\n\n\nasync def test_exec_respects_cwd(ssh_kaos: SSHKaos, remote_base: str):\n    await ssh_kaos.chdir(remote_base)\n\n    proc = await ssh_kaos.exec(\"pwd\")\n    out = (await proc.stdout.read()).decode().strip()\n    code = await proc.wait()\n\n    assert code == 0\n    assert out == remote_base\n\n\nasync def test_exec_wait_before_read(ssh_kaos: SSHKaos):\n    proc = await ssh_kaos.exec(\"echo\", \"output\")\n\n    exit_code = await proc.wait()\n    output = (await proc.stdout.read()).decode().strip()\n\n    assert exit_code == 0\n    assert output == \"output\"\n\n\nasync def test_mkdir_respects_exist_ok(ssh_kaos: SSHKaos, remote_base: str):\n    nested_dir = os.path.join(remote_base, \"deep\", \"level\")\n\n    await ssh_kaos.mkdir(nested_dir, parents=True, exist_ok=False)\n\n    with pytest.raises(FileExistsError):\n        await ssh_kaos.mkdir(nested_dir, exist_ok=False)\n\n    await ssh_kaos.mkdir(nested_dir, parents=True, exist_ok=True)\n\n\nasync def test_stat_reports_directory_and_file_metadata(ssh_kaos: SSHKaos, remote_base: str):\n    directory_stat = await ssh_kaos.stat(remote_base, follow_symlinks=False)\n    assert stat.S_ISDIR(directory_stat.st_mode)\n\n    file_path = os.path.join(remote_base, \"payload.txt\")\n    payload = \"metadata\"\n    await ssh_kaos.writetext(file_path, payload)\n\n    file_stat = await ssh_kaos.stat(file_path)\n    assert stat.S_ISREG(file_stat.st_mode)\n    assert file_stat.st_size == len(payload)\n    assert file_stat.st_nlink >= 0\n\n\nasync def test_kaospath_roundtrip(bind_current_kaos: SSHKaos, remote_base: str):\n    await bind_current_kaos.chdir(remote_base)\n\n    text_path = KaosPath(remote_base) / \"text.txt\"\n    bytes_path = KaosPath(remote_base) / \"blob.bin\"\n\n    text_payload = \"Hello SSH\\n\"\n    appended = \"More data\\n\"\n    written = await text_path.write_text(text_payload)\n    assert written == len(text_payload)\n\n    appended_len = await text_path.append_text(appended)\n    assert appended_len == len(appended)\n\n    full_text = await text_path.read_text()\n    assert full_text == text_payload + appended\n\n    lines = [line async for line in text_path.read_lines()]\n    assert lines == [\"Hello SSH\", \"More data\"]\n\n    bytes_payload = bytes(range(32))\n    bytes_written = await bytes_path.write_bytes(bytes_payload)\n    assert bytes_written == len(bytes_payload)\n\n    roundtrip = await bytes_path.read_bytes()\n    assert roundtrip == bytes_payload\n\n    assert str(KaosPath.cwd()) == remote_base\n\n\nasync def test_iterdir_lists_child_entries(ssh_kaos: SSHKaos, remote_base: str):\n    await ssh_kaos.writetext(os.path.join(remote_base, \"file1.txt\"), \"1\")\n    await ssh_kaos.writetext(os.path.join(remote_base, \"file2.log\"), \"2\")\n    await ssh_kaos.mkdir(os.path.join(remote_base, \"subdir\"), exist_ok=True)\n\n    entries = [entry async for entry in ssh_kaos.iterdir(remote_base)]\n    names = {entry.name for entry in entries}\n\n    assert names == {\"file1.txt\", \"file2.log\", \"subdir\"}\n    assert all(isinstance(entry, KaosPath) for entry in entries)\n\n\nasync def test_glob_is_case_sensitive(ssh_kaos: SSHKaos, remote_base: str):\n    await ssh_kaos.writetext(os.path.join(remote_base, \"file.log\"), \"lowercase\")\n    await ssh_kaos.writetext(os.path.join(remote_base, \"FILE.LOG\"), \"uppercase\")\n\n    matches = {str(path) async for path in ssh_kaos.glob(remote_base, \"*.log\")}\n    assert os.path.join(remote_base, \"file.log\") in matches\n    assert os.path.join(remote_base, \"FILE.LOG\") not in matches\n\n    with pytest.raises(ValueError):\n        await anext(ssh_kaos.glob(remote_base, \"*.log\", case_sensitive=False))\n\n\nasync def test_exec_streams_stdout_and_stderr(ssh_kaos: SSHKaos):\n    proc = await ssh_kaos.exec(\"sh\", \"-c\", \"printf 'out\\\\n' && printf 'err\\\\n' 1>&2\")\n\n    stdout_data, stderr_data = await asyncio.gather(proc.stdout.read(), proc.stderr.read())\n    exit_code = await proc.wait()\n\n    assert proc.returncode == exit_code == 0\n    assert stdout_data.decode().strip() == \"out\"\n    assert stderr_data.decode().strip() == \"err\"\n\n\nasync def test_exec_rejects_empty_command(ssh_kaos: SSHKaos):\n    with pytest.raises(ValueError):\n        await ssh_kaos.exec()  # type: ignore[misc]\n\n\nasync def test_process_kill_updates_returncode(ssh_kaos: SSHKaos):\n    proc = await ssh_kaos.exec(\"sh\", \"-c\", \"echo ready; sleep 30\")\n\n    first_line = await proc.stdout.readline()\n    assert first_line == b\"ready\\n\"\n    assert proc.returncode is None\n\n    await proc.kill()\n    exit_code = await proc.wait()\n\n    assert exit_code != 0\n    assert proc.returncode == exit_code\n    assert proc.pid == -1\n"
  },
  {
    "path": "packages/kimi-code/pyproject.toml",
    "content": "[project]\nname = \"kimi-code\"\nversion = \"1.24.0\"\ndescription = \"Kimi Code is a CLI agent that lives in your terminal.\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\ndependencies = [\"kimi-cli==1.24.0\"]\n\n[project.scripts]\nkimi-code = \"kimi_cli.__main__:main\"\n\n[build-system]\nrequires = [\"uv_build>=0.8.5,<0.10.0\"]\nbuild-backend = \"uv_build\"\n\n[tool.uv.build-backend]\nmodule-name = [\"kimi_code\"]\n"
  },
  {
    "path": "packages/kimi-code/src/kimi_code/__init__.py",
    "content": "from __future__ import annotations\n\nimport importlib\nimport sys\n\n# Alias the kimi_code package to kimi_cli for compatibility.\nsys.modules[__name__] = importlib.import_module(\"kimi_cli\")\n"
  },
  {
    "path": "packages/kosong/.pre-commit-config.yaml",
    "content": "orphan: true\n\nrepos:\n  - repo: local\n    hooks:\n      - id: make-format-kosong\n        name: make format-kosong\n        entry: make -C ../.. format-kosong\n        language: system\n        pass_filenames: false\n      - id: make-check-kosong\n        name: make check-kosong\n        entry: make -C ../.. check-kosong\n        language: system\n        pass_filenames: false\n"
  },
  {
    "path": "packages/kosong/CHANGELOG.md",
    "content": "# Changelog\n\n## Unreleased\n\n## 0.45.0 (2026-03-11)\n\n- OpenAI Responses: Fix implicit `reasoning.effort=null` being sent which breaks Responses-compatible endpoints that require reasoning — reasoning parameters are now omitted unless explicitly set\n\n## 0.44.0 (2026-03-09)\n\n- Anthropic: Support optional `metadata` parameter in `Anthropic` chat provider for passing metadata (e.g., `user_id`) to the API\n\n## 0.43.0 (2026-02-24)\n\n- Add `RetryableChatProvider` protocol for providers that can recover from retryable transport errors\n- Implement `RetryableChatProvider` in Kimi, OpenAI Legacy, and OpenAI Responses providers\n- Add `create_openai_client` and `close_replaced_openai_client` utilities to `openai_common`\n\n## 0.42.0 (2026-02-06)\n\n- Anthropic: Use adaptive thinking for Opus 4.6+ models instead of budget-based thinking\n\n## 0.41.1 (2026-02-05)\n\n- Handle string annotations in `SimpleToolset` return type check (supports `from __future__ import annotations`)\n\n## 0.41.0 (2026-01-27)\n\n- Remove default temperature setting in Kimi chat provider based on model name\n\n## 0.40.0 (2026-01-24)\n\n- Add `ScriptedEchoChatProvider` for scripted conversation simulation in end-to-end testing\n\n## 0.39.1 (2026-01-21)\n\n- Fix streamed usage from choice not being read properly\n\n## 0.39.0 (2026-01-21)\n\n- Control thinking mode via `extra_body` parameter instead of legacy `reasoning_effort`\n- Add `files` property to `Kimi` provider that returns a `KimiFiles` object\n- Add `KimiFiles.upload_video()` method for uploading videos to Kimi files API, returning `VideoURLPart`\n\n## 0.38.0 (2026-01-15)\n\n- Add `thinking_effort` property to `ChatProvider` protocol to query current thinking effort level\n\n## 0.37.0 (2026-01-08)\n\n- Change `TokenUsage` from dataclass to pydantic BaseModel.\n\n## 0.36.1 (2026-01-04)\n\n- Relax `loguru` lower bound.\n\n## 0.36.0 (2025-12-31)\n\n- Add `VideoURLPart` content part\n\n## 0.35.1-4 (2025-12-26)\n\n- Nothing changed.\n\n## 0.35.0 (2025-12-24)\n\n- Add registry-based `DisplayBlock` validation to allow custom tool/UI display block subclasses, plus `BriefDisplayBlock` and `UnknownDisplayBlock`\n- Rename brief display payload field to `text` and keep tool return display blocks empty when no brief is provided\n\n## 0.34.1 (2025-12-22)\n\n- Add `convert_mcp_content` util to convert MCP content type to kosong content type\n\n## 0.34.0 (2025-12-19)\n\n- Support Vertex AI in GoogleGenAI chat provider\n- Add `SimpleToolset.add()` and `SimpleToolset.remove()` methods to add or remove tools from the toolset\n\n## 0.33.0 (2025-12-12)\n\n- Lower the required Python version to 3.12\n- Make the `contrib` module an optional extra that can be installed with `uv add \"kosong[contrib]\"`\n\n## 0.32.0 (2025-12-08)\n\n- Introduce `ToolMessageConversion` to customize how tool messages are converted in chat providers\n\n## 0.31.0 (2025-12-03)\n\n- Fix OpenAI Responses provider not mapping `role=\"system\"` to `developer`\n- Improve the compatibility of OpenAI Responses and Anthropic providers against some third-party APIs\n\n## 0.30.0 (2025-12-03)\n\n- Serialize empty content as an empty list instead of `None`\n- Fix Kimi chat provider panicking when `stream` is `False`\n\n## 0.29.0 (2025-12-02)\n\n- Change `Message.content` field from `str | list[ContentPart]` to just `list[ContentPart]`\n- Add `Message.extract_text()` method to extract text content from message\n\n## 0.28.1 (2025-12-01)\n\n- Fix interleaved thinking for Kimi and OpenAILegacy chat providers\n\n## 0.28.0 (2025-11-28)\n\n- Support non-OpenAI models which do not accept `developer` role in system prompt in `OpenAIResponses` chat provider\n- Fix token usage for Anthropic chat provider\n- Fix `StepResult.tool_results()` cannot be called multiple times\n- Add `EchoChatProvider` to allow generate assistant responses by echoing back the user messages\n\n## 0.27.1 (2025-11-24)\n\n- Nothing\n\n## 0.27.0 (2025-11-24)\n\n- Fix function call ID in `GoogleGenAI` chat provider\n- Make `CallableTool2` not a `pydantic.BaseModel`\n- Introduce `ToolReturnValue` as the common base class of `ToolOk` and `ToolError`\n- Require `CallableTool` and `CallableTool2` to return `ToolReturnValue` instead of `ToolOk | ToolError`\n- Rename `ToolResult.result` to `ToolResult.return_value`\n\n## 0.26.2 (2025-11-20)\n\n- Better thinking level mapping in `GoogleGenAI` chat provider\n\n## 0.26.1 (2025-11-19)\n\n- Deref JSON schema in tool parameters to fix compatibility with some LLM providers\n\n## 0.26.0 (2025-11-19)\n\n- Fix thinking part in `Anthropic` provider's non-stream mode\n- Add `GoogleGenAI` chat provider\n\n## 0.25.1 (2025-11-18)\n\n- Catch httpx exceptions correctly in Kimi and OpenAI providers\n\n## 0.25.0 (2025-11-13)\n\n- Add `reasoning_key` argument to `OpenAILegacy` chat provider to specify the field for reasoning content in messages\n\n## 0.24.0 (2025-11-12)\n\n- Set default temperature settings for Kimi models based on model name\n\n## 0.23.0 (2025-11-10)\n\n- Change type of `ToolError.output` to `str | ContentPart | Sequence[ContentPart]`\n\n## 0.22.0 (2025-11-10)\n\n- Add `APIEmptyResponseError` for cases where the API returns an empty response\n- Add `GenerateResult` as the return type of `generate` function\n- Add `id: str | None` field to `GenerateResult` and `StepResult`\n"
  },
  {
    "path": "packages/kosong/LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "packages/kosong/NOTICE",
    "content": "Kosong\nCopyright 2025 Moonshot AI\n\nThis product includes software developed at\nMoonshot AI (https://www.moonshot.ai/)."
  },
  {
    "path": "packages/kosong/README.md",
    "content": "# Kosong\n\nKosong is an LLM abstraction layer designed for modern AI agent applications. It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you can build agents with ease and avoid vendor lock-in.\n\n> Kosong means \"empty\" in Malay and Indonesian.\n\n## Installation\n\nKosong requires Python 3.12 or higher. We recommend using uv as the package manager.\n\nInit your project with:\n\n```bash\nuv init --python 3.12  # or higher\n```\n\nThen add Kosong as a dependency:\n\n```bash\nuv add kosong\n```\n\nTo enable chat providers other than Kimi (e.g. Anthropic and Google Gemini), install the optional extra:\n\n```bash\nuv add 'kosong[contrib]'\n```\n\n## Examples\n\n### Simple chat completion\n\n```python\nimport asyncio\n\nimport kosong\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.message import Message\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    history = [\n        Message(role=\"user\", content=\"Who are you?\"),\n    ]\n\n    result = await kosong.generate(\n        chat_provider=kimi,\n        system_prompt=\"You are a helpful assistant.\",\n        tools=[],\n        history=history,\n    )\n    print(result.message)\n    print(result.usage)\n\n\nasyncio.run(main())\n```\n\n### Streaming output\n\n```python\nimport asyncio\n\nimport kosong\nfrom kosong.chat_provider import StreamedMessagePart\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.message import Message\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    history = [\n        Message(role=\"user\", content=\"Who are you?\"),\n    ]\n\n    def output(message_part: StreamedMessagePart):\n        print(message_part)\n\n    result = await kosong.generate(\n        chat_provider=kimi,\n        system_prompt=\"You are a helpful assistant.\",\n        tools=[],\n        history=history,\n        on_message_part=output,\n    )\n    print(result.message)\n    print(result.usage)\n\n\nasyncio.run(main())\n```\n\n### Tool calling with `kosong.step`\n\n```python\nimport asyncio\n\nfrom pydantic import BaseModel\n\nimport kosong\nfrom kosong import StepResult\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.message import Message\nfrom kosong.tooling import CallableTool2, ToolOk, ToolReturnValue\nfrom kosong.tooling.simple import SimpleToolset\n\n\nclass AddToolParams(BaseModel):\n    a: int\n    b: int\n\n\nclass AddTool(CallableTool2[AddToolParams]):\n    name: str = \"add\"\n    description: str = \"Add two integers.\"\n    params: type[AddToolParams] = AddToolParams\n\n    async def __call__(self, params: AddToolParams) -> ToolReturnValue:\n        return ToolOk(output=str(params.a + params.b))\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    toolset = SimpleToolset()\n    toolset += AddTool()\n\n    history = [\n        Message(role=\"user\", content=\"Please add 2 and 3 with the add tool.\"),\n    ]\n\n    result: StepResult = await kosong.step(\n        chat_provider=kimi,\n        system_prompt=\"You are a precise math tutor.\",\n        toolset=toolset,\n        history=history,\n    )\n    print(result.message)\n    print(await result.tool_results())\n\n\nasyncio.run(main())\n```\n\n## Builtin Demo\n\nKosong comes with a builtin demo agent that you can run locally. To start the demo, run:\n\n```sh\nexport KIMI_BASE_URL=\"https://api.moonshot.ai/v1\"\nexport KIMI_API_KEY=\"your_kimi_api_key\"\n\nuv run python -m kosong kimi --with-bash\n```\n\n## Development\n\nTo set up a development environment, clone the repository and install the dependencies:\n\n```bash\ngit clone https://github.com/MoonshotAI/kosong.git\ncd kosong\nuv sync --all-extras\n\nmake check  # run lint and type checks\nmake test   # run tests\nmake format # format code\n```\n"
  },
  {
    "path": "packages/kosong/pyproject.toml",
    "content": "[project]\nname = \"kosong\"\nversion = \"0.45.0\"\ndescription = \"The LLM abstraction layer for modern AI agent applications.\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\ndependencies = [\n    \"anthropic>=0.78.0\",\n    \"google-genai>=1.56.0\",\n    \"jsonschema>=4.25.1\",\n    \"loguru>=0.6.0,<0.8\",\n    \"openai>=2.14.0,<2.15.0\",\n    \"pydantic>=2.12.5\",\n    \"python-dotenv>=1.2.1\",\n    \"typing-extensions>=4.15.0\",\n    \"mcp>=1,<2\",\n]\n\n[project.optional-dependencies]\ncontrib = [\n    \"anthropic>=0.78.0\",\n    \"google-genai>=1.55.0\",\n]\n\n[dependency-groups]\ndev = [\n    \"pyright>=1.1.407\",\n    \"ty>=0.0.7\",\n    \"pytest>=9.0.2\",\n    \"pytest-asyncio>=1.3.0\",\n    \"respx>=0.22.0\",\n    \"ruff>=0.14.10\",\n    \"inline-snapshot[black]>=0.31.1\",\n    \"pdoc>=16.0.0\",\n]\n\n[build-system]\nrequires = [\"uv_build>=0.8.5,<0.10.0\"]\nbuild-backend = \"uv_build\"\n\n[tool.uv.build-backend]\nmodule-name = [\"kosong\"]\n\n[tool.ruff]\nline-length = 100\n\n[tool.ruff.format]\ndocstring-code-format = true\n\n[tool.ruff.lint]\nselect = [\n    \"E\",   # pycodestyle\n    \"F\",   # Pyflakes\n    \"UP\",  # pyupgrade\n    \"B\",   # flake8-bugbear\n    \"SIM\", # flake8-simplify\n    \"I\",   # isort\n]\n\n[tool.pyright]\ntypeCheckingMode = \"strict\"\npythonVersion = \"3.14\"\ninclude = [\n    \"src/**/*.py\",\n    \"tests/**/*.py\",\n]\n\n[tool.ty.environment]\npython-version = \"3.14\"\n\n[tool.ty.src]\ninclude = [\n    \"src/**/*.py\",\n    \"tests/**/*.py\",\n]\n"
  },
  {
    "path": "packages/kosong/src/kosong/__init__.py",
    "content": "\"\"\"\nKosong is an LLM abstraction layer designed for modern AI agent applications.\nIt unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you\ncan build agents with ease and avoid vendor lock-in.\n\nKey features:\n\n- `kosong.generate` creates a completion stream and merges streamed message parts (including\n  content and tool calls) from any `ChatProvider` into a complete `Message` plus optional\n  `TokenUsage`.\n- `kosong.step` layers tool dispatch (`Tool`, `Toolset`, `SimpleToolset`) over `generate`,\n  exposing `StepResult` with awaited tool outputs and streaming callbacks.\n- Message structures and tool abstractions live under `kosong.message` and `kosong.tooling`.\n\nExample:\n\n```python\nimport asyncio\n\nfrom pydantic import BaseModel\n\nimport kosong\nfrom kosong import StepResult\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.message import Message\nfrom kosong.tooling import CallableTool2, ToolOk, ToolReturnValue\nfrom kosong.tooling.simple import SimpleToolset\n\n\nclass AddToolParams(BaseModel):\n    a: int\n    b: int\n\n\nclass AddTool(CallableTool2[AddToolParams]):\n    name: str = \"add\"\n    description: str = \"Add two integers.\"\n    params: type[AddToolParams] = AddToolParams\n\n    async def __call__(self, params: AddToolParams) -> ToolReturnValue:\n        return ToolOk(output=str(params.a + params.b))\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    toolset = SimpleToolset()\n    toolset += AddTool()\n\n    history = [\n        Message(role=\"user\", content=\"Please add 2 and 3 with the add tool.\"),\n    ]\n\n    result: StepResult = await kosong.step(\n        chat_provider=kimi,\n        system_prompt=\"You are a precise math tutor.\",\n        toolset=toolset,\n        history=history,\n    )\n    print(result.message)\n    print(await result.tool_results())\n\n\nasyncio.run(main())\n```\n\"\"\"\n\nimport asyncio\nfrom collections.abc import Callable, Sequence\nfrom dataclasses import dataclass\n\nfrom loguru import logger\n\nfrom kosong._generate import GenerateResult, generate\nfrom kosong.chat_provider import ChatProvider, ChatProviderError, StreamedMessagePart, TokenUsage\nfrom kosong.message import Message, ToolCall\nfrom kosong.tooling import ToolResult, ToolResultFuture, Toolset\nfrom kosong.utils.aio import Callback\n\n# Explicitly import submodules\nfrom . import chat_provider, contrib, message, tooling, utils\n\nlogger.disable(\"kosong\")\n\n__all__ = [\n    # submodules\n    \"chat_provider\",\n    \"tooling\",\n    \"message\",\n    \"utils\",\n    \"contrib\",\n    # classes and functions\n    \"generate\",\n    \"GenerateResult\",\n    \"step\",\n    \"StepResult\",\n]\n\n\nasync def step(\n    chat_provider: ChatProvider,\n    system_prompt: str,\n    toolset: Toolset,\n    history: Sequence[Message],\n    *,\n    on_message_part: Callback[[StreamedMessagePart], None] | None = None,\n    on_tool_result: Callable[[ToolResult], None] | None = None,\n) -> \"StepResult\":\n    \"\"\"\n    Run one agent \"step\". In one step, the function generates LLM response based on the given\n    context for exactly one time. All new message parts will be streamed to `on_message_part` in\n    real-time if provided. Tool calls will be handled by `toolset`. The generated message will be\n    returned in a `StepResult`. Depending on the toolset implementation, the tool calls may be\n    handled asynchronously and the results need to be fetched with `await result.tool_results()`.\n\n    The message history will NOT be modified in this function.\n\n    The token usage will be returned in the `StepResult` if available.\n\n    Raises:\n        APIConnectionError: If the API connection fails.\n        APITimeoutError: If the API request times out.\n        APIStatusError: If the API returns a status code of 4xx or 5xx.\n        APIEmptyResponseError: If the API returns an empty response.\n        ChatProviderError: If any other recognized chat provider error occurs.\n        asyncio.CancelledError: If the step is cancelled.\n    \"\"\"\n\n    tool_calls: list[ToolCall] = []\n    tool_result_futures: dict[str, ToolResultFuture] = {}\n\n    def future_done_callback(future: ToolResultFuture):\n        if on_tool_result:\n            try:\n                result = future.result()\n                on_tool_result(result)\n            except asyncio.CancelledError:\n                return\n\n    async def on_tool_call(tool_call: ToolCall):\n        tool_calls.append(tool_call)\n        result = toolset.handle(tool_call)\n\n        if isinstance(result, ToolResult):\n            future = ToolResultFuture()\n            future.add_done_callback(future_done_callback)\n            future.set_result(result)\n            tool_result_futures[tool_call.id] = future\n        else:\n            result.add_done_callback(future_done_callback)\n            tool_result_futures[tool_call.id] = result\n\n    try:\n        result = await generate(\n            chat_provider,\n            system_prompt,\n            toolset.tools,\n            history,\n            on_message_part=on_message_part,\n            on_tool_call=on_tool_call,\n        )\n    except (ChatProviderError, asyncio.CancelledError):\n        # cancel all the futures to avoid hanging tasks\n        for future in tool_result_futures.values():\n            future.remove_done_callback(future_done_callback)\n            future.cancel()\n        await asyncio.gather(*tool_result_futures.values(), return_exceptions=True)\n        raise\n\n    return StepResult(\n        result.id,\n        result.message,\n        result.usage,\n        tool_calls,\n        tool_result_futures,\n    )\n\n\n@dataclass(frozen=True, slots=True)\nclass StepResult:\n    id: str | None\n    \"\"\"The ID of the generated message.\"\"\"\n\n    message: Message\n    \"\"\"The message generated in this step.\"\"\"\n\n    usage: TokenUsage | None\n    \"\"\"The token usage in this step.\"\"\"\n\n    tool_calls: list[ToolCall]\n    \"\"\"All the tool calls generated in this step.\"\"\"\n\n    _tool_result_futures: dict[str, ToolResultFuture]\n    \"\"\"@private The futures of the results of the spawned tool calls.\"\"\"\n\n    async def tool_results(self) -> list[ToolResult]:\n        \"\"\"All the tool results returned by corresponding tool calls.\"\"\"\n        if not self._tool_result_futures:\n            return []\n\n        try:\n            results: list[ToolResult] = []\n            for tool_call in self.tool_calls:\n                future = self._tool_result_futures[tool_call.id]\n                result = await future\n                results.append(result)\n            return results\n        finally:\n            # one exception should cancel all the futures to avoid hanging tasks\n            for future in self._tool_result_futures.values():\n                future.cancel()\n            await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)\n"
  },
  {
    "path": "packages/kosong/src/kosong/__main__.py",
    "content": "import asyncio\nimport os\nimport textwrap\nfrom argparse import ArgumentParser\nfrom typing import Literal\n\nfrom dotenv import load_dotenv\nfrom pydantic import BaseModel\n\nimport kosong\nfrom kosong.chat_provider import ChatProvider\nfrom kosong.message import Message\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolResult, ToolReturnValue, Toolset\nfrom kosong.tooling.simple import SimpleToolset\n\n\nclass BashToolParams(BaseModel):\n    command: str\n    \"\"\"The bash command to execute.\"\"\"\n\n\nclass BashTool(CallableTool2[BashToolParams]):\n    name: str = \"Bash\"\n    description: str = \"Execute a bash command.\"\n    params: type[BashToolParams] = BashToolParams\n\n    async def __call__(self, params: BashToolParams) -> ToolReturnValue:\n        proc = await asyncio.create_subprocess_shell(\n            params.command,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await proc.communicate()\n        stdout_text = stdout.decode().strip()\n        stderr_text = stderr.decode().strip()\n        output_text = \"\\n\".join(filter(None, [stdout_text, stderr_text]))\n        if proc.returncode == 0:\n            return ToolOk(output=output_text)\n        else:\n            return ToolError(\n                output=output_text,\n                message=f\"Command failed with exit code {proc.returncode}\",\n                brief=\"Bash command failed.\",\n            )\n\n\nasync def agent_loop(chat_provider: ChatProvider, toolset: Toolset):\n    system_prompt = \"You are a helpful assistant.\"\n    history: list[Message] = []\n\n    while True:\n        user_input = input(\"You: \").strip()\n        if not user_input:\n            continue\n        if user_input.lower() in {\"exit\", \"quit\"}:\n            break\n\n        history.append(Message(role=\"user\", content=user_input))\n\n        while True:\n            result = await kosong.step(\n                chat_provider=chat_provider,\n                system_prompt=system_prompt,\n                toolset=toolset,\n                history=history,\n            )\n\n            tool_results = await result.tool_results()\n\n            assistant_message = result.message\n            tool_messages = [tool_result_to_message(tr) for tr in tool_results]\n\n            history.append(assistant_message)\n            history.extend(tool_messages)\n\n            if s := assistant_message.extract_text():\n                print(\"Assistant:\\n\", textwrap.indent(s, \"  \"))\n            for tool_msg in tool_messages:\n                if s := tool_msg.extract_text():\n                    print(\"Tool:\\n\", textwrap.indent(s, \"  \"))\n\n            if not result.tool_calls:\n                break\n\n\ndef tool_result_to_message(result: ToolResult) -> Message:\n    return Message(\n        role=\"tool\",\n        tool_call_id=result.tool_call_id,\n        content=result.return_value.output,\n    )\n\n\nasync def main():\n    load_dotenv()\n\n    parser = ArgumentParser(description=\"A simple agent.\")\n    parser.add_argument(\n        \"provider\",\n        choices=[\"kimi\", \"openai\", \"anthropic\", \"google\"],\n        help=\"The chat provider to use.\",\n    )\n    parser.add_argument(\n        \"--with-bash\",\n        action=\"store_true\",\n        help=\"Enable Bash tool.\",\n    )\n    args = parser.parse_args()\n\n    provider: Literal[\"kimi\", \"openai\", \"anthropic\", \"google\"] = args.provider\n    with_bash: bool = args.with_bash\n\n    provider_upper = provider.upper()\n    base_url = os.getenv(f\"{provider_upper}_BASE_URL\")\n    api_key = os.getenv(f\"{provider_upper}_API_KEY\")\n    model = os.getenv(f\"{provider_upper}_MODEL_NAME\")\n\n    match provider:\n        case \"kimi\":\n            from kosong.chat_provider.kimi import Kimi\n\n            base_url = base_url or \"https://api.moonshot.ai/v1\"\n            assert api_key is not None, \"Expect KIMI_API_KEY environment variable\"\n            model = model or \"kimi-k2-turbo-preview\"\n\n            chat_provider = Kimi(base_url=base_url, api_key=api_key, model=model)\n        case \"openai\":\n            from kosong.contrib.chat_provider.openai_responses import OpenAIResponses\n\n            base_url = base_url or \"https://api.openai.com/v1\"\n            assert api_key is not None, \"Expect OPENAI_API_KEY environment variable\"\n            model = model or \"gpt-5\"\n\n            chat_provider = OpenAIResponses(base_url=base_url, api_key=api_key, model=model)\n        case \"anthropic\":\n            from kosong.contrib.chat_provider.anthropic import Anthropic\n\n            base_url = base_url or \"https://api.anthropic.com\"\n            assert api_key is not None, \"Expect ANTHROPIC_API_KEY environment variable\"\n            model = model or \"claude-sonnet-4-5\"\n\n            chat_provider = Anthropic(\n                base_url=base_url, api_key=api_key, model=model, default_max_tokens=50_000\n            )\n        case \"google\":\n            from kosong.contrib.chat_provider.google_genai import GoogleGenAI\n\n            api_key = api_key or os.getenv(\"GEMINI_API_KEY\")\n            assert api_key is not None, (\n                \"Expect GOOGLE_API_KEY or GEMINI_API_KEY environment variable\"\n            )\n            model = model or \"gemini-3-pro-preview\"\n            chat_provider = GoogleGenAI(\n                base_url=base_url, api_key=api_key, model=model\n            ).with_thinking(\"high\")\n\n    toolset = SimpleToolset()\n    if with_bash:\n        toolset += BashTool()\n\n    await agent_loop(chat_provider, toolset)\n\n\nasyncio.run(main())\n"
  },
  {
    "path": "packages/kosong/src/kosong/_generate.py",
    "content": "from collections.abc import Sequence\nfrom dataclasses import dataclass\n\nfrom loguru import logger\n\nfrom kosong.chat_provider import (\n    APIEmptyResponseError,\n    ChatProvider,\n    StreamedMessagePart,\n    TokenUsage,\n)\nfrom kosong.message import ContentPart, Message, ToolCall\nfrom kosong.tooling import Tool\nfrom kosong.utils.aio import Callback, callback\n\n\nasync def generate(\n    chat_provider: ChatProvider,\n    system_prompt: str,\n    tools: Sequence[Tool],\n    history: Sequence[Message],\n    *,\n    on_message_part: Callback[[StreamedMessagePart], None] | None = None,\n    on_tool_call: Callback[[ToolCall], None] | None = None,\n) -> \"GenerateResult\":\n    \"\"\"\n    Generate one message based on the given context.\n    Parts of the message will be streamed to the specified callbacks if provided.\n\n    Args:\n        chat_provider: The chat provider to use for generation.\n        system_prompt: The system prompt to use for generation.\n        tools: The tools available for the model to call.\n        history: The message history to use for generation.\n        on_message_part: An optional callback to be called for each raw message part.\n        on_tool_call: An optional callback to be called for each complete tool call.\n\n    Returns:\n        A tuple of the generated message and the token usage (if available).\n        All parts in the message are guaranteed to be complete and merged as much as possible.\n\n    Raises:\n        APIConnectionError: If the API connection fails.\n        APITimeoutError: If the API request times out.\n        APIStatusError: If the API returns a status code of 4xx or 5xx.\n        APIEmptyResponseError: If the API returns an empty response.\n        ChatProviderError: If any other recognized chat provider error occurs.\n    \"\"\"\n    message = Message(role=\"assistant\", content=[])\n    pending_part: StreamedMessagePart | None = None  # message part that is currently incomplete\n\n    logger.trace(\"Generating with history: {history}\", history=history)\n    stream = await chat_provider.generate(system_prompt, tools, history)\n    async for part in stream:\n        logger.trace(\"Received part: {part}\", part=part)\n        if on_message_part:\n            await callback(on_message_part, part.model_copy(deep=True))\n\n        if pending_part is None:\n            pending_part = part\n        elif not pending_part.merge_in_place(part):  # try merge into the pending part\n            # unmergeable part must push the pending part to the buffer\n            _message_append(message, pending_part)\n            if isinstance(pending_part, ToolCall) and on_tool_call:\n                await callback(on_tool_call, pending_part)\n            pending_part = part\n\n    # end of message\n    if pending_part is not None:\n        _message_append(message, pending_part)\n        if isinstance(pending_part, ToolCall) and on_tool_call:\n            await callback(on_tool_call, pending_part)\n\n    if not message.content and not message.tool_calls:\n        raise APIEmptyResponseError(\"The API returned an empty response.\")\n\n    return GenerateResult(\n        id=stream.id,\n        message=message,\n        usage=stream.usage,\n    )\n\n\n@dataclass(frozen=True, slots=True)\nclass GenerateResult:\n    \"\"\"The result of a generation.\"\"\"\n\n    id: str | None\n    \"\"\"The ID of the generated message.\"\"\"\n    message: Message\n    \"\"\"The generated message.\"\"\"\n    usage: TokenUsage | None\n    \"\"\"The token usage of the generated message.\"\"\"\n\n\ndef _message_append(message: Message, part: StreamedMessagePart) -> None:\n    match part:\n        case ContentPart():\n            message.content.append(part)\n        case ToolCall():\n            if message.tool_calls is None:\n                message.tool_calls = []\n            message.tool_calls.append(part)\n        case _:\n            # may be an orphaned `ToolCallPart`\n            return\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/__init__.py",
    "content": "from collections.abc import AsyncIterator, Sequence\nfrom typing import Literal, Protocol, Self, runtime_checkable\n\nfrom pydantic import BaseModel\n\nfrom kosong.message import ContentPart, Message, ToolCall, ToolCallPart\nfrom kosong.tooling import Tool\n\n\n@runtime_checkable\nclass ChatProvider(Protocol):\n    \"\"\"The interface of chat providers.\"\"\"\n\n    name: str\n    \"\"\"\n    The name of the chat provider.\n    \"\"\"\n\n    @property\n    def model_name(self) -> str:\n        \"\"\"\n        The name of the model to use.\n        \"\"\"\n        ...\n\n    @property\n    def thinking_effort(self) -> \"ThinkingEffort | None\":\n        \"\"\"\n        The current thinking effort level. Returns None if not explicitly set.\n        \"\"\"\n        ...\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> \"StreamedMessage\":\n        \"\"\"\n        Generate a new message based on the given system prompt, tools, and history.\n\n        Raises:\n            APIConnectionError: If the API connection fails.\n            APITimeoutError: If the API request times out.\n            APIStatusError: If the API returns a status code of 4xx or 5xx.\n            ChatProviderError: If any other recognized chat provider error occurs.\n        \"\"\"\n        ...\n\n    def with_thinking(self, effort: \"ThinkingEffort\") -> Self:\n        \"\"\"\n        Return a copy of self configured with the given thinking effort.\n        If the chat provider does not support thinking, simply return a copy of self.\n        \"\"\"\n        ...\n\n\n@runtime_checkable\nclass RetryableChatProvider(Protocol):\n    \"\"\"Optional interface for providers that can recover from retryable transport errors.\"\"\"\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        \"\"\"\n        Try to recover provider transport state after a retryable error.\n\n        Returns:\n            bool: Whether recovery action was performed.\n        \"\"\"\n        ...\n\n\ntype StreamedMessagePart = ContentPart | ToolCall | ToolCallPart\n\n\n@runtime_checkable\nclass StreamedMessage(Protocol):\n    \"\"\"The interface of streamed messages.\"\"\"\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        \"\"\"Create an async iterator from the stream.\"\"\"\n        ...\n\n    @property\n    def id(self) -> str | None:\n        \"\"\"The ID of the streamed message.\"\"\"\n        ...\n\n    @property\n    def usage(self) -> \"TokenUsage | None\":\n        \"\"\"The token usage of the streamed message.\"\"\"\n        ...\n\n\nclass TokenUsage(BaseModel):\n    \"\"\"Token usage statistics.\"\"\"\n\n    input_other: int\n    \"\"\"Input tokens excluding `input_cache_read` and `input_cache_creation`.\"\"\"\n    output: int\n    \"\"\"Total output tokens.\"\"\"\n    input_cache_read: int = 0\n    \"\"\"Cached input tokens.\"\"\"\n    input_cache_creation: int = 0\n    \"\"\"Input tokens used for cache creation. For now, only Anthropic API supports this.\"\"\"\n\n    @property\n    def total(self) -> int:\n        \"\"\"Total tokens used, including input and output tokens.\"\"\"\n        return self.input + self.output\n\n    @property\n    def input(self) -> int:\n        \"\"\"Total input tokens, including cached and uncached tokens.\"\"\"\n        return self.input_other + self.input_cache_read + self.input_cache_creation\n\n\ntype ThinkingEffort = Literal[\"off\", \"low\", \"medium\", \"high\"]\n\"\"\"The effort level for thinking.\"\"\"\n\n\nclass ChatProviderError(Exception):\n    \"\"\"The error raised by a chat provider.\"\"\"\n\n    def __init__(self, message: str):\n        super().__init__(message)\n\n\nclass APIConnectionError(ChatProviderError):\n    \"\"\"The error raised when the API connection fails.\"\"\"\n\n\nclass APITimeoutError(ChatProviderError):\n    \"\"\"The error raised when the API request times out.\"\"\"\n\n\nclass APIStatusError(ChatProviderError):\n    \"\"\"The error raised when the API returns a status code of 4xx or 5xx.\"\"\"\n\n    status_code: int\n\n    def __init__(self, status_code: int, message: str):\n        super().__init__(message)\n        self.status_code = status_code\n\n\nclass APIEmptyResponseError(ChatProviderError):\n    \"\"\"The error raised when the API returns an empty response.\"\"\"\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/chaos.py",
    "content": "import json\nimport os\nimport random\nfrom collections.abc import AsyncIterator, Sequence\nfrom typing import TYPE_CHECKING, Any\n\nimport httpx\nfrom pydantic import BaseModel\n\nfrom kosong.chat_provider import (\n    ChatProvider,\n    ChatProviderError,\n    RetryableChatProvider,\n    StreamedMessage,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.message import Message, ToolCall, ToolCallPart\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(\n        chaos: \"ChaosChatProvider\",\n    ):\n        _: ChatProvider = chaos\n        _: RetryableChatProvider = chaos\n\n\nclass ChaosConfig(BaseModel):\n    \"\"\"Configuration for chaos provider.\"\"\"\n\n    error_probability: float = 0.3\n    error_types: list[int] = [429, 500, 502, 503]\n    retry_after: int = 2\n    seed: int | None = None\n    corrupt_tool_call_probability: float = 0.1\n\n    @classmethod\n    def from_env(cls) -> \"ChaosConfig\":\n        \"\"\"Create config from environment variables.\"\"\"\n        seed_str = os.getenv(\"CHAOS_SEED\")\n        return cls(\n            error_probability=float(os.getenv(\"CHAOS_ERROR_PROBABILITY\", \"0.3\")),\n            error_types=[\n                int(x.strip()) for x in os.getenv(\"CHAOS_ERROR_TYPES\", \"429,500,502,503\").split(\",\")\n            ],\n            retry_after=int(os.getenv(\"CHAOS_RETRY_AFTER\", \"2\")),\n            seed=int(seed_str) if seed_str else None,\n            corrupt_tool_call_probability=float(\n                os.getenv(\"CHAOS_CORRUPT_TOOL_CALL_PROBABILITY\", \"0.1\")\n            ),\n        )\n\n\nclass ChaosTransport(httpx.AsyncBaseTransport):\n    \"\"\"HTTP transport that randomly injects errors.\"\"\"\n\n    def __init__(self, wrapped_transport: httpx.AsyncBaseTransport, config: ChaosConfig):\n        self._wrapped = wrapped_transport\n        self._config = config\n        self._rng = random.Random(config.seed)\n\n    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:\n        if self._should_inject_error():\n            error_code = self._rng.choice(self._config.error_types)\n            return self._create_error_response(request, error_code)\n\n        return await self._wrapped.handle_async_request(request)\n\n    def _should_inject_error(self) -> bool:\n        return self._rng.random() < self._config.error_probability\n\n    def _create_error_response(self, request: httpx.Request, status_code: int) -> httpx.Response:\n        error_messages = {\n            429: {\"error\": {\"code\": \"rate_limit_exceeded\", \"message\": \"Rate limit exceeded\"}},\n            500: {\"error\": {\"code\": \"internal_error\", \"message\": \"Internal server error\"}},\n            502: {\"error\": {\"code\": \"bad_gateway\", \"message\": \"Bad gateway\"}},\n            503: {\n                \"error\": {\n                    \"code\": \"service_unavailable\",\n                    \"message\": \"Service temporarily unavailable\",\n                }\n            },\n        }\n\n        content = json.dumps(\n            error_messages.get(status_code, {\"error\": {\"message\": \"Unknown error\"}})\n        )\n        headers = {\"content-type\": \"application/json\"}\n\n        if status_code == 429:\n            headers[\"retry-after\"] = str(self._config.retry_after)\n\n        return httpx.Response(\n            status_code=status_code,\n            headers=headers,\n            content=content.encode(),\n            request=request,\n        )\n\n\nclass ChaosChatProvider:\n    \"\"\"Wrap a chat provider and inject chaos into its HTTP transport and streamed tool calls.\"\"\"\n\n    def __init__(self, provider: ChatProvider, chaos_config: ChaosConfig | None = None):\n        self._provider = provider\n        self._chaos_config = chaos_config or ChaosConfig.from_env()\n        self.name: str = provider.name\n        self._monkey_patch_client()\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> \"ChaosStreamedMessage\":\n        base_stream = await self._provider.generate(system_prompt, tools, history)\n        return ChaosStreamedMessage(base_stream, self._chaos_config)\n\n    def _monkey_patch_client(self):\n        \"\"\"\n        Inject chaos transport into providers backed by httpx AsyncBaseTransport.\n\n        Supported today (explicit list):\n        - Kimi\n        - OpenAILegacy\n        - Anthropic\n\n        The provider must expose an AsyncOpenAI/Anthropic/httpx client via `.client`,\n        `.client._client`, or `._client`. Providers without an accessible httpx transport\n        will raise ChatProviderError.\n        \"\"\"\n        transport_owner = self._find_transport_owner()\n        transport = getattr(transport_owner, \"_transport\", None)\n        if not isinstance(transport, httpx.AsyncBaseTransport):\n            raise ChatProviderError(\n                \"ChaosChatProvider only supports providers backed by httpx.AsyncBaseTransport\"\n            )\n\n        chaos_transport = ChaosTransport(transport, self._chaos_config)\n        transport_owner._transport = chaos_transport  # type: ignore[reportPrivateUsage]\n\n    def _find_transport_owner(self) -> Any:\n        \"\"\"Locate the object that owns the httpx transport.\"\"\"\n        candidates: list[Any] = []\n\n        client = getattr(self._provider, \"client\", None)\n        if client is not None:\n            candidates.append(client)\n            raw_client = getattr(client, \"_client\", None)\n            if raw_client is not None:\n                candidates.append(raw_client)\n\n        inner_client = getattr(self._provider, \"_client\", None)\n        if inner_client is not None:\n            candidates.append(inner_client)\n\n        for owner in candidates:\n            if hasattr(owner, \"_transport\"):\n                return owner\n            nested = getattr(owner, \"_client\", None)\n            if nested and hasattr(nested, \"_transport\"):\n                return nested\n\n        raise ChatProviderError(\n            \"ChaosChatProvider only supports providers backed by httpx.AsyncBaseTransport\"\n        )\n\n    @property\n    def model_name(self) -> str:\n        if (\n            self._chaos_config.error_probability > 0\n            or self._chaos_config.corrupt_tool_call_probability > 0\n        ):\n            return f\"chaos({self._provider.model_name})\"\n        return self._provider.model_name\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return self._provider.thinking_effort\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        if not isinstance(self._provider, RetryableChatProvider):\n            return False\n        recovered = self._provider.on_retryable_error(error)\n        if recovered:\n            self._monkey_patch_client()\n        return recovered\n\n    def with_thinking(self, effort: ThinkingEffort) -> \"ChaosChatProvider\":\n        return ChaosChatProvider(self._provider.with_thinking(effort), self._chaos_config)\n\n    @classmethod\n    def for_kimi(\n        cls, chaos_config: ChaosConfig | None = None, **kwargs: Any\n    ) -> \"ChaosChatProvider\":\n        \"\"\"Helper to wrap a Kimi provider without changing caller sites.\"\"\"\n        from kosong.chat_provider.kimi import Kimi\n\n        return cls(Kimi(**kwargs), chaos_config=chaos_config)\n\n\nclass ChaosStreamedMessage:\n    \"\"\"Stream wrapper that injects chaos into tool calls.\"\"\"\n\n    def __init__(self, wrapped: StreamedMessage, config: ChaosConfig):\n        self._wrapped = wrapped\n        self._config = config\n        self._rng = random.Random(config.seed)\n        self._iterator = wrapped.__aiter__()\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        part = await self._iterator.__anext__()\n        return self._maybe_corrupt_tool_call(part)\n\n    @property\n    def id(self) -> str | None:\n        return self._wrapped.id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        return self._wrapped.usage\n\n    def _should_corrupt_tool_call(self) -> bool:\n        probability = self._config.corrupt_tool_call_probability\n        return probability > 0 and self._rng.random() < probability\n\n    def _maybe_corrupt_tool_call(self, part: StreamedMessagePart) -> StreamedMessagePart:\n        if not self._should_corrupt_tool_call():\n            return part\n        if isinstance(part, ToolCall):\n            return self._corrupt_tool_call(part)\n        if isinstance(part, ToolCallPart):\n            return self._corrupt_tool_call_part(part)\n        return part\n\n    def _corrupt_tool_call(self, tool_call: ToolCall) -> StreamedMessagePart:\n        arguments = tool_call.function.arguments\n        if arguments is None or not arguments.endswith(\"}\"):\n            return tool_call\n        corrupted = tool_call.model_copy(deep=True)\n        corrupted.function.arguments = arguments[:-1]\n        return corrupted\n\n    def _corrupt_tool_call_part(self, part: ToolCallPart) -> StreamedMessagePart:\n        arguments = part.arguments_part\n        if arguments is None or not arguments.endswith(\"}\"):\n            return part\n        corrupted = part.model_copy(deep=True)\n        corrupted.arguments_part = arguments[:-1]\n        return corrupted\n\n\nif __name__ == \"__main__\":\n\n    async def _dev_main_anthropic():\n        from dotenv import load_dotenv\n\n        from kosong.contrib.chat_provider.anthropic import Anthropic\n        from kosong.message import Message, TextPart\n\n        load_dotenv()\n\n        provider = Anthropic(\n            model=\"claude-3-5-sonnet-latest\",\n            api_key=os.getenv(\"ANTHROPIC_API_KEY\"),\n            default_max_tokens=64,\n            stream=True,\n        )\n        chat = ChaosChatProvider(\n            provider,\n            ChaosConfig(\n                error_probability=0.0,\n                corrupt_tool_call_probability=0.2,\n                seed=42,\n            ),\n        )\n        history = [Message(role=\"user\", content=[TextPart(text=\"Say hello briefly.\")])]\n        stream = await chat.generate(system_prompt=\"\", tools=[], history=history)\n        async for part in stream:\n            print(part.model_dump(exclude_none=True))\n        print(\"id:\", stream.id)\n        print(\"usage:\", stream.usage)\n\n    import asyncio\n\n    asyncio.run(_dev_main_anthropic())\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/echo/__init__.py",
    "content": "from .echo import EchoChatProvider, EchoStreamedMessage\nfrom .scripted_echo import ScriptedEchoChatProvider, ScriptedEchoStreamedMessage\n\n__all__ = [\n    \"EchoChatProvider\",\n    \"EchoStreamedMessage\",\n    \"ScriptedEchoChatProvider\",\n    \"ScriptedEchoStreamedMessage\",\n]\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/echo/dsl.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom typing import Any, cast\n\nfrom kosong.chat_provider import ChatProviderError, StreamedMessagePart, TokenUsage\nfrom kosong.message import (\n    AudioURLPart,\n    ImageURLPart,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n    VideoURLPart,\n)\n\n\ndef parse_echo_script(\n    script: str,\n) -> tuple[list[StreamedMessagePart], str | None, TokenUsage | None]:\n    parts: list[StreamedMessagePart] = []\n    message_id: str | None = None\n    usage: TokenUsage | None = None\n\n    for lineno, raw_line in enumerate(script.splitlines(), start=1):\n        line = raw_line.strip()\n        if not line or line.startswith(\"#\") or line.startswith(\"```\"):\n            continue\n        if line.lower() == \"echo\":\n            continue\n        key, sep, payload = line.partition(\":\")\n        if not sep:\n            raise ChatProviderError(f\"Invalid echo DSL at line {lineno}: {raw_line!r}\")\n\n        kind = key.strip().lower()\n        payload = payload[1:] if payload.startswith(\" \") else payload\n        if kind == \"id\":\n            message_id = _strip_quotes(payload.strip())\n            continue\n        if kind == \"usage\":\n            usage = _parse_usage(payload)\n            continue\n\n        part = _parse_part(kind, payload, lineno, raw_line)\n        parts.append(part)\n\n    return parts, message_id, usage\n\n\ndef _parse_part(kind: str, payload: str, lineno: int, raw_line: str) -> StreamedMessagePart:\n    match kind:\n        case \"text\":\n            return TextPart(text=_strip_quotes(payload))\n        case \"think\":\n            return ThinkPart(think=_strip_quotes(payload))\n        case \"image_url\":\n            url, image_id = _parse_url_payload(payload, kind)\n            return ImageURLPart(image_url=ImageURLPart.ImageURL(url=url, id=image_id))\n        case \"audio_url\":\n            url, audio_id = _parse_url_payload(payload, kind)\n            return AudioURLPart(audio_url=AudioURLPart.AudioURL(url=url, id=audio_id))\n        case \"video_url\":\n            url, video_id = _parse_url_payload(payload, kind)\n            return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url, id=video_id))\n        case \"tool_call\":\n            return _parse_tool_call(payload, lineno, raw_line)\n        case \"tool_call_part\":\n            return _parse_tool_call_part(payload)\n        case _:\n            raise ChatProviderError(\n                f\"Unknown echo DSL kind '{kind}' at line {lineno}: {raw_line!r}\"\n            )\n\n\ndef _parse_usage(payload: str) -> TokenUsage:\n    mapping = _parse_mapping(payload, context=\"usage\")\n\n    def _int_value(key: str) -> int:\n        value = mapping.get(key, 0)\n        try:\n            return int(value)\n        except (TypeError, ValueError):\n            raise ChatProviderError(\n                f\"Usage field '{key}' must be an integer, got {value!r}\"\n            ) from None\n\n    return TokenUsage(\n        input_other=_int_value(\"input_other\"),\n        output=_int_value(\"output\"),\n        input_cache_read=_int_value(\"input_cache_read\"),\n        input_cache_creation=_int_value(\"input_cache_creation\"),\n    )\n\n\ndef _parse_url_payload(payload: str, kind: str) -> tuple[str, str | None]:\n    value = _parse_value(payload)\n    if isinstance(value, dict):\n        mapping = cast(dict[str, Any], value)\n        url = mapping.get(\"url\")\n        if not isinstance(url, str):\n            raise ChatProviderError(f\"{kind} requires a url field, got {mapping!r}\")\n        content_id = mapping.get(\"id\")\n        if content_id is not None and not isinstance(content_id, str):\n            raise ChatProviderError(f\"{kind} id must be a string when provided.\")\n        return url, content_id\n    if not isinstance(value, str):\n        raise ChatProviderError(f\"{kind} expects url string or object, got {value!r}\")\n    return value, None\n\n\ndef _parse_tool_call(payload: str, lineno: int, raw_line: str) -> ToolCall:\n    mapping = _parse_mapping(payload, context=\"tool_call\")\n    function = mapping.get(\"function\") if isinstance(mapping.get(\"function\"), dict) else None\n\n    tool_call_id = mapping.get(\"id\")\n    name = mapping.get(\"name\") or (function.get(\"name\") if function else None)\n    arguments = mapping.get(\"arguments\")\n    extras = mapping.get(\"extras\")\n\n    if function:\n        if arguments is None:\n            arguments = function.get(\"arguments\")\n        if extras is None:\n            extras = function.get(\"extras\")\n\n    if not isinstance(tool_call_id, str) or not isinstance(name, str):\n        raise ChatProviderError(\n            f\"tool_call requires string id and name at line {lineno}: {raw_line!r}\"\n        )\n\n    if arguments is not None and not isinstance(arguments, str):\n        raise ChatProviderError(\n            f\"tool_call.arguments must be a string at line {lineno}, got {type(arguments).__name__}\"\n        )\n\n    return ToolCall(\n        id=tool_call_id,\n        function=ToolCall.FunctionBody(name=name, arguments=arguments),\n        extras=cast(dict[str, Any], extras) if isinstance(extras, dict) else None,\n    )\n\n\ndef _parse_tool_call_part(payload: str) -> ToolCallPart:\n    value = _parse_value(payload)\n    if isinstance(value, dict):\n        value = cast(dict[str, Any], value)\n        arguments_part: Any | None = value.get(\"arguments_part\")\n    else:\n        arguments_part = value\n    if isinstance(arguments_part, (dict, list)):\n        arguments_part = json.dumps(arguments_part, separators=(\",\", \":\"))\n    return ToolCallPart(arguments_part=None if arguments_part in (None, \"\") else arguments_part)\n\n\ndef _parse_mapping(raw: str, *, context: str) -> dict[str, Any]:\n    raw = raw.strip()\n    try:\n        loaded = json.loads(raw)\n    except json.JSONDecodeError:\n        loaded = None\n    if isinstance(loaded, dict):\n        return cast(dict[str, Any], loaded)\n    if loaded is not None:\n        raise ChatProviderError(f\"{context} payload must be an object, got {loaded!r}\")\n\n    mapping: dict[str, Any] = {}\n    for token in raw.replace(\",\", \" \").split():\n        if not token:\n            continue\n        if \"=\" not in token:\n            raise ChatProviderError(f\"Invalid token '{token}' in {context} payload.\")\n        key, value = token.split(\"=\", 1)\n        mapping[key.strip()] = _parse_value(value.strip())\n\n    if not mapping:\n        raise ChatProviderError(f\"{context} payload cannot be empty.\")\n    return mapping\n\n\ndef _parse_value(raw: str) -> Any:\n    raw = raw.strip()\n    if not raw:\n        return None\n    lowered = raw.lower()\n    if lowered in {\"null\", \"none\"}:\n        return None\n    try:\n        return json.loads(raw)\n    except json.JSONDecodeError:\n        return _strip_quotes(raw)\n\n\ndef _strip_quotes(value: str) -> str:\n    if len(value) >= 2 and value[0] == value[-1] and value[0] in {\"'\", '\"'}:\n        return value[1:-1]\n    return value\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/echo/echo.py",
    "content": "from __future__ import annotations\n\nimport copy\nfrom collections.abc import AsyncIterator, Sequence\nfrom typing import TYPE_CHECKING, Self\n\nfrom kosong.chat_provider import (\n    ChatProvider,\n    ChatProviderError,\n    StreamedMessage,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.chat_provider.echo.dsl import parse_echo_script\nfrom kosong.message import Message\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(echo: EchoChatProvider):\n        _: ChatProvider = echo\n\n\nclass EchoChatProvider:\n    \"\"\"\n    A test-only chat provider that streams parts described by a tiny DSL.\n\n    The DSL lives in the content of the last message in `history` and is made of lines in the\n    form `kind: payload`. Empty lines, comment lines starting with `#`, and markdown fences\n    starting with ``` are ignored. Supported kinds:\n\n    - `id`: sets the streamed message id.\n    - `usage`: token usage, e.g. `usage: {\"input_other\": 10, \"output\": 2}` or\n      `usage: input_other=1 output=2 input_cache_read=3`.\n    - `text`: a text chunk.\n    - `think`: a thinking chunk.\n    - `image_url`: either a raw URL or `{\"url\": \"...\", \"id\": \"opt\"}`.\n    - `audio_url`: either a raw URL or `{\"url\": \"...\", \"id\": \"opt\"}`.\n    - `video_url`: either a raw URL or `{\"url\": \"...\", \"id\": \"opt\"}`.\n    - `tool_call`: a JSON or key/value object. Fields: `id`, `name` (or `function.name`),\n      optional `arguments`/`function.arguments`, optional `extras`.\n    - `tool_call_part`: a string/JSON with `arguments_part`; `null` becomes `None`.\n\n    Example:\n\n    ```\n    id: echo-42\n    usage: {\"input_other\": 10, \"output\": 2}\n    think: thinking...\n    text: Hello,\n    text:  world!\n    image_url: {\"url\": \"https://example.com/image.png\", \"id\": \"img-1\"}\n    tool_call: {\"id\": \"call-1\", \"name\": \"search\", \"arguments\": \"{\\\\\"query\"}\n    tool_call_part: {\"arguments_part\": \"\\\\\": \\\\\"what time is\"}\n    tool_call_part: {\"arguments_part\": \" it?\\\\\"}\"}\n    ```\n    \"\"\"\n\n    name = \"echo\"\n\n    @property\n    def model_name(self) -> str:\n        return \"echo\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> EchoStreamedMessage:\n        if not history:\n            raise ChatProviderError(\"EchoChatProvider requires at least one message in history.\")\n        if history[-1].role != \"user\":\n            raise ChatProviderError(\"EchoChatProvider expects the last history message to be user.\")\n\n        script_text = history[-1].extract_text()\n        parts, message_id, usage = parse_echo_script(script_text)\n        if not parts:\n            raise ChatProviderError(\"EchoChatProvider DSL produced no streamable parts.\")\n        return EchoStreamedMessage(parts=parts, message_id=message_id, usage=usage)\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        # Thinking effort is irrelevant to the echo provider; return a shallow copy to\n        # satisfy the protocol and keep the instance immutable.\n        return copy.copy(self)\n\n\nclass EchoStreamedMessage(StreamedMessage):\n    \"\"\"Streamed message for EchoChatProvider.\"\"\"\n\n    def __init__(\n        self,\n        *,\n        parts: list[StreamedMessagePart],\n        message_id: str | None,\n        usage: TokenUsage | None,\n    ):\n        self._iter = self._to_stream(parts)\n        self._id = message_id\n        self._usage = usage\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    async def _to_stream(\n        self, parts: list[StreamedMessagePart]\n    ) -> AsyncIterator[StreamedMessagePart]:\n        for part in parts:\n            yield part\n\n    @property\n    def id(self) -> str | None:\n        return self._id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        return self._usage\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/echo/scripted_echo.py",
    "content": "from __future__ import annotations\n\nimport copy\nimport json\nfrom collections import deque\nfrom collections.abc import AsyncIterator, Iterable, Sequence\nfrom typing import TYPE_CHECKING, Self\n\nfrom kosong.chat_provider import (\n    ChatProvider,\n    ChatProviderError,\n    StreamedMessage,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.chat_provider.echo.dsl import parse_echo_script\nfrom kosong.message import Message\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(scripted: ScriptedEchoChatProvider):\n        _: ChatProvider = scripted\n\n\nclass ScriptedEchoChatProvider:\n    \"\"\"\n    A test-only chat provider that consumes a queue of echo DSL scripts per call.\n    \"\"\"\n\n    name = \"scripted_echo\"\n\n    def __init__(self, scripts: Iterable[str], *, trace: bool = False):\n        self._scripts = deque(scripts)\n        self._turn = 0\n        self._trace = trace\n\n    @property\n    def model_name(self) -> str:\n        return \"scripted_echo\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> ScriptedEchoStreamedMessage:\n        if not self._scripts:\n            raise ChatProviderError(f\"ScriptedEchoChatProvider exhausted at turn {self._turn + 1}.\")\n        script_text = self._scripts.popleft()\n        if self._trace:\n            script_json = json.dumps(script_text)\n            print(f\"SCRIPTED_ECHO TURN {self._turn + 1}: {script_json}\")\n        self._turn += 1\n        parts, message_id, usage = parse_echo_script(script_text)\n        if not parts:\n            raise ChatProviderError(\"ScriptedEchoChatProvider DSL produced no streamable parts.\")\n        return ScriptedEchoStreamedMessage(parts=parts, message_id=message_id, usage=usage)\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        copied = copy.copy(self)\n        copied._scripts = deque(self._scripts)\n        return copied\n\n\nclass ScriptedEchoStreamedMessage(StreamedMessage):\n    \"\"\"Streamed message for ScriptedEchoChatProvider.\"\"\"\n\n    def __init__(\n        self,\n        *,\n        parts: list[StreamedMessagePart],\n        message_id: str | None,\n        usage: TokenUsage | None,\n    ):\n        self._iter = self._to_stream(parts)\n        self._id = message_id\n        self._usage = usage\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    async def _to_stream(\n        self, parts: list[StreamedMessagePart]\n    ) -> AsyncIterator[StreamedMessagePart]:\n        for part in parts:\n            yield part\n\n    @property\n    def id(self) -> str | None:\n        return self._id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        return self._usage\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/kimi.py",
    "content": "import copy\nimport mimetypes\nimport os\nimport uuid\nfrom collections.abc import AsyncIterator, Sequence\nfrom typing import TYPE_CHECKING, Any, Literal, Self, Unpack, cast\n\nimport httpx\nfrom openai import AsyncOpenAI, AsyncStream, BaseModel, OpenAIError, omit\nfrom openai._types import RequestFiles, RequestOptions\nfrom openai.types.chat import (\n    ChatCompletion,\n    ChatCompletionChunk,\n    ChatCompletionMessageFunctionToolCall,\n    ChatCompletionMessageParam,\n    ChatCompletionToolParam,\n)\nfrom openai.types.completion_usage import CompletionUsage\nfrom typing_extensions import TypedDict\n\nfrom kosong.chat_provider import (\n    ChatProvider,\n    ChatProviderError,\n    RetryableChatProvider,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.chat_provider.openai_common import (\n    close_replaced_openai_client,\n    convert_error,\n    create_openai_client,\n    tool_to_openai,\n)\nfrom kosong.message import (\n    ContentPart,\n    Message,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n    VideoURLPart,\n)\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(kimi: \"Kimi\"):\n        _: ChatProvider = kimi\n        _: RetryableChatProvider = kimi\n\n\nclass ThinkingConfig(TypedDict, total=True):\n    type: Literal[\"enabled\", \"disabled\"]\n\n\nclass ExtraBody(TypedDict, total=False, extra_items=Any):\n    thinking: ThinkingConfig\n\n\nclass Kimi:\n    \"\"\"\n    A chat provider that uses the Kimi API.\n\n    >>> chat_provider = Kimi(model=\"kimi-k2-turbo-preview\", api_key=\"sk-1234567890\")\n    >>> chat_provider.name\n    'kimi'\n    >>> chat_provider.model_name\n    'kimi-k2-turbo-preview'\n    >>> chat_provider.with_generation_kwargs(temperature=0)._generation_kwargs\n    {'temperature': 0}\n    >>> chat_provider._generation_kwargs\n    {}\n    \"\"\"\n\n    name = \"kimi\"\n\n    class GenerationKwargs(TypedDict, total=False):\n        \"\"\"\n        See https://platform.moonshot.ai/docs/api/chat#request-body.\n        \"\"\"\n\n        max_tokens: int | None\n        temperature: float | None\n        top_p: float | None\n        n: int | None\n        presence_penalty: float | None\n        frequency_penalty: float | None\n        stop: str | list[str] | None\n        prompt_cache_key: str | None\n        reasoning_effort: str | None\n        \"\"\"Legacy thinking parameter. Use `extra_body.thinking` instead.\"\"\"\n        extra_body: ExtraBody | None\n\n    def __init__(\n        self,\n        *,\n        model: str,\n        api_key: str | None = None,\n        base_url: str | None = None,\n        stream: bool = True,\n        **client_kwargs: Any,\n    ):\n        if api_key is None:\n            api_key = os.getenv(\"KIMI_API_KEY\")\n        if api_key is None:\n            raise ChatProviderError(\n                \"The api_key client option or the KIMI_API_KEY environment variable is not set\"\n            )\n        if base_url is None:\n            base_url = os.getenv(\"KIMI_BASE_URL\", \"https://api.moonshot.ai/v1\")\n\n        self.model: str = model\n        \"\"\"The name of the model to use.\"\"\"\n        self.stream: bool = stream\n        \"\"\"Whether to generate responses as a stream.\"\"\"\n        self._api_key: str | None = api_key\n        self._base_url: str | None = base_url\n        self._client_kwargs: dict[str, Any] = dict(client_kwargs)\n        self.client: AsyncOpenAI = create_openai_client(\n            api_key=self._api_key,\n            base_url=self._base_url,\n            client_kwargs=self._client_kwargs,\n        )\n        \"\"\"The underlying `AsyncOpenAI` client.\"\"\"\n        self._generation_kwargs: Kimi.GenerationKwargs = {}\n\n    @property\n    def model_name(self) -> str:\n        return self.model\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        reasoning_effort = self._generation_kwargs.get(\"reasoning_effort\")\n        if reasoning_effort is None:\n            return None\n        match reasoning_effort:\n            case \"low\":\n                return \"low\"\n            case \"medium\":\n                return \"medium\"\n            case \"high\":\n                return \"high\"\n            case _:\n                return \"off\"\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> \"KimiStreamedMessage\":\n        messages: list[ChatCompletionMessageParam] = []\n        if system_prompt:\n            messages.append({\"role\": \"system\", \"content\": system_prompt})\n        messages.extend(_convert_message(message) for message in history)\n\n        generation_kwargs: dict[str, Any] = {\n            # default kimi generation kwargs\n            \"max_tokens\": 32000,\n        }\n        generation_kwargs.update(self._generation_kwargs)\n\n        try:\n            response = await self.client.chat.completions.create(\n                model=self.model,\n                messages=messages,\n                tools=(_convert_tool(tool) for tool in tools),\n                stream=self.stream,\n                stream_options={\"include_usage\": True} if self.stream else omit,\n                **generation_kwargs,\n            )\n            return KimiStreamedMessage(response)\n        except (OpenAIError, httpx.HTTPError) as e:\n            raise convert_error(e) from e\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        old_client = self.client\n        self.client = create_openai_client(\n            api_key=self._api_key,\n            base_url=self._base_url,\n            client_kwargs=self._client_kwargs,\n        )\n        close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs)\n        return True\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        match effort:\n            case \"off\":\n                reasoning_effort = None\n            case \"low\":\n                reasoning_effort = \"low\"\n            case \"medium\":\n                reasoning_effort = \"medium\"\n            case \"high\":\n                reasoning_effort = \"high\"\n        return self.with_generation_kwargs(reasoning_effort=reasoning_effort).with_extra_body(\n            {\n                \"thinking\": {\n                    \"type\": \"enabled\" if effort != \"off\" else \"disabled\",\n                }\n            }\n        )\n\n    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:\n        \"\"\"\n        Copy the chat provider, updating the generation kwargs with the given values.\n\n        Returns:\n            Self: A new instance of the chat provider with updated generation kwargs.\n        \"\"\"\n        new_self = copy.copy(self)\n        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)\n        new_self._generation_kwargs.update(kwargs)\n        return new_self\n\n    def with_extra_body(self, extra_body: ExtraBody) -> Self:\n        \"\"\"\n        Copy the chat provider, updating the extra_body in generation kwargs.\n\n        Returns:\n            Self: A new instance of the chat provider with updated extra_body.\n        \"\"\"\n        new_self = copy.copy(self)\n        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)\n        old_extra_body = new_self._generation_kwargs.get(\"extra_body\") or {}\n        new_extra_body: ExtraBody = {**old_extra_body, **extra_body}\n        new_self._generation_kwargs[\"extra_body\"] = new_extra_body\n        return new_self\n\n    @property\n    def model_parameters(self) -> dict[str, Any]:\n        \"\"\"\n        The parameters of the model to use.\n\n        For tracing/logging purposes.\n        \"\"\"\n\n        model_parameters: dict[str, Any] = {\"base_url\": str(self.client.base_url)}\n        model_parameters.update(self._generation_kwargs)\n        return model_parameters\n\n    @property\n    def files(self) -> \"KimiFiles\":\n        return KimiFiles(self.client)\n\n\nclass KimiFiles:\n    def __init__(self, client: AsyncOpenAI) -> None:\n        self._client = client\n\n    async def upload_video(self, *, data: bytes, mime_type: str) -> VideoURLPart:\n        \"\"\"Upload a video to Kimi files API and return a video URL content part.\"\"\"\n        if not mime_type.startswith(\"video/\"):\n            raise ChatProviderError(f\"Expected a video mime type, got {mime_type}\")\n        url = await self._upload_file(data=data, mime_type=mime_type, purpose=\"video\")\n        return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url))\n\n    async def _upload_file(self, *, data: bytes, mime_type: str, purpose: \"KimiFilePurpose\") -> str:\n        filename = _guess_filename(mime_type)\n        files: RequestFiles = {\"file\": (filename, data, mime_type)}\n        options: RequestOptions = {\"headers\": {\"Content-Type\": \"multipart/form-data\"}}\n        try:\n            response: KimiFileObject = await self._client.post(\n                \"/files\",\n                cast_to=KimiFileObject,\n                body={\"purpose\": purpose},\n                files=files,\n                options=options,\n            )\n        except (OpenAIError, httpx.HTTPError) as e:\n            raise convert_error(e) from e\n        return f\"ms://{response.id}\"\n\n\nclass KimiFileObject(BaseModel):\n    id: str\n\n\ntype KimiFilePurpose = Literal[\"video\", \"image\"]\n\n\ndef _guess_filename(mime_type: str) -> str:\n    extension = mimetypes.guess_extension(mime_type) or \".bin\"\n    return f\"upload{extension}\"\n\n\ndef _convert_message(message: Message) -> ChatCompletionMessageParam:\n    message = message.model_copy(deep=True)\n    reasoning_content: str = \"\"\n    content: list[ContentPart] = []\n    for part in message.content:\n        if isinstance(part, ThinkPart):\n            reasoning_content += part.think\n        else:\n            content.append(part)\n    message.content = content\n    dumped_message = message.model_dump(exclude_none=True)\n    if reasoning_content:\n        dumped_message[\"reasoning_content\"] = reasoning_content\n    return cast(ChatCompletionMessageParam, dumped_message)\n\n\ndef _convert_tool(tool: Tool) -> ChatCompletionToolParam:\n    if tool.name.startswith(\"$\"):\n        # Kimi builtin functions start with `$`\n        return cast(\n            ChatCompletionToolParam,\n            {\n                \"type\": \"builtin_function\",\n                \"function\": {\n                    \"name\": tool.name,\n                    # no need to set description and parameters\n                },\n            },\n        )\n    else:\n        return tool_to_openai(tool)\n\n\nclass KimiStreamedMessage:\n    \"\"\"The streamed message of the Kimi chat provider.\"\"\"\n\n    def __init__(self, response: ChatCompletion | AsyncStream[ChatCompletionChunk]):\n        if isinstance(response, ChatCompletion):\n            self._iter = self._convert_non_stream_response(response)\n        else:\n            self._iter = self._convert_stream_response(response)\n        self._id: str | None = None\n        self._usage: CompletionUsage | None = None\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    @property\n    def id(self) -> str | None:\n        return self._id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        if self._usage:\n            cached = 0\n            other_input = self._usage.prompt_tokens\n            if hasattr(self._usage, \"cached_tokens\"):\n                # https://platform.moonshot.cn/docs/api/chat#%E8%BF%94%E5%9B%9E%E5%86%85%E5%AE%B9\n                # TODO: delete this when Moonshot API becomes compatible with OpenAI API\n                cached = getattr(self._usage, \"cached_tokens\") or 0  # noqa: B009\n                other_input -= cached\n            elif (\n                self._usage.prompt_tokens_details\n                and self._usage.prompt_tokens_details.cached_tokens\n            ):\n                cached = self._usage.prompt_tokens_details.cached_tokens\n                other_input -= cached\n            return TokenUsage(\n                input_other=other_input,\n                output=self._usage.completion_tokens,\n                input_cache_read=cached,\n            )\n        return None\n\n    async def _convert_non_stream_response(\n        self,\n        response: ChatCompletion,\n    ) -> AsyncIterator[StreamedMessagePart]:\n        self._id = response.id\n        self._usage = response.usage\n        message = response.choices[0].message\n        if reasoning_content := getattr(message, \"reasoning_content\", None):\n            assert isinstance(reasoning_content, str)\n            yield ThinkPart(think=reasoning_content)\n        if message.content:\n            yield TextPart(text=message.content)\n        if message.tool_calls:\n            for tool_call in message.tool_calls:\n                if isinstance(tool_call, ChatCompletionMessageFunctionToolCall):\n                    yield ToolCall(\n                        id=tool_call.id or str(uuid.uuid4()),\n                        function=ToolCall.FunctionBody(\n                            name=tool_call.function.name,\n                            arguments=tool_call.function.arguments,\n                        ),\n                    )\n\n    async def _convert_stream_response(\n        self,\n        response: AsyncIterator[ChatCompletionChunk],\n    ) -> AsyncIterator[StreamedMessagePart]:\n        try:\n            async for chunk in response:\n                if chunk.id:\n                    self._id = chunk.id\n                if usage := extract_usage_from_chunk(chunk):\n                    self._usage = usage\n\n                if not chunk.choices:\n                    continue\n\n                delta = chunk.choices[0].delta\n\n                # convert thinking content\n                if reasoning_content := getattr(delta, \"reasoning_content\", None):\n                    assert isinstance(reasoning_content, str)\n                    yield ThinkPart(think=reasoning_content)\n\n                # convert text content\n                if delta.content:\n                    yield TextPart(text=delta.content)\n\n                # convert tool calls\n                for tool_call in delta.tool_calls or []:\n                    if not tool_call.function:\n                        continue\n\n                    if tool_call.function.name:\n                        yield ToolCall(\n                            id=tool_call.id or str(uuid.uuid4()),\n                            function=ToolCall.FunctionBody(\n                                name=tool_call.function.name,\n                                arguments=tool_call.function.arguments,\n                            ),\n                        )\n                    elif tool_call.function.arguments:\n                        yield ToolCallPart(\n                            arguments_part=tool_call.function.arguments,\n                        )\n                    else:\n                        # skip empty tool calls\n                        pass\n        except (OpenAIError, httpx.HTTPError) as e:\n            raise convert_error(e) from e\n\n\ndef extract_usage_from_chunk(chunk: ChatCompletionChunk) -> CompletionUsage | None:\n    if chunk.usage:\n        return chunk.usage\n    if not chunk.choices:\n        return None\n    choice_dump: dict[str, object] = chunk.choices[0].model_dump()\n    raw_usage = choice_dump.get(\"usage\")\n    if isinstance(raw_usage, CompletionUsage):\n        return raw_usage\n    if isinstance(raw_usage, dict):\n        return CompletionUsage.model_validate(raw_usage)\n    return None\n\n\nif __name__ == \"__main__\":\n\n    async def _dev_main():\n        chat = Kimi(model=\"kimi-k2-turbo-preview\", stream=False)\n        system_prompt = \"\"\n        history = [\n            Message(role=\"user\", content=\"Hello, who is Confucius?\"),\n        ]\n        stream = await chat.with_generation_kwargs(\n            temperature=0,\n            max_tokens=1000,\n        ).generate(system_prompt, [], history)\n        async for part in stream:\n            print(part.model_dump(exclude_none=True))\n        print(\"id:\", stream.id)\n        print(\"usage:\", stream.usage)\n\n    import asyncio\n\n    from dotenv import load_dotenv\n\n    load_dotenv()\n    asyncio.run(_dev_main())\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/mock.py",
    "content": "import copy\nfrom collections.abc import AsyncIterator, Sequence\nfrom typing import TYPE_CHECKING, Self\n\nfrom kosong.chat_provider import (\n    ChatProvider,\n    StreamedMessage,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.message import Message\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(mock: \"MockChatProvider\"):\n        _: ChatProvider = mock\n\n\nclass MockChatProvider(ChatProvider):\n    \"\"\"\n    A mock chat provider.\n    \"\"\"\n\n    name = \"mock\"\n\n    def __init__(\n        self,\n        message_parts: list[StreamedMessagePart],\n    ):\n        \"\"\"Initialize the mock chat provider with predefined message parts.\"\"\"\n        self._message_parts = message_parts\n\n    @property\n    def model_name(self) -> str:\n        return \"mock\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> \"MockStreamedMessage\":\n        \"\"\"Always return the predefined message parts.\"\"\"\n        return MockStreamedMessage(self._message_parts)\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        return copy.copy(self)\n\n\nclass MockStreamedMessage(StreamedMessage):\n    \"\"\"The streamed message of the mock chat provider.\"\"\"\n\n    def __init__(self, message_parts: list[StreamedMessagePart]):\n        self._iter = self._to_stream(message_parts)\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    async def _to_stream(\n        self, message_parts: list[StreamedMessagePart]\n    ) -> AsyncIterator[StreamedMessagePart]:\n        for part in message_parts:\n            yield part\n\n    @property\n    def id(self) -> str:\n        return \"mock\"\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        return None\n"
  },
  {
    "path": "packages/kosong/src/kosong/chat_provider/openai_common.py",
    "content": "import asyncio\nimport inspect\nfrom collections.abc import Awaitable, Mapping\nfrom typing import Any, cast\n\nimport httpx\nimport openai\nfrom openai import AsyncOpenAI, OpenAIError\nfrom openai.types import ReasoningEffort\nfrom openai.types.chat import ChatCompletionToolParam\n\nfrom kosong.chat_provider import (\n    APIConnectionError,\n    APIStatusError,\n    APITimeoutError,\n    ChatProviderError,\n    ThinkingEffort,\n)\nfrom kosong.tooling import Tool\n\n\ndef create_openai_client(\n    *,\n    api_key: str | None,\n    base_url: str | None,\n    client_kwargs: Mapping[str, Any],\n) -> AsyncOpenAI:\n    return AsyncOpenAI(api_key=api_key, base_url=base_url, **dict(client_kwargs))\n\n\nasync def _drain_awaitable(awaitable: Awaitable[object]) -> None:\n    try:\n        await awaitable\n    except Exception:\n        return\n\n\ndef close_openai_client(client: AsyncOpenAI) -> None:\n    close = getattr(client, \"close\", None)\n    if not callable(close):\n        return\n    try:\n        result = close()\n    except Exception:\n        return\n    if not inspect.isawaitable(result):\n        return\n    try:\n        loop = asyncio.get_running_loop()\n    except RuntimeError:\n        if hasattr(result, \"close\"):\n            result.close()  # type: ignore[attr-defined]\n        return\n    loop.create_task(_drain_awaitable(cast(Awaitable[object], result)))\n\n\ndef close_replaced_openai_client(client: AsyncOpenAI, *, client_kwargs: Mapping[str, Any]) -> None:\n    \"\"\"\n    Close a replaced OpenAI client unless it would close a shared external http client.\n\n    When callers pass `http_client=...` to `AsyncOpenAI`, multiple wrappers may share the same\n    `httpx.AsyncClient`. Closing the replaced wrapper would also close that shared client and\n    break the new wrapper immediately.\n    \"\"\"\n    shared_http_client = client_kwargs.get(\"http_client\")\n    if isinstance(shared_http_client, httpx.AsyncClient) and getattr(client, \"_client\", None) is (\n        shared_http_client\n    ):\n        return\n    close_openai_client(client)\n\n\ndef convert_error(error: OpenAIError | httpx.HTTPError) -> ChatProviderError:\n    match error:\n        case openai.APIStatusError():\n            return APIStatusError(error.status_code, error.message)\n        case openai.APIConnectionError():\n            return APIConnectionError(error.message)\n        case openai.APITimeoutError():\n            return APITimeoutError(error.message)\n        case httpx.TimeoutException():\n            return APITimeoutError(str(error))\n        case httpx.NetworkError():\n            return APIConnectionError(str(error))\n        case httpx.HTTPStatusError():\n            return APIStatusError(error.response.status_code, str(error))\n        case _:\n            return ChatProviderError(f\"Error: {error}\")\n\n\ndef thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffort:\n    match effort:\n        case \"off\":\n            return None\n        case \"low\":\n            return \"low\"\n        case \"medium\":\n            return \"medium\"\n        case \"high\":\n            return \"high\"\n\n\ndef reasoning_effort_to_thinking_effort(effort: ReasoningEffort) -> ThinkingEffort:\n    match effort:\n        case \"low\" | \"minimal\":\n            return \"low\"\n        case \"medium\":\n            return \"medium\"\n        case \"high\" | \"xhigh\":\n            return \"high\"\n        case \"none\" | None:\n            return \"off\"\n\n\ndef tool_to_openai(tool: Tool) -> ChatCompletionToolParam:\n    \"\"\"Convert a single tool to OpenAI tool format.\"\"\"\n    # simply `model_dump` because the `Tool` type is OpenAI-compatible\n    return {\n        \"type\": \"function\",\n        \"function\": {\n            \"name\": tool.name,\n            \"description\": tool.description,\n            \"parameters\": tool.parameters,\n        },\n    }\n"
  },
  {
    "path": "packages/kosong/src/kosong/contrib/__init__.py",
    "content": ""
  },
  {
    "path": "packages/kosong/src/kosong/contrib/chat_provider/__init__.py",
    "content": ""
  },
  {
    "path": "packages/kosong/src/kosong/contrib/chat_provider/anthropic.py",
    "content": "try:\n    import anthropic as _  # noqa: F401\nexcept ModuleNotFoundError as exc:\n    raise ModuleNotFoundError(\n        \"Anthropic support requires the optional dependency 'anthropic'. \"\n        'Install with `pip install \"kosong[contrib]\"`.'\n    ) from exc\n\nimport copy\nimport json\nfrom collections.abc import AsyncIterator, Mapping, Sequence\nfrom typing import TYPE_CHECKING, Any, Literal, Self, TypedDict, Unpack, cast\n\nfrom anthropic import (\n    AnthropicError,\n    AsyncAnthropic,\n    AsyncStream,\n    omit,\n)\nfrom anthropic import (\n    APIConnectionError as AnthropicAPIConnectionError,\n)\nfrom anthropic import (\n    APIStatusError as AnthropicAPIStatusError,\n)\nfrom anthropic import (\n    APITimeoutError as AnthropicAPITimeoutError,\n)\nfrom anthropic import (\n    AuthenticationError as AnthropicAuthenticationError,\n)\nfrom anthropic import (\n    PermissionDeniedError as AnthropicPermissionDeniedError,\n)\nfrom anthropic import (\n    RateLimitError as AnthropicRateLimitError,\n)\nfrom anthropic.lib.streaming import MessageStopEvent\nfrom anthropic.types import (\n    Base64ImageSourceParam,\n    CacheControlEphemeralParam,\n    ContentBlockParam,\n    ImageBlockParam,\n    MessageDeltaEvent,\n    MessageDeltaUsage,\n    MessageParam,\n    MessageStartEvent,\n    MetadataParam,\n    RawContentBlockDeltaEvent,\n    RawContentBlockStartEvent,\n    RawMessageStreamEvent,\n    TextBlockParam,\n    ThinkingBlockParam,\n    ThinkingConfigParam,\n    ToolChoiceParam,\n    ToolParam,\n    ToolResultBlockParam,\n    ToolUseBlockParam,\n    URLImageSourceParam,\n    Usage,\n)\nfrom anthropic.types import (\n    Message as AnthropicMessage,\n)\nfrom anthropic.types.tool_result_block_param import Content as ToolResultContent\n\nfrom kosong.chat_provider import (\n    APIConnectionError,\n    APIStatusError,\n    APITimeoutError,\n    ChatProvider,\n    ChatProviderError,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.contrib.chat_provider.common import ToolMessageConversion\nfrom kosong.message import (\n    ContentPart,\n    ImageURLPart,\n    Message,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n)\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(anthropic: \"Anthropic\"):\n        _: ChatProvider = anthropic\n\n\ntype MessagePayload = tuple[str | None, list[MessageParam]]\n\ntype BetaFeatures = Literal[\"interleaved-thinking-2025-05-14\"]\n\n\nclass Anthropic:\n    \"\"\"\n    Chat provider backed by Anthropic's Messages API.\n    \"\"\"\n\n    name = \"anthropic\"\n\n    class GenerationKwargs(TypedDict, total=False):\n        max_tokens: int | None\n        temperature: float | None\n        top_k: int | None\n        top_p: float | None\n        # e.g., {\"type\": \"adaptive\"} or {\"type\": \"enabled\", \"budget_tokens\": 1024}\n        thinking: ThinkingConfigParam | None\n        # e.g., {\"type\": \"auto\", \"disable_parallel_tool_use\": True}\n        tool_choice: ToolChoiceParam | None\n\n        beta_features: list[BetaFeatures] | None\n        extra_headers: Mapping[str, str] | None\n\n    def __init__(\n        self,\n        *,\n        model: str,\n        api_key: str | None = None,\n        base_url: str | None = None,\n        stream: bool = True,\n        # which process should we apply on tool result\n        tool_message_conversion: ToolMessageConversion | None = None,\n        # Must provide a max_tokens. Can be overridden by .with_generation_kwargs()\n        default_max_tokens: int,\n        metadata: MetadataParam | None = None,\n        **client_kwargs: Any,\n    ):\n        self._model = model\n        self._stream = stream\n        self._client = AsyncAnthropic(api_key=api_key, base_url=base_url, **client_kwargs)\n        self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion\n        self._metadata = metadata\n        self._generation_kwargs: Anthropic.GenerationKwargs = {\n            \"max_tokens\": default_max_tokens,\n            \"beta_features\": [\"interleaved-thinking-2025-05-14\"],\n        }\n\n    @property\n    def model_name(self) -> str:\n        return self._model\n\n    @property\n    def thinking_effort(self) -> \"ThinkingEffort | None\":\n        thinking_config = self._generation_kwargs.get(\"thinking\")\n        if thinking_config is None:\n            return None\n        if thinking_config[\"type\"] == \"disabled\":\n            return \"off\"\n        if thinking_config[\"type\"] == \"adaptive\":\n            return \"high\"\n        budget = thinking_config[\"budget_tokens\"]\n        if budget <= 1024:\n            return \"low\"\n        if budget <= 4096:\n            return \"medium\"\n        return \"high\"\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> \"AnthropicStreamedMessage\":\n        # https://docs.claude.com/en/api/messages#body-messages\n        # Anthropic API does not support system roles, but just a system prompt.\n        system = (\n            [\n                TextBlockParam(\n                    text=system_prompt,\n                    type=\"text\",\n                    cache_control=CacheControlEphemeralParam(type=\"ephemeral\"),\n                )\n            ]\n            if system_prompt\n            else omit\n        )\n        messages: list[MessageParam] = []\n        for message in history:\n            messages.append(self._convert_message(message))\n        if messages:\n            last_message = messages[-1]\n            last_content = last_message[\"content\"]\n\n            # inject cache control in the last content.\n            # https://docs.claude.com/en/docs/build-with-claude/prompt-caching\n            if isinstance(last_content, list) and last_content:\n                content_blocks = cast(list[ContentBlockParam], last_content)\n                last_block = content_blocks[-1]\n                match last_block[\"type\"]:\n                    case (\n                        \"text\"\n                        | \"image\"\n                        | \"document\"\n                        | \"search_result\"\n                        | \"tool_use\"\n                        | \"tool_result\"\n                        | \"server_tool_use\"\n                        | \"web_search_tool_result\"\n                    ):\n                        last_block[\"cache_control\"] = CacheControlEphemeralParam(type=\"ephemeral\")\n                    case \"thinking\" | \"redacted_thinking\":\n                        pass\n        generation_kwargs: dict[str, Any] = {}\n        generation_kwargs.update(self._generation_kwargs)\n        betas = generation_kwargs.pop(\"beta_features\", [])\n        extra_headers = {\n            **{\"anthropic-beta\": \",\".join(str(e) for e in betas)},\n            **(generation_kwargs.pop(\"extra_headers\", {})),\n        }\n\n        tools_ = [_convert_tool(tool) for tool in tools]\n        if tools:\n            tools_[-1][\"cache_control\"] = CacheControlEphemeralParam(type=\"ephemeral\")\n        try:\n            response = await self._client.messages.create(\n                model=self._model,\n                messages=messages,\n                system=system,\n                tools=tools_,\n                stream=self._stream,\n                extra_headers=extra_headers,\n                metadata=self._metadata if self._metadata is not None else omit,\n                **generation_kwargs,\n            )\n            return AnthropicStreamedMessage(response)\n        except AnthropicError as e:\n            raise _convert_error(e) from e\n\n    def _use_adaptive_thinking(self) -> bool:\n        \"\"\"Whether to use adaptive thinking (Opus 4.6+) instead of budget-based thinking.\"\"\"\n        model = self._model.lower()\n        return \"opus-4.6\" in model or \"opus-4-6\" in model\n\n    def with_thinking(self, effort: \"ThinkingEffort\") -> Self:\n        thinking_config: ThinkingConfigParam\n        if self._use_adaptive_thinking():\n            # Opus 4.6+: use adaptive thinking (budget_tokens is deprecated).\n            # The interleaved-thinking beta header is also not needed with adaptive.\n            match effort:\n                case \"off\":\n                    thinking_config = {\"type\": \"disabled\"}\n                case _:\n                    thinking_config = {\"type\": \"adaptive\"}  # type: ignore[typeddict-item]\n            new = self.with_generation_kwargs(thinking=thinking_config)\n            # Remove the now-unnecessary interleaved-thinking beta header.\n            if (\n                beta_features := new._generation_kwargs.get(\"beta_features\")\n            ) and \"interleaved-thinking-2025-05-14\" in beta_features:\n                beta_features.remove(\"interleaved-thinking-2025-05-14\")\n            return new\n        else:\n            # Pre-4.6 models: use legacy budget-based thinking.\n            match effort:\n                case \"off\":\n                    thinking_config = {\"type\": \"disabled\"}\n                case \"low\":\n                    thinking_config = {\"type\": \"enabled\", \"budget_tokens\": 1024}\n                case \"medium\":\n                    thinking_config = {\"type\": \"enabled\", \"budget_tokens\": 4096}\n                case \"high\":\n                    thinking_config = {\"type\": \"enabled\", \"budget_tokens\": 32_000}\n            return self.with_generation_kwargs(thinking=thinking_config)\n\n    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:\n        \"\"\"\n        Copy the chat provider, updating the generation kwargs with the given values.\n\n        Returns:\n            Self: A new instance of the chat provider with updated generation kwargs.\n        \"\"\"\n        new_self = copy.copy(self)\n        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)\n        new_self._generation_kwargs.update(kwargs)\n        return new_self\n\n    @property\n    def model_parameters(self) -> dict[str, Any]:\n        \"\"\"\n        The parameters of the model to use.\n\n        For tracing/logging purposes.\n        \"\"\"\n\n        model_parameters: dict[str, Any] = {\"base_url\": str(self._client.base_url)}\n        model_parameters.update(self._generation_kwargs)\n        return model_parameters\n\n    def _convert_message(self, message: Message) -> MessageParam:\n        \"\"\"Convert a single internal message into Anthropic wire format.\"\"\"\n        role = message.role\n\n        if role == \"system\":\n            # Anthropic does not support system messages in the conversation.\n            # We map it to a special user message.\n            return MessageParam(\n                role=\"user\",\n                content=[\n                    TextBlockParam(\n                        type=\"text\", text=f\"<system>{message.extract_text(sep='\\n')}</system>\"\n                    )\n                ],\n            )\n        elif role == \"tool\":\n            if message.tool_call_id is None:\n                raise ChatProviderError(\"Tool message missing `tool_call_id`.\")\n            if self._tool_message_conversion == \"extract_text\":\n                content = message.extract_text(sep=\"\\n\")\n            else:\n                content = message.content\n            block = _tool_result_message_to_block(message.tool_call_id, content)\n            return MessageParam(role=\"user\", content=[block])\n\n        assert role in (\"user\", \"assistant\")\n        blocks: list[ContentBlockParam] = []\n        for part in message.content:\n            if isinstance(part, TextPart):\n                blocks.append(TextBlockParam(type=\"text\", text=part.text))\n            elif isinstance(part, ImageURLPart):\n                blocks.append(_image_url_part_to_anthropic(part))\n            elif isinstance(part, ThinkPart):\n                if part.encrypted is None:\n                    # missing signature, strip this thinking block.\n                    continue\n                else:\n                    blocks.append(\n                        ThinkingBlockParam(\n                            type=\"thinking\", thinking=part.think, signature=part.encrypted\n                        )\n                    )\n            else:\n                continue\n        for tool_call in message.tool_calls or []:\n            if tool_call.function.arguments:\n                try:\n                    parsed_arguments = json.loads(tool_call.function.arguments)\n                except json.JSONDecodeError as exc:  # pragma: no cover - defensive guard\n                    raise ChatProviderError(\"Tool call arguments must be valid JSON.\") from exc\n                if not isinstance(parsed_arguments, dict):\n                    raise ChatProviderError(\"Tool call arguments must be a JSON object.\")\n                tool_input = cast(dict[str, object], parsed_arguments)\n            else:\n                tool_input = {}\n            blocks.append(\n                ToolUseBlockParam(\n                    type=\"tool_use\",\n                    id=tool_call.id,\n                    name=tool_call.function.name,\n                    input=tool_input,\n                )\n            )\n        return MessageParam(role=role, content=blocks)\n\n\nclass AnthropicStreamedMessage:\n    def __init__(self, response: AnthropicMessage | AsyncStream[RawMessageStreamEvent]):\n        if isinstance(response, AnthropicMessage):\n            self._iter = self._convert_non_stream_response(response)\n        else:\n            self._iter = self._convert_stream_response(response)\n        self._id: str | None = None\n        self._usage = Usage(input_tokens=0, output_tokens=0)\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    @property\n    def id(self) -> str | None:\n        return self._id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance\n        return TokenUsage(\n            # Note: in some Anthropic-compatible APIs, input_tokens can be None\n            input_other=self._usage.input_tokens or 0,\n            output=self._usage.output_tokens,\n            input_cache_read=self._usage.cache_read_input_tokens or 0,\n            input_cache_creation=self._usage.cache_creation_input_tokens or 0,\n        )\n\n    def _update_usage(self, delta_usage: MessageDeltaUsage) -> None:\n        if delta_usage.cache_creation_input_tokens is not None:\n            self._usage.cache_creation_input_tokens = delta_usage.cache_creation_input_tokens\n        if delta_usage.cache_read_input_tokens is not None:\n            self._usage.cache_read_input_tokens = delta_usage.cache_read_input_tokens\n        if delta_usage.input_tokens is not None:\n            self._usage.input_tokens = delta_usage.input_tokens\n        if delta_usage.output_tokens is not None:  # type: ignore\n            self._usage.output_tokens = delta_usage.output_tokens\n\n    async def _convert_non_stream_response(\n        self,\n        response: AnthropicMessage,\n    ) -> AsyncIterator[StreamedMessagePart]:\n        self._id = response.id\n        self._usage = response.usage\n        for block in response.content:\n            match block.type:\n                case \"text\":\n                    yield TextPart(text=block.text)\n                case \"thinking\":\n                    yield ThinkPart(think=block.thinking, encrypted=block.signature)\n                case \"redacted_thinking\":\n                    yield ThinkPart(think=\"\", encrypted=block.data)\n                case \"tool_use\":\n                    yield ToolCall(\n                        id=block.id,\n                        function=ToolCall.FunctionBody(\n                            name=block.name, arguments=json.dumps(block.input)\n                        ),\n                    )\n                case _:\n                    continue\n\n    async def _convert_stream_response(\n        self,\n        manager: AsyncStream[RawMessageStreamEvent],\n    ) -> AsyncIterator[StreamedMessagePart]:\n        try:\n            async with manager as stream:\n                async for event in stream:\n                    if isinstance(event, MessageStartEvent):\n                        self._id = event.message.id\n                        # Capture initial usage from start event\n                        # (contains initial prompt/input token usage)\n                        self._usage = event.message.usage\n                    elif isinstance(event, RawContentBlockStartEvent):\n                        block = event.content_block\n                        match block.type:\n                            case \"text\":\n                                yield TextPart(text=block.text)\n                            case \"thinking\":\n                                yield ThinkPart(think=block.thinking)\n                            case \"redacted_thinking\":\n                                yield ThinkPart(think=\"\", encrypted=block.data)\n                            case \"tool_use\":\n                                yield ToolCall(\n                                    id=block.id,\n                                    function=ToolCall.FunctionBody(name=block.name, arguments=\"\"),\n                                )\n                            case \"server_tool_use\" | \"web_search_tool_result\":\n                                # ignore\n                                continue\n                    elif isinstance(event, RawContentBlockDeltaEvent):\n                        delta = event.delta\n                        match delta.type:\n                            case \"text_delta\":\n                                yield TextPart(text=delta.text)\n                            case \"thinking_delta\":\n                                yield ThinkPart(think=delta.thinking)\n                            case \"input_json_delta\":\n                                yield ToolCallPart(arguments_part=delta.partial_json)\n                            case \"signature_delta\":\n                                yield ThinkPart(think=\"\", encrypted=delta.signature)\n                            case \"citations_delta\":\n                                # ignore\n                                continue\n                    elif isinstance(event, MessageDeltaEvent):\n                        if event.usage:\n                            self._update_usage(event.usage)\n                    elif isinstance(event, MessageStopEvent):\n                        continue\n        except AnthropicError as exc:\n            raise _convert_error(exc) from exc\n\n\ndef _convert_tool(tool: Tool) -> ToolParam:\n    return {\n        \"name\": tool.name,\n        \"description\": tool.description,\n        \"input_schema\": tool.parameters,\n    }\n\n\ndef _tool_result_message_to_block(\n    tool_call_id: str, content: str | list[ContentPart]\n) -> ToolResultBlockParam:\n    block_content: str | list[ToolResultContent]\n    # If tool_result_process is `extract_text`, we join all text parts into one string\n    if isinstance(content, str):\n        block_content = content\n    else:\n        # Otherwise, map parts to content blocks\n        blocks: list[ToolResultContent] = []\n        for part in content:\n            if isinstance(part, TextPart):\n                if part.text:\n                    blocks.append(TextBlockParam(type=\"text\", text=part.text))\n            elif isinstance(part, ImageURLPart):\n                blocks.append(_image_url_part_to_anthropic(part))\n            else:\n                # https://docs.claude.com/en/docs/build-with-claude/files#file-types-and-content-blocks\n                # Anthropic API supports very limited file types\n                raise ChatProviderError(\n                    f\"Anthropic API does not support {type(part)} in tool result\"\n                )\n        block_content = blocks\n\n    return ToolResultBlockParam(\n        type=\"tool_result\",\n        tool_use_id=tool_call_id,\n        content=block_content,\n    )\n\n\ndef _image_url_part_to_anthropic(part: ImageURLPart) -> ImageBlockParam:\n    url = part.image_url.url\n    # data:[<media-type>][;base64],<data>\n    if url.startswith(\"data:\"):\n        res = url[5:].split(\";base64,\", 1)\n        if len(res) != 2:\n            raise ChatProviderError(f\"Invalid data URL for image: {url}\")\n        media_type, data = res\n        if media_type not in (\"image/png\", \"image/jpeg\", \"image/gif\", \"image/webp\"):\n            raise ChatProviderError(\n                f\"Unsupported media type for base64 image: {media_type}, url: {url}\"\n            )\n        return ImageBlockParam(\n            type=\"image\",\n            source=Base64ImageSourceParam(\n                type=\"base64\",\n                data=data,\n                media_type=media_type,\n            ),\n        )\n    else:\n        return ImageBlockParam(\n            type=\"image\",\n            source=URLImageSourceParam(type=\"url\", url=url),\n        )\n\n\ndef _convert_error(error: AnthropicError) -> ChatProviderError:\n    if isinstance(error, AnthropicAPIStatusError):\n        return APIStatusError(error.status_code, str(error))\n    if isinstance(error, AnthropicAuthenticationError):\n        return APIStatusError(getattr(error, \"status_code\", 401), str(error))\n    if isinstance(error, AnthropicPermissionDeniedError):\n        return APIStatusError(getattr(error, \"status_code\", 403), str(error))\n    if isinstance(error, AnthropicRateLimitError):\n        return APIStatusError(getattr(error, \"status_code\", 429), str(error))\n    if isinstance(error, AnthropicAPIConnectionError):\n        return APIConnectionError(str(error))\n    if isinstance(error, AnthropicAPITimeoutError):\n        return APITimeoutError(str(error))\n    return ChatProviderError(f\"Anthropic error: {error}\")\n"
  },
  {
    "path": "packages/kosong/src/kosong/contrib/chat_provider/common.py",
    "content": "from __future__ import annotations\n\nfrom typing import Literal\n\ntype ToolMessageConversion = Literal[\"extract_text\"]\n"
  },
  {
    "path": "packages/kosong/src/kosong/contrib/chat_provider/google_genai.py",
    "content": "try:\n    from google import genai as _  # noqa: F401\nexcept ModuleNotFoundError as exc:\n    raise ModuleNotFoundError(\n        \"Google Gemini support requires the optional dependency 'google-genai'. \"\n        'Install with `pip install \"kosong[contrib]\"`.'\n    ) from exc\n\nimport base64\nimport copy\nimport json\nimport mimetypes\nfrom collections.abc import AsyncIterator, Sequence\nfrom typing import TYPE_CHECKING, Any, Self, TypedDict, Unpack, cast\n\nimport httpx\nfrom google import genai\nfrom google.genai import client as genai_client\nfrom google.genai import errors as genai_errors\nfrom google.genai.types import (\n    Content,\n    FunctionCall,\n    FunctionDeclaration,\n    FunctionResponse,\n    FunctionResponsePart,\n    GenerateContentConfig,\n    GenerateContentResponse,\n    GenerateContentResponseUsageMetadata,\n    HttpOptions,\n    Part,\n    ThinkingConfig,\n    ThinkingLevel,\n    Tool,\n    ToolConfig,\n)\n\nfrom kosong.chat_provider import (\n    APIStatusError,\n    APITimeoutError,\n    ChatProvider,\n    ChatProviderError,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.message import (\n    AudioURLPart,\n    ContentPart,\n    ImageURLPart,\n    Message,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n)\nfrom kosong.tooling import Tool as KosongTool\nfrom kosong.tooling import ToolReturnValue\n\nif TYPE_CHECKING:\n\n    def type_check(google_genai: \"GoogleGenAI\"):\n        _: ChatProvider = google_genai\n\n\nclass GoogleGenAI:\n    \"\"\"\n    Chat provider backed by Google's Gemini API.\n    \"\"\"\n\n    name = \"google_genai\"\n\n    class GenerationKwargs(TypedDict, total=False):\n        max_output_tokens: int | None\n        temperature: float | None\n        top_k: int | None\n        top_p: float | None\n        # Thinking configuration for supported models\n        thinking_config: ThinkingConfig | None\n        # Tool configuration\n        tool_config: ToolConfig | None\n        # Extra headers\n        http_options: HttpOptions | None\n\n    def __init__(\n        self,\n        *,\n        model: str,\n        api_key: str | None = None,\n        base_url: str | None = None,\n        stream: bool = True,\n        vertexai: bool | None = None,\n        **client_kwargs: Any,\n    ):\n        self._model = model\n        self._stream = stream\n        self._base_url = base_url\n        self._client: genai_client.Client = genai.Client(\n            http_options=HttpOptions(base_url=base_url),\n            api_key=api_key,\n            vertexai=vertexai,\n            **client_kwargs,\n        )\n        self._generation_kwargs: GoogleGenAI.GenerationKwargs = {}\n\n    @property\n    def model_name(self) -> str:\n        return self._model\n\n    @property\n    def thinking_effort(self) -> \"ThinkingEffort | None\":\n        thinking_config = self._generation_kwargs.get(\"thinking_config\")\n        if thinking_config is None:\n            return None\n\n        # For gemini-3 models that use thinking_level\n        thinking_level = thinking_config.thinking_level\n        if thinking_level is not None:\n            match thinking_level:\n                case ThinkingLevel.LOW | ThinkingLevel.MINIMAL:\n                    return \"low\"\n                case ThinkingLevel.MEDIUM:\n                    return \"medium\"\n                case ThinkingLevel.HIGH:\n                    return \"high\"\n                case _:\n                    return None\n\n        # For other models that use thinking_budget\n        thinking_budget = thinking_config.thinking_budget\n        if thinking_budget is not None:\n            if thinking_budget == 0:\n                return \"off\"\n            if thinking_budget <= 1024:\n                return \"low\"\n            if thinking_budget <= 4096:\n                return \"medium\"\n            return \"high\"\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[KosongTool],\n        history: Sequence[Message],\n    ) -> \"GoogleGenAIStreamedMessage\":\n        contents = messages_to_google_genai_contents(history)\n\n        config = GenerateContentConfig(**self._generation_kwargs)\n        config.system_instruction = system_prompt\n        config.tools = [tool_to_google_genai(tool) for tool in tools]\n\n        try:\n            if self._stream:\n                stream_response = await self._client.aio.models.generate_content_stream(  # type: ignore[reportUnknownMemberType]\n                    model=self._model,\n                    contents=contents,  # type: ignore[reportArgumentType]\n                    config=config,\n                )\n                return GoogleGenAIStreamedMessage(stream_response)\n            else:\n                response = await self._client.aio.models.generate_content(  # type: ignore[reportUnknownMemberType]\n                    model=self._model,\n                    contents=contents,  # type: ignore[reportArgumentType]\n                    config=config,\n                )\n                return GoogleGenAIStreamedMessage(response)\n        except Exception as e:  # genai_errors.APIError and others\n            raise _convert_error(e) from e\n\n    def with_thinking(self, effort: \"ThinkingEffort\") -> Self:\n        thinking_config = ThinkingConfig(include_thoughts=True)\n\n        # Map thinking effort to budget tokens\n        if \"gemini-3\" in self._model:\n            match effort:\n                case \"off\":\n                    # use default thinking config\n                    pass\n                case \"low\":\n                    thinking_config.thinking_level = ThinkingLevel.LOW\n                case \"medium\":\n                    # FIXME: medium not supported yet, use high\n                    thinking_config.thinking_level = ThinkingLevel.HIGH\n                case \"high\":\n                    thinking_config.thinking_level = ThinkingLevel.HIGH\n        else:\n            match effort:\n                case \"off\":\n                    thinking_config.thinking_budget = 0\n                    thinking_config.include_thoughts = False\n                case \"low\":\n                    thinking_config.thinking_budget = 1024\n                    thinking_config.include_thoughts = True\n                case \"medium\":\n                    thinking_config.thinking_budget = 4096\n                    thinking_config.include_thoughts = True\n                case \"high\":\n                    thinking_config.thinking_budget = 32_000\n                    thinking_config.include_thoughts = True\n\n        return self.with_generation_kwargs(thinking_config=thinking_config)\n\n    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:\n        \"\"\"\n        Copy the chat provider, updating the generation kwargs with the given values.\n\n        Returns:\n            Self: A new instance of the chat provider with updated generation kwargs.\n        \"\"\"\n        new_self = copy.copy(self)\n        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)\n        new_self._generation_kwargs.update(kwargs)\n        return new_self\n\n    @property\n    def model_parameters(self) -> dict[str, Any]:\n        \"\"\"\n        The parameters of the model to use.\n\n        For tracing/logging purposes.\n        \"\"\"\n        return {\n            \"model\": self._model,\n            \"base_url\": self._base_url,\n            **self._generation_kwargs,\n        }\n\n\nclass GoogleGenAIStreamedMessage:\n    def __init__(self, response: GenerateContentResponse | AsyncIterator[GenerateContentResponse]):\n        if isinstance(response, GenerateContentResponse):\n            self._iter = self._convert_non_stream_response(response)\n        else:\n            self._iter = self._convert_stream_response(response)\n        self._id: str | None = None\n        self._usage: GenerateContentResponseUsageMetadata | None = None\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    @property\n    def id(self) -> str | None:\n        return self._id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        if self._usage is None:\n            return None\n        return TokenUsage(\n            input_other=self._usage.prompt_token_count or 0,\n            output=self._usage.candidates_token_count or 0,\n            input_cache_read=self._usage.cached_content_token_count or 0,\n            input_cache_creation=0,\n        )\n\n    async def _convert_non_stream_response(\n        self,\n        response: GenerateContentResponse,\n    ) -> AsyncIterator[StreamedMessagePart]:\n        # Extract usage information\n        if response.usage_metadata:\n            self._usage = response.usage_metadata\n        # Extract ID if available\n        if response.response_id is not None:\n            self._id = response.response_id\n\n        # Process candidates\n        for candidate in response.candidates or []:\n            parts = candidate.content.parts if candidate.content else None\n            if not parts:\n                continue\n            for part in parts:\n                async for message_part in self._process_part_async(part):\n                    yield message_part\n\n    async def _convert_stream_response(\n        self,\n        response_stream: AsyncIterator[GenerateContentResponse],\n    ) -> AsyncIterator[StreamedMessagePart]:\n        try:\n            async for response in response_stream:\n                # Extract ID from first response\n                if not self._id and response.response_id is not None:\n                    self._id = response.response_id\n\n                # Extract usage information\n                if response.usage_metadata:\n                    self._usage = response.usage_metadata\n\n                # Process candidates\n                for candidate in response.candidates or []:\n                    parts = candidate.content.parts if candidate.content else None\n                    if not parts:\n                        continue\n                    for part in parts:\n                        async for message_part in self._process_part_async(part):\n                            yield message_part\n        except genai_errors.APIError as exc:\n            raise _convert_error(exc) from exc\n\n    def _process_part(self, part: Part):\n        \"\"\"Process a single part and yield message components (synchronous generator).\n\n        Handles different part types from Gemini API:\n        - synthetic thinking parts (part.thought is True)\n        - encrypted thinking parts (part.thought_signature is not None)\n        - text parts\n        - function calls\n        \"\"\"\n        if part.thought:\n            # Synthetic thinking part\n            if part.text:\n                yield ThinkPart(think=part.text)\n        elif part.text:\n            # Regular text part\n            yield TextPart(text=part.text)\n        elif part.function_call:\n            func_call = part.function_call\n            if func_call.name is None:\n                # Skip function calls without a name\n                return\n            id_ = func_call.id if func_call.id is not None else f\"{id(func_call)}\"\n            tool_call_id = f\"{func_call.name}_{id_}\"\n            # Gemini uses thought_signature to store the encrypted thinking signature.\n            # part.thought is synthetic\n            # See: https://colab.research.google.com/github/GoogleCloudPlatform/generative-ai/blob/main/gemini/thinking/intro_thought_signatures.ipynb\n            thought_signature_b64 = (\n                base64.b64encode(part.thought_signature).decode(\"ascii\")\n                if part.thought_signature\n                else None\n            )\n            yield ToolCall(\n                id=tool_call_id,\n                function=ToolCall.FunctionBody(\n                    name=func_call.name,\n                    arguments=json.dumps(func_call.args) if func_call.args else \"{}\",\n                ),\n                extras={\n                    \"thought_signature_b64\": thought_signature_b64,\n                }\n                if thought_signature_b64\n                else None,\n            )\n\n    async def _process_part_async(self, part: Part) -> AsyncIterator[StreamedMessagePart]:\n        \"\"\"Async wrapper for _process_part.\"\"\"\n        for message_part in self._process_part(part):\n            yield message_part\n\n\ndef tool_to_google_genai(tool: KosongTool) -> Tool:\n    \"\"\"Convert a Kosong tool to GoogleGenAI tool format.\"\"\"\n    # Use parameters_json_schema instead of parameters to bypass the SDK's\n    # Pydantic validation (extra='forbid') which rejects standard JSON Schema\n    # metadata fields like $schema, $id, $comment, examples, etc.\n    # This is the SDK's official way to pass raw JSON Schema directly to the API.\n    return Tool(\n        function_declarations=[\n            FunctionDeclaration(\n                name=tool.name,\n                description=tool.description,\n                parameters_json_schema=tool.parameters,\n            )\n        ]\n    )\n\n\ndef _image_url_part_to_google_genai(part: ImageURLPart) -> Part:\n    \"\"\"Convert an image URL part to GoogleGenAI format.\"\"\"\n    url = part.image_url.url\n\n    # Handle data URLs\n    if url.startswith(\"data:\"):\n        # data:[<media-type>][;base64],<data>\n        res = url[5:].split(\";base64,\", 1)\n        if len(res) != 2:\n            raise ChatProviderError(f\"Invalid data URL for image: {url}\")\n\n        media_type, data_b64 = res\n        if media_type not in (\"image/png\", \"image/jpeg\", \"image/gif\", \"image/webp\"):\n            raise ChatProviderError(\n                f\"Unsupported media type for base64 image: {media_type}, url: {url}\"\n            )\n\n        # Decode base64 string to bytes\n        data_bytes = base64.b64decode(data_b64)\n        return Part.from_bytes(data=data_bytes, mime_type=media_type)\n    else:\n        # For regular URLs, try to download the image and convert to bytes\n        mime_type, _ = mimetypes.guess_type(url)\n        if not mime_type or not mime_type.startswith(\"image/\"):\n            # Default to image/png if we can't detect or it's not an image type\n            mime_type = \"image/png\"\n        response = httpx.get(url).raise_for_status()\n        data_bytes = response.content\n        return Part.from_bytes(data=data_bytes, mime_type=mime_type)\n\n\ndef _audio_url_part_to_google_genai(part: AudioURLPart) -> Part:\n    \"\"\"Convert an audio URL part to GoogleGenAI format.\"\"\"\n    url = part.audio_url.url\n\n    # Handle data URLs\n    if url.startswith(\"data:\"):\n        # data:[<media-type>][;base64],<data>\n        res = url[5:].split(\";base64,\", 1)\n        if len(res) != 2:\n            raise ChatProviderError(f\"Invalid data URL for audio: {url}\")\n\n        media_type, data_b64 = res\n        # Supported audio formats for GoogleGenAI\n        supported_audio_types = (\n            \"audio/wav\",\n            \"audio/mp3\",\n            \"audio/aiff\",\n            \"audio/aac\",\n            \"audio/ogg\",\n            \"audio/flac\",\n        )\n        if media_type not in supported_audio_types:\n            error_msg = (\n                f\"Unsupported media type for base64 audio: {media_type}, url: {url}. \"\n                f\"Supported types: {supported_audio_types}\"\n            )\n            raise ChatProviderError(error_msg)\n\n        # Decode base64 string to bytes\n        data_bytes = base64.b64decode(data_b64)\n        return Part.from_bytes(data=data_bytes, mime_type=media_type)\n    else:\n        # Fetch the audio and convert to bytes\n        mime_type, _ = mimetypes.guess_type(url)\n        if not mime_type or not mime_type.startswith(\"audio/\"):\n            # Default to audio/mp3 if we can't detect or it's not an audio type\n            mime_type = \"audio/mp3\"\n        response = httpx.get(url).raise_for_status()\n        data_bytes = response.content\n        return Part.from_bytes(data=data_bytes, mime_type=mime_type)\n\n\ndef _tool_result_to_response_and_parts(\n    parts: list[ContentPart],\n) -> tuple[dict[str, str], list[FunctionResponsePart]]:\n    \"\"\"Convert tool response content to Gemini function response format.\"\"\"\n    genai_parts: list[FunctionResponsePart] = []\n    response: str = \"\"\n\n    for part in parts:\n        if isinstance(part, TextPart):\n            if part.text:\n                response += part.text\n        elif isinstance(part, ImageURLPart):\n            genai_parts.append(FunctionResponsePart.from_uri(file_uri=part.image_url.url))\n        elif isinstance(part, AudioURLPart):\n            genai_parts.append(FunctionResponsePart.from_uri(file_uri=part.audio_url.url))\n        else:\n            # Skip unsupported parts (like ThinkPart, etc.)\n            continue\n\n    return {\"output\": response}, genai_parts\n\n\ndef _tool_call_id_to_name(tool_call_id: str, tool_name_by_id: dict[str, str]) -> str:\n    \"\"\"Resolve Gemini `FunctionResponse.name` from a tool_call_id.\"\"\"\n    if tool_call_id in tool_name_by_id:\n        return tool_name_by_id[tool_call_id]\n    # Fallback for older ids of the form \"{tool_name}_{id}\".\n    return tool_call_id.split(\"_\", 1)[0]\n\n\ndef _tool_message_to_function_response_part(\n    message: Message,\n    *,\n    tool_name_by_id: dict[str, str],\n) -> Part:\n    if message.role != \"tool\":  # pragma: no cover - defensive guard\n        raise ChatProviderError(\"Expected a tool message.\")\n    if message.tool_call_id is None:\n        raise ChatProviderError(\"Tool response is missing `tool_call_id`.\")\n\n    response_data, tool_result_parts = _tool_result_to_response_and_parts(message.content)\n    return Part(\n        function_response=FunctionResponse(\n            id=message.tool_call_id,\n            name=_tool_call_id_to_name(message.tool_call_id, tool_name_by_id),\n            response=response_data,\n            parts=tool_result_parts,\n        )\n    )\n\n\ndef _tool_messages_to_google_genai_content(\n    messages: Sequence[Message],\n    *,\n    tool_name_by_id: dict[str, str],\n    expected_tool_call_ids: Sequence[str] | None = None,\n    require_all_expected: bool = False,\n) -> Content:\n    \"\"\"Pack one-or-more tool results into a single Gemini \"user\" turn.\n\n    VertexAI-backed Gemini enforces that, for a tool-calling turn, the next\n    turn contains the same number of `functionResponse` parts as the preceding\n    `functionCall` parts. Packing multiple tool results into a single \"user\"\n    Content keeps us compliant and avoids ordering issues from parallel tool\n    execution.\n    \"\"\"\n    if not messages:\n        raise ChatProviderError(\"Expected at least one tool message.\")\n\n    expected_index: dict[str, int] = (\n        {tool_call_id: i for i, tool_call_id in enumerate(expected_tool_call_ids)}\n        if expected_tool_call_ids is not None\n        else {}\n    )\n    seen_tool_call_ids: set[str] = set()\n    indexed_messages = list(enumerate(messages))\n    indexed_messages.sort(\n        key=lambda t: (expected_index.get(cast(str, t[1].tool_call_id), 10**9), t[0])\n    )\n\n    parts: list[Part] = []\n    actual_tool_call_ids: list[str] = []\n    for _, message in indexed_messages:\n        if message.tool_call_id is None:\n            raise ChatProviderError(\"Tool response is missing `tool_call_id`.\")\n        if message.tool_call_id in seen_tool_call_ids:\n            raise ChatProviderError(f\"Duplicate tool response for id: {message.tool_call_id}\")\n        seen_tool_call_ids.add(message.tool_call_id)\n        actual_tool_call_ids.append(message.tool_call_id)\n        parts.append(\n            _tool_message_to_function_response_part(message, tool_name_by_id=tool_name_by_id)\n        )\n\n    if expected_tool_call_ids is not None and require_all_expected:\n        expected_set = set(expected_tool_call_ids)\n        missing = [\n            tool_call_id\n            for tool_call_id in expected_tool_call_ids\n            if tool_call_id not in seen_tool_call_ids\n        ]\n        extra = [\n            tool_call_id\n            for tool_call_id in actual_tool_call_ids\n            if tool_call_id not in expected_set\n        ]\n        if missing:\n            raise ChatProviderError(f\"Missing tool responses for ids: {missing}\")\n        if extra:\n            raise ChatProviderError(f\"Unexpected tool responses for ids: {extra}\")\n\n    return Content(role=\"user\", parts=parts)\n\n\ndef messages_to_google_genai_contents(messages: Sequence[Message]) -> list[Content]:\n    \"\"\"Convert internal messages into a Gemini contents list.\n\n    Tool results for a tool-calling turn are packed into a single \"user\" message\n    with N `functionResponse` parts matching the preceding \"model\" message's\n    N `functionCall` parts. This avoids ordering issues from parallel tool\n    execution and satisfies VertexAI's stricter validation.\n    \"\"\"\n    contents: list[Content] = []\n    tool_name_by_id: dict[str, str] = {}\n\n    i = 0\n    while i < len(messages):\n        message = messages[i]\n\n        if message.role == \"assistant\" and message.tool_calls:\n            contents.append(message_to_google_genai(message))\n            expected_tool_call_ids: list[str] = []\n            for tool_call in message.tool_calls:\n                tool_name_by_id[tool_call.id] = tool_call.function.name\n                expected_tool_call_ids.append(tool_call.id)\n\n            # Collect consecutive tool messages that correspond to this turn.\n            j = i + 1\n            tool_messages: list[Message] = []\n            while j < len(messages) and messages[j].role == \"tool\":\n                tool_messages.append(messages[j])\n                j += 1\n\n            if tool_messages:\n                contents.append(\n                    _tool_messages_to_google_genai_content(\n                        tool_messages,\n                        tool_name_by_id=tool_name_by_id,\n                        expected_tool_call_ids=expected_tool_call_ids,\n                        require_all_expected=True,\n                    )\n                )\n                i = j\n                continue\n\n            i += 1\n            continue\n\n        if message.role == \"tool\":\n            # Tool message without an immediately preceding tool-calling assistant\n            # message (e.g. truncated history). Convert it best-effort.\n            contents.append(\n                _tool_messages_to_google_genai_content([message], tool_name_by_id=tool_name_by_id)\n            )\n            i += 1\n            continue\n\n        contents.append(message_to_google_genai(message))\n        if message.role == \"assistant\" and message.tool_calls:\n            for tool_call in message.tool_calls:\n                tool_name_by_id[tool_call.id] = tool_call.function.name\n        i += 1\n\n    return contents\n\n\ndef message_to_google_genai(message: Message) -> Content:\n    \"\"\"Convert a single internal message into GoogleGenAI wire format.\"\"\"\n    role = message.role\n\n    if role == \"tool\":\n        raise ChatProviderError(\n            \"Tool messages must be converted via messages_to_google_genai_contents \"\n            \"to preserve tool-call ordering and tool-response packing.\"\n        )\n\n    # GoogleGenAI uses: \"user\" and \"model\" (not \"assistant\")\n    google_genai_role = \"model\" if role == \"assistant\" else role\n    parts: list[Part] = []\n\n    # Handle content parts\n    for part in message.content:\n        if isinstance(part, TextPart):\n            parts.append(Part.from_text(text=part.text))\n        elif isinstance(part, ImageURLPart):\n            parts.append(_image_url_part_to_google_genai(part))\n        elif isinstance(part, AudioURLPart):\n            parts.append(_audio_url_part_to_google_genai(part))\n        elif isinstance(part, ThinkPart):\n            # Note: skip part.thought because it is synthetic\n            continue\n        else:\n            # Skip unsupported parts\n            continue\n\n    # Handle tool calls for assistant messages\n    for tool_call in message.tool_calls or []:\n        if tool_call.function.arguments:\n            try:\n                parsed_arguments = json.loads(tool_call.function.arguments)\n            except json.JSONDecodeError as exc:  # pragma: no cover - defensive guard\n                raise ChatProviderError(\"Tool call arguments must be valid JSON.\") from exc\n            if not isinstance(parsed_arguments, dict):\n                raise ChatProviderError(\"Tool call arguments must be a JSON object.\")\n            args = cast(dict[str, object], parsed_arguments)\n        else:\n            args = {}\n\n        function_call = FunctionCall(\n            id=tool_call.id,\n            name=tool_call.function.name,\n            args=args,\n        )\n        function_call_part = Part(function_call=function_call)\n        # Add thought_signature back to function_call\n        if tool_call.extras and \"thought_signature_b64\" in tool_call.extras:\n            function_call_part.thought_signature = base64.b64decode(\n                cast(str, tool_call.extras[\"thought_signature_b64\"])\n            )\n        parts.append(function_call_part)\n\n    return Content(role=google_genai_role, parts=parts)\n\n\ndef _convert_error(error: Exception) -> ChatProviderError:\n    \"\"\"Convert a GoogleGenAI error to a Kosong chat provider error.\"\"\"\n    # Handle specific GoogleGenAI error types with detailed status code mapping\n    if isinstance(error, genai_errors.ClientError):\n        # 4xx client errors\n        status_code = getattr(error, \"code\", 400)\n        if status_code == 401:\n            return APIStatusError(401, f\"Authentication failed: {error}\")\n        elif status_code == 403:\n            return APIStatusError(403, f\"Permission denied: {error}\")\n        elif status_code == 429:\n            return APIStatusError(429, f\"Rate limit exceeded: {error}\")\n        return APIStatusError(status_code, str(error))\n    elif isinstance(error, genai_errors.ServerError):\n        # 5xx server errors\n        status_code = getattr(error, \"code\", 500)\n        return APIStatusError(status_code, f\"Server error: {error}\")\n    elif isinstance(error, genai_errors.APIError):\n        # Generic API errors\n        status_code = getattr(error, \"code\", 500)\n        return APIStatusError(status_code, str(error))\n    elif isinstance(error, TimeoutError):\n        return APITimeoutError(f\"Request timed out: {error}\")\n    else:\n        # Fallback for unexpected errors\n        return ChatProviderError(f\"Unexpected GoogleGenAI error: {error}\")\n\n\nif __name__ == \"__main__\":\n\n    async def main():\n        import os\n        from typing import override\n\n        from pydantic import BaseModel\n\n        import kosong\n        from kosong.tooling import CallableTool2, ToolOk\n        from kosong.tooling.simple import SimpleToolset\n\n        chat = GoogleGenAI(\n            model=\"gemini-3-pro-preview\",\n            vertexai=True,\n            api_key=os.getenv(\"VERTEXAI_API_KEY\"),\n        ).with_thinking(\"high\")\n        system_prompt = \"You are a helpful assistant.\"\n\n        class GetWeatherParams(BaseModel):\n            city: str\n\n        class GetWeather(CallableTool2[GetWeatherParams]):\n            name: str = \"get_weather\"\n            description: str = \"Get the weather of a city\"\n            params: type[GetWeatherParams] = GetWeatherParams\n\n            @override\n            async def __call__(self, params: GetWeatherParams) -> ToolReturnValue:\n                return ToolOk(output=\"Sunny\")\n\n        toolset = SimpleToolset()\n        toolset += GetWeather()\n        history = [\n            Message(\n                role=\"user\",\n                content=(\n                    \"What's the weather like in Beijing and Shanghai? \"\n                    \"Spawn parallel tool calls to get the answer.\"\n                ),\n            )\n        ]\n        result = await kosong.step(chat, system_prompt, toolset, history)\n        tool_results = await result.tool_results()\n\n        assistant_message = result.message\n        tool_messages = [\n            Message(role=\"tool\", content=tr.return_value.output, tool_call_id=tr.tool_call_id)\n            for tr in tool_results\n        ]\n        history.extend([assistant_message] + tool_messages)\n\n        async for part in await chat.generate(system_prompt, toolset.tools, history):\n            print(part.model_dump(exclude_none=True))\n\n    import asyncio\n\n    from dotenv import load_dotenv\n\n    load_dotenv()\n    asyncio.run(main())\n"
  },
  {
    "path": "packages/kosong/src/kosong/contrib/chat_provider/openai_legacy.py",
    "content": "import copy\nimport uuid\nfrom collections.abc import AsyncIterator, Sequence\nfrom typing import TYPE_CHECKING, Any, Self, Unpack, cast\n\nimport httpx\nfrom openai import AsyncStream, Omit, OpenAIError, omit\nfrom openai.types import CompletionUsage, ReasoningEffort\nfrom openai.types.chat import (\n    ChatCompletion,\n    ChatCompletionChunk,\n    ChatCompletionMessageFunctionToolCall,\n    ChatCompletionMessageParam,\n)\nfrom typing_extensions import TypedDict\n\nfrom kosong.chat_provider import (\n    ChatProvider,\n    RetryableChatProvider,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.chat_provider.openai_common import (\n    close_replaced_openai_client,\n    convert_error,\n    create_openai_client,\n    reasoning_effort_to_thinking_effort,\n    thinking_effort_to_reasoning_effort,\n    tool_to_openai,\n)\nfrom kosong.contrib.chat_provider.common import ToolMessageConversion\nfrom kosong.message import ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(openai_legacy: \"OpenAILegacy\"):\n        _: ChatProvider = openai_legacy\n        _: RetryableChatProvider = openai_legacy\n\n\nclass OpenAILegacy:\n    \"\"\"\n    A chat provider that uses the OpenAI Chat Completions API.\n\n    >>> chat_provider = OpenAILegacy(model=\"gpt-5\", api_key=\"sk-1234567890\")\n    >>> chat_provider.name\n    'openai'\n    >>> chat_provider.model_name\n    'gpt-5'\n    \"\"\"\n\n    name = \"openai\"\n\n    class GenerationKwargs(TypedDict, extra_items=Any, total=False):\n        \"\"\"\n        Generation kwargs for various kinds of OpenAI-compatible APIs.\n        `extra_items=Any` is used to support any extra args.\n        \"\"\"\n\n        max_tokens: int | None\n        temperature: float | None\n        top_p: float | None\n        n: int | None\n        presence_penalty: float | None\n        frequency_penalty: float | None\n        stop: str | list[str] | None\n        prompt_cache_key: str | None\n\n    def __init__(\n        self,\n        *,\n        model: str,\n        api_key: str | None = None,\n        base_url: str | None = None,\n        stream: bool = True,\n        reasoning_key: str | None = None,\n        tool_message_conversion: ToolMessageConversion | None = None,\n        **client_kwargs: Any,\n    ):\n        \"\"\"\n        Initialize the OpenAILegacy chat provider.\n\n        To support OpenAI-compatible APIs that inject reasoning content in a extra field in\n        the message, such as `{\"reasoning\": ...}`, `reasoning_key` can be set to the key name.\n        \"\"\"\n        self.model = model\n        self.stream = stream\n        self._api_key: str | None = api_key\n        self._base_url: str | None = base_url\n        self._client_kwargs: dict[str, Any] = dict(client_kwargs)\n        self.client = create_openai_client(\n            api_key=self._api_key,\n            base_url=self._base_url,\n            client_kwargs=self._client_kwargs,\n        )\n        \"\"\"The underlying `AsyncOpenAI` client.\"\"\"\n        self._reasoning_effort: ReasoningEffort | Omit = omit\n        self._reasoning_key = reasoning_key\n        self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion\n        self._generation_kwargs: OpenAILegacy.GenerationKwargs = {}\n\n    @property\n    def model_name(self) -> str:\n        return self.model\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        if isinstance(self._reasoning_effort, Omit):\n            return None\n        return reasoning_effort_to_thinking_effort(self._reasoning_effort)\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> \"OpenAILegacyStreamedMessage\":\n        messages: list[ChatCompletionMessageParam] = []\n        if system_prompt:\n            # `system` vs `developer`: see `message_to_openai` comments\n            messages.append({\"role\": \"system\", \"content\": system_prompt})\n        messages.extend(self._convert_message(message) for message in history)\n\n        generation_kwargs: dict[str, Any] = {}\n        generation_kwargs.update(self._generation_kwargs)\n\n        try:\n            response = await self.client.chat.completions.create(\n                model=self.model,\n                messages=messages,\n                tools=(tool_to_openai(tool) for tool in tools),\n                stream=self.stream,\n                stream_options={\"include_usage\": True} if self.stream else omit,\n                reasoning_effort=self._reasoning_effort,\n                **generation_kwargs,\n            )\n            return OpenAILegacyStreamedMessage(response, self._reasoning_key)\n        except (OpenAIError, httpx.HTTPError) as e:\n            raise convert_error(e) from e\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        old_client = self.client\n        self.client = create_openai_client(\n            api_key=self._api_key,\n            base_url=self._base_url,\n            client_kwargs=self._client_kwargs,\n        )\n        close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs)\n        return True\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        new_self = copy.copy(self)\n        new_self._reasoning_effort = thinking_effort_to_reasoning_effort(effort)\n        return new_self\n\n    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:\n        \"\"\"\n        Copy the chat provider, updating the generation kwargs with the given values.\n\n        Returns:\n            Self: A new instance of the chat provider with updated generation kwargs.\n        \"\"\"\n        new_self = copy.copy(self)\n        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)\n        new_self._generation_kwargs.update(kwargs)\n        return new_self\n\n    @property\n    def model_parameters(self) -> dict[str, Any]:\n        \"\"\"\n        The parameters of the model to use.\n\n        For tracing/logging purposes.\n        \"\"\"\n\n        model_parameters: dict[str, Any] = {\"base_url\": str(self.client.base_url)}\n        if self._reasoning_effort is not omit:\n            model_parameters[\"reasoning_effort\"] = self._reasoning_effort\n        return model_parameters\n\n    def _convert_message(self, message: Message) -> ChatCompletionMessageParam:\n        \"\"\"Convert a Kosong message to OpenAI message.\"\"\"\n        # Note: for openai, `developer` role is more standard, but `system` is still accepted.\n        # And many openai-compatible models do not accept `developer` role.\n        # So we use `system` role here. OpenAIResponses will use `developer` role.\n        # See https://cdn.openai.com/spec/model-spec-2024-05-08.html#definitions\n        message = message.model_copy(deep=True)\n        reasoning_content: str = \"\"\n        content: list[ContentPart] = []\n        for part in message.content:\n            if isinstance(part, ThinkPart):\n                reasoning_content += part.think\n            else:\n                content.append(part)\n        # if tool message and `tool_result_conversion` is `extract_text`, patch all text parts into\n        # one so that we can make use of the serialization process of `Message` to output string\n        if message.role == \"tool\" and self._tool_message_conversion == \"extract_text\":\n            message.content = [TextPart(text=message.extract_text(sep=\"\\n\"))]\n        else:\n            message.content = content\n        dumped_message = message.model_dump(exclude_none=True)\n        if reasoning_content:\n            assert self._reasoning_key, (\n                \"reasoning_key must not be empty if reasoning_content exists\"\n            )\n            dumped_message[self._reasoning_key] = reasoning_content\n        return cast(ChatCompletionMessageParam, dumped_message)\n\n\nclass OpenAILegacyStreamedMessage:\n    def __init__(\n        self, response: ChatCompletion | AsyncStream[ChatCompletionChunk], reasoning_key: str | None\n    ):\n        self._reasoning_key: str | None = reasoning_key\n        if isinstance(response, ChatCompletion):\n            self._iter = self._convert_non_stream_response(response)\n        else:\n            self._iter = self._convert_stream_response(response)\n        self._id: str | None = None\n        self._usage: CompletionUsage | None = None\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    @property\n    def id(self) -> str | None:\n        return self._id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        if self._usage:\n            cached = 0\n            other_input = self._usage.prompt_tokens\n            if (\n                self._usage.prompt_tokens_details\n                and self._usage.prompt_tokens_details.cached_tokens\n            ):\n                cached = self._usage.prompt_tokens_details.cached_tokens\n                other_input -= cached\n            return TokenUsage(\n                input_other=other_input,\n                output=self._usage.completion_tokens,\n                input_cache_read=cached,\n            )\n        return None\n\n    async def _convert_non_stream_response(\n        self,\n        response: ChatCompletion,\n    ) -> AsyncIterator[StreamedMessagePart]:\n        self._id = response.id\n        self._usage = response.usage\n        message = response.choices[0].message\n        reasoning_key = self._reasoning_key\n        if reasoning_key and (reasoning_content := getattr(message, reasoning_key, None)):\n            assert isinstance(reasoning_content, str)\n            yield ThinkPart(think=reasoning_content)\n        if message.content:\n            yield TextPart(text=message.content)\n        if message.tool_calls:\n            for tool_call in message.tool_calls:\n                if isinstance(tool_call, ChatCompletionMessageFunctionToolCall):\n                    yield ToolCall(\n                        id=tool_call.id or str(uuid.uuid4()),\n                        function=ToolCall.FunctionBody(\n                            name=tool_call.function.name,\n                            arguments=tool_call.function.arguments,\n                        ),\n                    )\n\n    async def _convert_stream_response(\n        self,\n        response: AsyncIterator[ChatCompletionChunk],\n    ) -> AsyncIterator[StreamedMessagePart]:\n        try:\n            async for chunk in response:\n                if chunk.id:\n                    self._id = chunk.id\n                if chunk.usage:\n                    self._usage = chunk.usage\n\n                if not chunk.choices:\n                    continue\n\n                delta = chunk.choices[0].delta\n\n                # convert thinking content\n                reasoning_key = self._reasoning_key\n                if reasoning_key and (reasoning_content := getattr(delta, reasoning_key, None)):\n                    assert isinstance(reasoning_content, str)\n                    yield ThinkPart(think=reasoning_content)\n\n                # convert text content\n                if delta.content:\n                    yield TextPart(text=delta.content)\n\n                # convert tool calls\n                for tool_call in delta.tool_calls or []:\n                    if not tool_call.function:\n                        continue\n\n                    if tool_call.function.name:\n                        yield ToolCall(\n                            id=tool_call.id or str(uuid.uuid4()),\n                            function=ToolCall.FunctionBody(\n                                name=tool_call.function.name,\n                                arguments=tool_call.function.arguments,\n                            ),\n                        )\n                    elif tool_call.function.arguments:\n                        yield ToolCallPart(\n                            arguments_part=tool_call.function.arguments,\n                        )\n                    else:\n                        # skip empty tool calls\n                        pass\n        except (OpenAIError, httpx.HTTPError) as e:\n            raise convert_error(e) from e\n\n\nif __name__ == \"__main__\":\n\n    async def _dev_main():\n        chat = OpenAILegacy(model=\"gpt-4o\", stream=False)\n        system_prompt = \"You are a helpful assistant.\"\n        history = [Message(role=\"user\", content=\"Hello, how are you?\")]\n        async for part in await chat.generate(system_prompt, [], history):\n            print(part.model_dump(exclude_none=True))\n\n        tools = [\n            Tool(\n                name=\"get_weather\",\n                description=\"Get the weather\",\n                parameters={\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"city\": {\n                            \"type\": \"string\",\n                            \"description\": \"The city to get the weather for.\",\n                        },\n                    },\n                },\n            )\n        ]\n        history = [Message(role=\"user\", content=\"What's the weather in Beijing?\")]\n        stream = await chat.generate(system_prompt, tools, history)\n        async for part in stream:\n            print(part.model_dump(exclude_none=True))\n        print(\"usage:\", stream.usage)\n\n    import asyncio\n\n    from dotenv import load_dotenv\n\n    load_dotenv()\n    asyncio.run(_dev_main())\n"
  },
  {
    "path": "packages/kosong/src/kosong/contrib/chat_provider/openai_responses.py",
    "content": "import copy\nimport uuid\nfrom collections.abc import AsyncIterator, Sequence\nfrom typing import TYPE_CHECKING, Any, Self, TypedDict, Unpack, cast, get_args\n\nimport httpx\nfrom openai import AsyncStream, OpenAIError\nfrom openai.types.responses import (\n    Response,\n    ResponseInputItemParam,\n    ResponseInputParam,\n    ResponseOutputMessageParam,\n    ResponseOutputTextParam,\n    ResponseReasoningItemParam,\n    ResponseStreamEvent,\n    ResponseUsage,\n    ToolParam,\n)\nfrom openai.types.responses.response_function_call_output_item_list_param import (\n    ResponseFunctionCallOutputItemListParam,\n)\nfrom openai.types.responses.response_input_file_content_param import (\n    ResponseInputFileContentParam,\n)\nfrom openai.types.responses.response_input_file_param import ResponseInputFileParam\nfrom openai.types.responses.response_input_message_content_list_param import (\n    ResponseInputMessageContentListParam,\n)\nfrom openai.types.shared.reasoning import Reasoning\nfrom openai.types.shared.reasoning_effort import ReasoningEffort\nfrom openai.types.shared_params.responses_model import ResponsesModel\n\nfrom kosong.chat_provider import (\n    ChatProvider,\n    RetryableChatProvider,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.chat_provider.openai_common import (\n    close_replaced_openai_client,\n    convert_error,\n    create_openai_client,\n    reasoning_effort_to_thinking_effort,\n    thinking_effort_to_reasoning_effort,\n)\nfrom kosong.contrib.chat_provider.common import ToolMessageConversion\nfrom kosong.message import (\n    AudioURLPart,\n    ContentPart,\n    ImageURLPart,\n    Message,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n)\nfrom kosong.tooling import Tool\n\nif TYPE_CHECKING:\n\n    def type_check(openai_responses: \"OpenAIResponses\"):\n        _: ChatProvider = openai_responses\n        _: RetryableChatProvider = openai_responses\n\n\ndef get_openai_models_set() -> set[str]:\n    \"\"\"Return a set of all available OpenAI response models.\n\n    This extracts all literal values from the ResponsesModel TypeAlias, which includes\n    both ChatModel and additional response-specific models.\n    \"\"\"\n    responses_model_args = get_args(ResponsesModel)\n    # responses_model_args is (str, ChatModel, Literal[...])\n    # Extract from ChatModel (index 1)\n    chat_models = set(get_args(responses_model_args[1]))\n    # Extract from the Literal part (index 2)\n    response_models = set(get_args(responses_model_args[2]))\n\n    return chat_models | response_models\n\n\n_openai_models = get_openai_models_set()\n\n\ndef is_openai_model(model_name: str) -> bool:\n    \"\"\"Judge if the model name is an OpenAI model.\"\"\"\n    return model_name in _openai_models\n\n\nclass OpenAIResponses:\n    \"\"\"\n    A chat provider that uses the OpenAI Responses API.\n\n    Similar to `OpenAILegacy`, but uses `client.responses` under the hood.\n\n    This provider always enables reasoning when generating responses.\n    If you want to use a non-reasoning model, please use `OpenAILegacy` instead.\n\n    >>> chat_provider = OpenAIResponses(model=\"gpt-5-codex\", api_key=\"sk-1234567890\")\n    >>> chat_provider.name\n    'openai-responses'\n    >>> chat_provider.model_name\n    'gpt-5-codex'\n    \"\"\"\n\n    name = \"openai-responses\"\n\n    class GenerationKwargs(TypedDict, total=False):\n        max_output_tokens: int | None\n        max_tool_calls: int | None\n        reasoning_effort: ReasoningEffort | None\n        temperature: float | None\n        top_logprobs: float | None\n        top_p: float | None\n        user: str | None\n\n    def __init__(\n        self,\n        *,\n        model: str,\n        api_key: str | None = None,\n        base_url: str | None = None,\n        stream: bool = True,\n        tool_message_conversion: ToolMessageConversion | None = None,\n        **client_kwargs: Any,\n    ):\n        self._model = model\n        self._stream = stream\n        self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion\n        self._api_key: str | None = api_key\n        self._base_url: str | None = base_url\n        self._client_kwargs: dict[str, Any] = dict(client_kwargs)\n        self._client = create_openai_client(\n            api_key=self._api_key,\n            base_url=self._base_url,\n            client_kwargs=self._client_kwargs,\n        )\n        self._generation_kwargs: OpenAIResponses.GenerationKwargs = {}\n\n    @property\n    def model_name(self) -> str:\n        return self._model\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        reasoning_effort = self._generation_kwargs.get(\"reasoning_effort\")\n        if reasoning_effort is None:\n            return None\n        return reasoning_effort_to_thinking_effort(reasoning_effort)\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> \"OpenAIResponsesStreamedMessage\":\n        inputs: ResponseInputParam = []\n        if system_prompt:\n            system_message: ResponseInputItemParam = {\"role\": \"system\", \"content\": system_prompt}\n            if is_openai_model(self.model_name):\n                system_message[\"role\"] = \"developer\"\n            inputs.append(system_message)\n        # The `Message` type is OpenAI-compatible for Responses API `input` messages.\n\n        for message in history:\n            inputs.extend(self._convert_message(message))\n\n        generation_kwargs: dict[str, Any] = {}\n        generation_kwargs.update(self._generation_kwargs)\n        reasoning_effort = generation_kwargs.pop(\"reasoning_effort\", None)\n        if reasoning_effort is not None:\n            generation_kwargs[\"reasoning\"] = Reasoning(\n                effort=reasoning_effort,\n                summary=\"auto\",\n            )\n            generation_kwargs[\"include\"] = [\"reasoning.encrypted_content\"]\n\n        try:\n            response = await self._client.responses.create(\n                stream=self._stream,\n                model=self._model,\n                input=inputs,\n                tools=[_convert_tool(tool) for tool in tools],\n                store=False,\n                **generation_kwargs,\n            )\n            return OpenAIResponsesStreamedMessage(response)\n        except (OpenAIError, httpx.HTTPError) as e:\n            raise convert_error(e) from e\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        old_client = self._client\n        self._client = create_openai_client(\n            api_key=self._api_key,\n            base_url=self._base_url,\n            client_kwargs=self._client_kwargs,\n        )\n        close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs)\n        return True\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        reasoning_effort = thinking_effort_to_reasoning_effort(effort)\n        return self.with_generation_kwargs(reasoning_effort=reasoning_effort)\n\n    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:\n        \"\"\"\n        Copy the chat provider, updating the generation kwargs with the given values.\n\n        Returns:\n            Self: A new instance of the chat provider with updated generation kwargs.\n        \"\"\"\n        new_self = copy.copy(self)\n        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)\n        new_self._generation_kwargs.update(kwargs)\n        return new_self\n\n    @property\n    def model_parameters(self) -> dict[str, Any]:\n        \"\"\"\n        The parameters of the model to use.\n\n        For tracing/logging purposes.\n        \"\"\"\n\n        model_parameters: dict[str, Any] = {\"base_url\": str(self._client.base_url)}\n        model_parameters.update(self._generation_kwargs)\n        return model_parameters\n\n    def _convert_message(self, message: Message) -> list[ResponseInputItemParam]:\n        \"\"\"Convert a single message to OpenAI Responses input format.\n\n        Rules:\n        - role in {user, assistant}: map to EasyInputMessageParam with role kept\n        role == system: map to role=developer for OpenAI models, otherwise kept\n        content: str kept; list[ContentPart] mapped to ResponseInputMessageContentListParam\n        - role == tool: map to FunctionCallOutput with call_id and output\n        \"\"\"\n\n        role = message.role\n        if is_openai_model(self.model_name) and role == \"system\":\n            role = \"developer\"\n\n        # tool role → function_call_output (return value from a prior tool call)\n        if role == \"tool\":\n            call_id = message.tool_call_id or \"\"\n            if self._tool_message_conversion == \"extract_text\":\n                content = message.extract_text(sep=\"\\n\")\n            else:\n                content = message.content\n            output = _message_content_to_function_output_items(content)\n\n            return [\n                {\n                    \"call_id\": call_id,\n                    \"output\": output,\n                    \"type\": \"function_call_output\",\n                }\n            ]\n\n        result: list[ResponseInputItemParam] = []\n\n        # user/system/assistant → message input item\n        if len(message.content) > 0:\n            # Split into two kinds of blocks: contiguous non-ThinkPart message blocks, and\n            # contiguous ThinkPart groups (grouped by the same `encrypted` value)\n            pending_parts: list[ContentPart] = []\n\n            def flush_pending_parts() -> None:\n                if not pending_parts:\n                    return\n                if role == \"assistant\":\n                    # the \"id\" key is missing by purpose\n                    result.append(\n                        cast(\n                            ResponseOutputMessageParam,\n                            {\n                                \"content\": _content_parts_to_output_items(pending_parts),\n                                \"role\": role,\n                                \"type\": \"message\",\n                            },\n                        )\n                    )\n                else:\n                    result.append(\n                        {\n                            \"content\": _content_parts_to_input_items(pending_parts),\n                            \"role\": role,\n                            \"type\": \"message\",\n                        }\n                    )\n                pending_parts.clear()\n\n            i = 0\n            n = len(message.content)\n            while i < n:\n                part = message.content[i]\n                if isinstance(part, ThinkPart):\n                    # Flush accumulated non-reasoning parts first\n                    flush_pending_parts()\n                    # Aggregate consecutive ThinkPart items with the same `encrypted` value\n                    encrypted_value = part.encrypted\n                    summaries = [{\"type\": \"summary_text\", \"text\": part.think or \"\"}]\n                    i += 1\n                    while i < n:\n                        next_part = message.content[i]\n                        if not isinstance(next_part, ThinkPart):\n                            break\n                        if next_part.encrypted != encrypted_value:\n                            break\n                        summaries.append({\"type\": \"summary_text\", \"text\": next_part.think or \"\"})\n                        i += 1\n                    result.append(\n                        cast(\n                            ResponseReasoningItemParam,\n                            {\n                                \"summary\": summaries,\n                                \"type\": \"reasoning\",\n                                \"encrypted_content\": encrypted_value,\n                            },\n                        )\n                    )\n                else:\n                    pending_parts.append(part)\n                    i += 1\n\n            # Handle remaining trailing non-reasoning parts\n            flush_pending_parts()\n\n        for tool_call in message.tool_calls or []:\n            result.append(\n                {\n                    \"arguments\": tool_call.function.arguments or \"{}\",\n                    \"call_id\": tool_call.id,\n                    \"name\": tool_call.function.name,\n                    \"type\": \"function_call\",\n                }\n            )\n\n        return result\n\n\ndef _convert_tool(tool: Tool) -> ToolParam:\n    \"\"\"Convert a Kosong tool to an OpenAI Responses tool.\"\"\"\n    return {\n        \"type\": \"function\",\n        \"name\": tool.name,\n        \"description\": tool.description,\n        \"parameters\": tool.parameters,\n        \"strict\": False,\n    }\n\n\ndef _content_parts_to_input_items(parts: list[ContentPart]) -> ResponseInputMessageContentListParam:\n    \"\"\"Map internal ContentPart list → ResponseInputMessageContentListParam items.\"\"\"\n    items: ResponseInputMessageContentListParam = []\n    for part in parts:\n        if isinstance(part, TextPart):\n            if part.text:\n                items.append({\"type\": \"input_text\", \"text\": part.text})\n        elif isinstance(part, ImageURLPart):\n            # default detail\n            url = part.image_url.url\n            items.append(\n                {\n                    \"type\": \"input_image\",\n                    \"detail\": \"auto\",\n                    \"image_url\": url,\n                }\n            )\n        elif isinstance(part, AudioURLPart):\n            mapped = _map_audio_url_to_input_item(part.audio_url.url)\n            if mapped is not None:\n                items.append(mapped)\n        else:\n            # Unknown content – ignore\n            continue\n    return items\n\n\ndef _content_parts_to_output_items(parts: list[ContentPart]) -> list[ResponseOutputTextParam]:\n    \"\"\"Map internal ContentPart list → ResponseOutputTextParam list items.\"\"\"\n    items: list[ResponseOutputTextParam] = []\n    for part in parts:\n        if isinstance(part, TextPart):\n            if part.text:\n                items.append({\"type\": \"output_text\", \"text\": part.text, \"annotations\": []})\n        else:\n            # Unknown content – ignore\n            continue\n    return items\n\n\ndef _message_content_to_function_output_items(\n    content: str | list[ContentPart],\n) -> str | ResponseFunctionCallOutputItemListParam:\n    \"\"\"Map ContentPart list → ResponseFunctionCallOutputItemListParam items.\"\"\"\n    output: str | ResponseFunctionCallOutputItemListParam\n    # If tool_result_process is `extract_text`, patch all text parts into one string\n    if isinstance(content, str):\n        output = content\n    else:\n        items: ResponseFunctionCallOutputItemListParam = []\n        for part in content:\n            if isinstance(part, TextPart):\n                if part.text:\n                    items.append({\"type\": \"input_text\", \"text\": part.text})\n            elif isinstance(part, ImageURLPart):\n                url = part.image_url.url\n                items.append({\"type\": \"input_image\", \"image_url\": url})\n            elif isinstance(part, AudioURLPart):\n                mapped = _map_audio_url_to_file_content(part.audio_url.url)\n                if mapped is not None:\n                    items.append(mapped)\n            else:\n                continue\n        output = items\n    return output\n\n\ndef _map_audio_url_to_input_item(url: str) -> ResponseInputFileParam | None:\n    \"\"\"Map audio URL/data URI to an input content item (always an input_file).\n\n    OpenAI Responses message content no longer accepts `input_audio`, so both inline\n    data and remote URLs are converted to `input_file` items instead.\n    \"\"\"\n    if url.startswith(\"data:audio/\"):\n        try:\n            header, b64 = url.split(\",\", 1)\n            subtype = header.split(\"/\")[1].split(\";\")[0].lower()\n            ext = \"mp3\" if subtype in {\"mp3\", \"mpeg\"} else (\"wav\" if subtype == \"wav\" else None)\n            if ext is None:\n                return None\n            item: ResponseInputFileParam = {\"type\": \"input_file\", \"file_data\": b64}\n            item[\"filename\"] = f\"inline.{ext}\"\n            return item\n        except Exception:\n            return None\n    if url.startswith(\"http://\") or url.startswith(\"https://\"):\n        return {\"type\": \"input_file\", \"file_url\": url}\n    return None\n\n\ndef _map_audio_url_to_file_content(url: str) -> ResponseInputFileContentParam | None:\n    \"\"\"Map audio URL/data URI to a file content item for function_call_output.\"\"\"\n    if url.startswith(\"http://\") or url.startswith(\"https://\"):\n        return {\"type\": \"input_file\", \"file_url\": url}\n    if url.startswith(\"data:audio/\"):\n        try:\n            _, b64 = url.split(\",\", 1)\n            # We can attach filename optionally; Responses accepts file_data only\n            return {\"type\": \"input_file\", \"file_data\": b64}\n        except Exception:\n            return None\n    return None\n\n\nclass OpenAIResponsesStreamedMessage:\n    def __init__(self, response: Response | AsyncStream[ResponseStreamEvent]):\n        if isinstance(response, Response):\n            self._iter = self._convert_non_stream_response(response)\n        else:\n            self._iter = self._convert_stream_response(response)\n        self._id: str | None = None\n        self._usage: ResponseUsage | None = None\n\n    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    @property\n    def id(self) -> str | None:\n        return self._id\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        if self._usage:\n            cached = 0\n            other_input = self._usage.input_tokens\n            if self._usage.input_tokens_details and self._usage.input_tokens_details.cached_tokens:\n                cached = self._usage.input_tokens_details.cached_tokens\n                other_input -= cached\n            return TokenUsage(\n                input_other=other_input,\n                output=self._usage.output_tokens,\n                input_cache_read=cached,\n            )\n        return None\n\n    async def _convert_non_stream_response(\n        self, response: Response\n    ) -> AsyncIterator[StreamedMessagePart]:\n        \"\"\"Convert a non-streaming Responses API result into message parts.\"\"\"\n        self._id = response.id\n        self._usage = response.usage\n        for item in response.output:\n            if item.type == \"message\":\n                for content in item.content or []:\n                    if content.type == \"output_text\":\n                        yield TextPart(text=content.text)\n            elif item.type == \"function_call\":\n                yield ToolCall(\n                    id=item.call_id or str(uuid.uuid4()),\n                    function=ToolCall.FunctionBody(\n                        name=item.name,\n                        arguments=item.arguments,\n                    ),\n                )\n            elif item.type == \"reasoning\":\n                for summary in item.summary:\n                    yield ThinkPart(\n                        think=summary.text,\n                        encrypted=item.encrypted_content,\n                    )\n\n    async def _convert_stream_response(\n        self, response: AsyncStream[ResponseStreamEvent]\n    ) -> AsyncIterator[StreamedMessagePart]:\n        \"\"\"Convert streaming Responses events into message parts.\"\"\"\n        try:\n            async for chunk in response:\n                if chunk.type == \"response.output_text.delta\":\n                    yield TextPart(text=chunk.delta)\n                elif chunk.type == \"response.output_item.added\":\n                    item = chunk.item\n                    self._id = item.id\n                    if item.type == \"function_call\":\n                        yield ToolCall(\n                            id=item.call_id or str(uuid.uuid4()),\n                            function=ToolCall.FunctionBody(\n                                name=item.name,\n                                arguments=item.arguments,\n                            ),\n                        )\n                elif chunk.type == \"response.output_item.done\":\n                    item = chunk.item\n                    self._id = item.id\n                    if item.type == \"reasoning\":\n                        yield ThinkPart(think=\"\", encrypted=item.encrypted_content)\n                elif chunk.type == \"response.function_call_arguments.delta\":\n                    yield ToolCallPart(arguments_part=chunk.delta)\n                elif chunk.type == \"response.reasoning_summary_part.added\":\n                    yield ThinkPart(think=\"\")\n                elif chunk.type == \"response.reasoning_summary_text.delta\":\n                    yield ThinkPart(think=chunk.delta)\n                elif chunk.type == \"response.completed\":\n                    self._usage = chunk.response.usage\n        except (OpenAIError, httpx.HTTPError) as e:\n            raise convert_error(e) from e\n\n\nif __name__ == \"__main__\":\n\n    async def _dev_main():\n        # Non-streaming example\n        chat = OpenAIResponses(model=\"gpt-5-codex\", stream=True)\n        system_prompt = \"You are a helpful assistant.\"\n        history = [Message(role=\"user\", content=\"Hello, how are you?\")]\n\n        from kosong import generate\n\n        result = await generate(chat, system_prompt, [], history)\n        print(result.message)\n        print(result.usage)\n        history.append(result.message)\n\n        # Streaming example with tools\n        tools = [\n            Tool(\n                name=\"get_weather\",\n                description=\"Get the weather\",\n                parameters={\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"city\": {\n                            \"type\": \"string\",\n                            \"description\": \"The city to get the weather for.\",\n                        },\n                    },\n                },\n            )\n        ]\n        history.append(Message(role=\"user\", content=\"What's the weather in Beijing?\"))\n        result = await generate(chat, system_prompt, tools, history)\n        print(result.message)\n        print(result.usage)\n        history.append(result.message)\n        for tool_call in result.message.tool_calls or []:\n            assert tool_call.function.name == \"get_weather\"\n            history.append(Message(role=\"tool\", tool_call_id=tool_call.id, content=\"Sunny\"))\n        result = await generate(chat, system_prompt, tools, history)\n        print(result.message)\n        print(result.usage)\n\n    import asyncio\n\n    from dotenv import load_dotenv\n\n    load_dotenv(override=True)\n    asyncio.run(_dev_main())\n"
  },
  {
    "path": "packages/kosong/src/kosong/contrib/context/__init__.py",
    "content": ""
  },
  {
    "path": "packages/kosong/src/kosong/contrib/context/linear.py",
    "content": "import asyncio\nimport json\nfrom pathlib import Path\nfrom typing import IO, Protocol, runtime_checkable\n\nfrom kosong.message import Message\n\n\nclass LinearContext:\n    \"\"\"\n    A context that contains a linear history of messages.\n    \"\"\"\n\n    def __init__(self, storage: \"LinearStorage\"):\n        self._storage = storage\n\n    @property\n    def history(self) -> list[Message]:\n        return self._storage.messages\n\n    @property\n    def token_count(self) -> int:\n        return self._storage.token_count\n\n    async def add_message(self, message: Message):\n        await self._storage.append_message(message)\n\n    async def mark_token_count(self, token_count: int):\n        await self._storage.mark_token_count(token_count)\n\n\n@runtime_checkable\nclass LinearStorage(Protocol):\n    @property\n    def messages(self) -> list[Message]:\n        \"\"\"\n        All messages in the storage.\n        \"\"\"\n        ...\n\n    @property\n    def token_count(self) -> int:\n        \"\"\"\n        The total token count of the messages in the storage.\n        This may not be the precise token count, depending on the caller of `mark_token_count`.\n        \"\"\"\n        ...\n\n    async def append_message(self, message: Message) -> None: ...\n    async def mark_token_count(self, token_count: int) -> None: ...\n\n\nclass MemoryLinearStorage:\n    \"\"\"\n    A linear storage that stores messages in memory, only for testing.\n    \"\"\"\n\n    def __init__(self):\n        self._messages: list[Message] = []\n        self._token_count: int | None = None\n\n    @property\n    def messages(self) -> list[Message]:\n        return self._messages\n\n    @property\n    def token_count(self) -> int:\n        return self._token_count or 0\n\n    async def append_message(self, message: Message):\n        self._messages.append(message)\n\n    async def mark_token_count(self, token_count: int):\n        self._token_count = token_count\n\n\nclass JsonlLinearStorage(MemoryLinearStorage):\n    \"\"\"\n    A linear storage that stores messages in a JSONL file.\n    \"\"\"\n\n    def __init__(self, path: Path | str):\n        super().__init__()\n        self._path = path if isinstance(path, Path) else Path(path)\n        self._file: IO[str] | None = None\n\n    async def restore(self):\n        \"\"\"Restore all messages from the JSONL file.\"\"\"\n        if self._messages:\n            raise RuntimeError(\"The storage is already modified\")\n        if not self._path.exists():\n            return\n\n        def _restore():\n            with open(self._path, encoding=\"utf-8\") as f:\n                for line in f:\n                    if not line.strip():\n                        continue\n                    line_json = json.loads(line)\n                    if \"token_count\" in line_json:\n                        self._token_count = line_json[\"token_count\"]\n                        continue\n                    message = Message.model_validate(line_json)\n                    self._messages.append(message)\n\n        await asyncio.to_thread(_restore)\n\n    def _get_file(self) -> IO[str]:\n        if self._file is None:\n            self._file = open(self._path, \"a\", encoding=\"utf-8\")  # noqa: SIM115\n        return self._file\n\n    def __del__(self):\n        if self._file:\n            self._file.close()\n\n    async def append_message(self, message: Message):\n        await super().append_message(message)\n\n        def _write():\n            file = self._get_file()\n            json.dump(\n                message.model_dump(exclude_none=True),\n                file,\n                ensure_ascii=False,\n                separators=(\",\", \":\"),\n            )\n            file.write(\"\\n\")\n\n        await asyncio.to_thread(_write)\n\n    async def mark_token_count(self, token_count: int):\n        await super().mark_token_count(token_count)\n\n        def _write():\n            file = self._get_file()\n            json.dump(\n                {\"role\": \"_usage\", \"token_count\": token_count},\n                file,\n                ensure_ascii=False,\n                separators=(\",\", \":\"),\n            )\n            file.write(\"\\n\")\n\n        await asyncio.to_thread(_write)\n"
  },
  {
    "path": "packages/kosong/src/kosong/message.py",
    "content": "from abc import ABC\nfrom typing import Any, ClassVar, Literal, cast, override\n\nfrom pydantic import BaseModel, GetCoreSchemaHandler, field_serializer, field_validator\nfrom pydantic_core import core_schema\n\nfrom kosong.utils.typing import JsonType\n\n\nclass MergeableMixin:\n    def merge_in_place(self, other: Any) -> bool:\n        \"\"\"Merge the other part into the current part. Return True if the merge is successful.\"\"\"\n        return False\n\n\nclass ContentPart(BaseModel, ABC, MergeableMixin):\n    \"\"\"\n    A part of a message content.\n\n    This is the abstract base class for all supported content parts. Subclasses must define a `type`\n    field of type `str` and optional other fields specific to the content part.\n\n    For Kosong users, you typically do not need to subclass this directly. Instead, use the provided\n    subclasses like `TextPart`, `ThinkPart`, `ImageURLPart`, etc. Unless you are implementing custom\n    `ChatProvider`s that supports new content part types.\n    \"\"\"\n\n    __content_part_registry: ClassVar[dict[str, type[\"ContentPart\"]]] = {}\n\n    type: str\n    ...  # to be added by subclasses\n\n    def __init_subclass__(cls, **kwargs: Any) -> None:\n        super().__init_subclass__(**kwargs)\n\n        invalid_subclass_error_msg = (\n            f\"ContentPart subclass {cls.__name__} must have a `type` field of type `str`\"\n        )\n\n        type_value = getattr(cls, \"type\", None)\n        if type_value is None or not isinstance(type_value, str):\n            raise ValueError(invalid_subclass_error_msg)\n\n        cls.__content_part_registry[type_value] = cls\n\n    @classmethod\n    def __get_pydantic_core_schema__(\n        cls, source_type: Any, handler: GetCoreSchemaHandler\n    ) -> core_schema.CoreSchema:\n        # If we're dealing with the base ContentPart class, use custom validation\n        if cls.__name__ == \"ContentPart\":\n\n            def validate_content_part(value: Any) -> Any:\n                # if it's already an instance of a ContentPart subclass, return it\n                if hasattr(value, \"__class__\") and issubclass(value.__class__, cls):\n                    return value\n\n                # if it's a dict with a type field, dispatch to the appropriate subclass\n                if isinstance(value, dict) and \"type\" in value:\n                    type_value: Any | None = cast(dict[str, Any], value).get(\"type\")\n                    if not isinstance(type_value, str):\n                        raise ValueError(f\"Cannot validate {value} as ContentPart\")\n                    target_class = cls.__content_part_registry[type_value]\n                    return target_class.model_validate(value)\n\n                raise ValueError(f\"Cannot validate {value} as ContentPart\")\n\n            return core_schema.no_info_plain_validator_function(validate_content_part)\n\n        # for subclasses, use the default schema\n        return handler(source_type)\n\n\nclass TextPart(ContentPart):\n    \"\"\"\n    >>> TextPart(text=\"Hello, world!\").model_dump()\n    {'type': 'text', 'text': 'Hello, world!'}\n    \"\"\"\n\n    type: str = \"text\"\n    text: str\n\n    @override\n    def merge_in_place(self, other: Any) -> bool:\n        if not isinstance(other, TextPart):\n            return False\n        self.text += other.text\n        return True\n\n\nclass ThinkPart(ContentPart):\n    \"\"\"\n    >>> ThinkPart(think=\"I think I need to think about this.\").model_dump()\n    {'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}\n    \"\"\"\n\n    type: str = \"think\"\n    think: str\n    encrypted: str | None = None\n    \"\"\"Encrypted thinking content, or signature.\"\"\"\n\n    @override\n    def merge_in_place(self, other: Any) -> bool:\n        if not isinstance(other, ThinkPart):\n            return False\n        if self.encrypted:\n            return False\n        self.think += other.think\n        if other.encrypted:\n            self.encrypted = other.encrypted\n        return True\n\n\nclass ImageURLPart(ContentPart):\n    \"\"\"\n    >>> ImageURLPart(\n    ...     image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")\n    ... ).model_dump()\n    {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png', 'id': None}}\n    \"\"\"\n\n    class ImageURL(BaseModel):\n        \"\"\"Image URL payload.\"\"\"\n\n        url: str\n        \"\"\"The URL of the image, can be data URI scheme like `data:image/png;base64,...`.\"\"\"\n        id: str | None = None\n        \"\"\"The ID of the image, to allow LLMs to distinguish different images.\"\"\"\n\n    type: str = \"image_url\"\n    image_url: ImageURL\n\n\nclass AudioURLPart(ContentPart):\n    \"\"\"\n    >>> AudioURLPart(\n    ...     audio_url=AudioURLPart.AudioURL(url=\"https://example.com/audio.mp3\")\n    ... ).model_dump()\n    {'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}\n    \"\"\"\n\n    class AudioURL(BaseModel):\n        \"\"\"Audio URL payload.\"\"\"\n\n        url: str\n        \"\"\"The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`.\"\"\"\n        id: str | None = None\n        \"\"\"The ID of the audio, to allow LLMs to distinguish different audios.\"\"\"\n\n    type: str = \"audio_url\"\n    audio_url: AudioURL\n\n\nclass VideoURLPart(ContentPart):\n    \"\"\"\n    >>> VideoURLPart(\n    ...     video_url=VideoURLPart.VideoURL(url=\"https://example.com/video.mp4\")\n    ... ).model_dump()\n    {'type': 'video_url', 'video_url': {'url': 'https://example.com/video.mp4', 'id': None}}\n    \"\"\"\n\n    class VideoURL(BaseModel):\n        \"\"\"Video URL payload.\"\"\"\n\n        url: str\n        \"\"\"The URL of the video, can be data URI scheme like `data:video/mp4;base64,...`.\"\"\"\n        id: str | None = None\n        \"\"\"The ID of the video, to allow LLMs to distinguish different videos.\"\"\"\n\n    type: str = \"video_url\"\n    video_url: VideoURL\n\n\nclass ToolCall(BaseModel, MergeableMixin):\n    \"\"\"\n    A tool call requested by the assistant.\n\n    >>> ToolCall(\n    ...     id=\"123\",\n    ...     function=ToolCall.FunctionBody(name=\"function\", arguments=\"{}\"),\n    ... ).model_dump(exclude_none=True)\n    {'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}}\n    \"\"\"\n\n    class FunctionBody(BaseModel):\n        \"\"\"Tool call function body.\"\"\"\n\n        name: str\n        \"\"\"The name of the tool to be called.\"\"\"\n        arguments: str | None\n        \"\"\"Arguments of the tool call in JSON string format.\"\"\"\n\n    type: Literal[\"function\"] = \"function\"\n\n    id: str\n    \"\"\"The ID of the tool call.\"\"\"\n    function: FunctionBody\n    \"\"\"The function body of the tool call.\"\"\"\n    extras: dict[str, JsonType] | None = None\n    \"\"\"Extra information about the tool call.\"\"\"\n\n    @override\n    def merge_in_place(self, other: Any) -> bool:\n        if not isinstance(other, ToolCallPart):\n            return False\n        if self.function.arguments is None:\n            self.function.arguments = other.arguments_part\n        else:\n            self.function.arguments += other.arguments_part or \"\"\n        return True\n\n\nclass ToolCallPart(BaseModel, MergeableMixin):\n    \"\"\"A part of the tool call.\"\"\"\n\n    arguments_part: str | None = None\n    \"\"\"A part of the arguments of the tool call.\"\"\"\n\n    @override\n    def merge_in_place(self, other: Any) -> bool:\n        if not isinstance(other, ToolCallPart):\n            return False\n        if self.arguments_part is None:\n            self.arguments_part = other.arguments_part\n        else:\n            self.arguments_part += other.arguments_part or \"\"\n        return True\n\n\ntype Role = Literal[\n    # for OpenAI API, this should be converted to `developer`\n    # OpenAI & Kimi support system messages in the middle of the conversation.\n    # Anthropic only support system messages at the beginning https://docs.claude.com/en/api/messages#body-messages\n    # In this case, we map `system` message to a `user` message wrapped in `<system></system>` tags.\n    \"system\",\n    \"user\",\n    \"assistant\",\n    \"tool\",\n]\n\"\"\"The role of a message sender.\"\"\"\n\n\nclass Message(BaseModel):\n    \"\"\"A message in a conversation.\"\"\"\n\n    role: Role\n    \"\"\"The role of the message sender.\"\"\"\n\n    name: str | None = None\n\n    content: list[ContentPart]\n    \"\"\"\n    The content of the message.\n    Empty list `[]` will be interpreted as no content.\n    \"\"\"\n\n    tool_calls: list[ToolCall] | None = None\n    \"\"\"Tool calls requested by the assistant in this message.\"\"\"\n\n    tool_call_id: str | None = None\n    \"\"\"The ID of the tool call if this message is a tool response.\"\"\"\n\n    partial: bool | None = None\n\n    @field_serializer(\"content\")\n    def _serialize_content(self, content: list[ContentPart]) -> str | list[dict[str, Any]] | None:\n        if len(content) == 1 and isinstance(content[0], TextPart):\n            return content[0].text\n        return [part.model_dump() for part in content]\n\n    @field_validator(\"content\", mode=\"before\")\n    @classmethod\n    def _coerce_none_content(cls, value: Any) -> Any:\n        if value is None:\n            return []\n        if isinstance(value, str):\n            return [TextPart(text=value)]\n        return value\n\n    def __init__(\n        self,\n        *,\n        role: Role,\n        content: list[ContentPart] | ContentPart | str,\n        tool_calls: list[ToolCall] | None = None,\n        tool_call_id: str | None = None,\n        **data: Any,\n    ) -> None:\n        if isinstance(content, str):\n            content = [TextPart(text=content)]\n        elif isinstance(content, ContentPart):\n            content = [content]\n        super().__init__(\n            role=role,\n            content=content,\n            tool_calls=tool_calls,\n            tool_call_id=tool_call_id,\n            **data,\n        )\n\n    def extract_text(self, sep: str = \"\") -> str:\n        \"\"\"Extract and concatenate all text parts in the message content.\"\"\"\n        return sep.join(part.text for part in self.content if isinstance(part, TextPart))\n"
  },
  {
    "path": "packages/kosong/src/kosong/py.typed",
    "content": ""
  },
  {
    "path": "packages/kosong/src/kosong/tooling/__init__.py",
    "content": "from abc import ABC, abstractmethod\nfrom asyncio import Future\nfrom typing import Any, ClassVar, Protocol, Self, cast, override, runtime_checkable\n\nimport jsonschema\nimport pydantic\nfrom pydantic import BaseModel, GetCoreSchemaHandler, model_validator\nfrom pydantic.json_schema import GenerateJsonSchema\nfrom pydantic_core import core_schema\n\nfrom kosong.message import ContentPart, ToolCall\nfrom kosong.utils.jsonschema import deref_json_schema\nfrom kosong.utils.typing import JsonType\n\ntype ParametersType = dict[str, Any]\n\n\nclass Tool(BaseModel):\n    \"\"\"The definition of a tool that can be recognized by the model.\"\"\"\n\n    name: str\n    \"\"\"The name of the tool.\"\"\"\n\n    description: str\n    \"\"\"The description of the tool.\"\"\"\n\n    parameters: ParametersType\n    \"\"\"The parameters of the tool, in JSON Schema format.\"\"\"\n\n    @model_validator(mode=\"after\")\n    def _validate_parameters(self) -> Self:\n        jsonschema.validate(self.parameters, jsonschema.Draft202012Validator.META_SCHEMA)\n        return self\n\n\nclass DisplayBlock(BaseModel, ABC):\n    \"\"\"\n    A block of content to be displayed to the user.\n\n    Similar to `ContentPart`, but scoped to user-facing UI.\n    `ContentPart` is for model-facing message content; `DisplayBlock` is for tool/UI extensions.\n\n    Unlike `ContentPart`, Kosong users may directly subclass `DisplayBlock` to define custom\n    display blocks for their applications.\n    \"\"\"\n\n    __display_block_registry: ClassVar[dict[str, type[\"DisplayBlock\"]]] = {}\n\n    type: str\n    ...  # to be added by subclasses\n\n    def __init_subclass__(cls, **kwargs: Any) -> None:\n        super().__init_subclass__(**kwargs)\n\n        invalid_subclass_error_msg = (\n            f\"DisplayBlock subclass {cls.__name__} must have a `type` field of type `str`\"\n        )\n\n        type_value = getattr(cls, \"type\", None)\n        if type_value is None or not isinstance(type_value, str):\n            raise ValueError(invalid_subclass_error_msg)\n\n        cls.__display_block_registry[type_value] = cls\n\n    @classmethod\n    def __get_pydantic_core_schema__(\n        cls, source_type: Any, handler: GetCoreSchemaHandler\n    ) -> core_schema.CoreSchema:\n        # If we're dealing with the base DisplayBlock class, use custom validation\n        if cls.__name__ == \"DisplayBlock\":\n\n            def validate_display_block(value: Any) -> Any:\n                # if it's already an instance of a DisplayBlock subclass, return it\n                if hasattr(value, \"__class__\") and issubclass(value.__class__, cls):\n                    return value\n\n                # if it's a dict with a type field, dispatch to the appropriate subclass\n                if isinstance(value, dict) and \"type\" in value:\n                    type_value: Any | None = cast(dict[str, Any], value).get(\"type\")\n                    if not isinstance(type_value, str):\n                        raise ValueError(f\"Cannot validate {value} as DisplayBlock\")\n                    target_class = cls.__display_block_registry.get(type_value)\n                    if target_class is None:\n                        data = {k: v for k, v in cast(dict[str, Any], value).items() if k != \"type\"}\n                        return UnknownDisplayBlock.model_validate(\n                            {\"type\": type_value, \"data\": data}\n                        )\n                    return target_class.model_validate(value)\n\n                raise ValueError(f\"Cannot validate {value} as DisplayBlock\")\n\n            return core_schema.no_info_plain_validator_function(validate_display_block)\n\n        # for subclasses, use the default schema\n        return handler(source_type)\n\n\nclass UnknownDisplayBlock(DisplayBlock):\n    \"\"\"Fallback display block for unknown types.\"\"\"\n\n    type: str = \"unknown\"\n    data: JsonType\n\n\nclass BriefDisplayBlock(DisplayBlock):\n    \"\"\"A brief display block with plain string content.\"\"\"\n\n    type: str = \"brief\"\n    text: str\n\n\nclass ToolReturnValue(BaseModel):\n    \"\"\"The return type of a callable tool.\"\"\"\n\n    is_error: bool\n    \"\"\"Whether the tool call resulted in an error.\"\"\"\n\n    # For model\n    output: str | list[ContentPart]\n    \"\"\"The output content returned by the tool.\"\"\"\n    message: str\n    \"\"\"An explanatory message to be given to the model.\"\"\"\n\n    # For user\n    display: list[DisplayBlock]\n    \"\"\"The content blocks to be displayed to the user.\"\"\"\n\n    # For debugging/testing\n    extras: dict[str, JsonType] | None = None\n\n    @property\n    def brief(self) -> str:\n        \"\"\"Get the brief display block data, if any.\"\"\"\n        for block in self.display:\n            if isinstance(block, BriefDisplayBlock):\n                return block.text\n        return \"\"\n\n\nclass ToolOk(ToolReturnValue):\n    \"\"\"Subclass of `ToolReturnValue` representing a successful tool call.\"\"\"\n\n    def __init__(\n        self,\n        *,\n        output: str | ContentPart | list[ContentPart],\n        message: str = \"\",\n        brief: str = \"\",\n    ) -> None:\n        super().__init__(\n            is_error=False,\n            output=([output] if isinstance(output, ContentPart) else output),\n            message=message,\n            display=[BriefDisplayBlock(text=brief)] if brief else [],\n        )\n\n\nclass ToolError(ToolReturnValue):\n    \"\"\"Subclass of `ToolReturnValue` representing a failed tool call.\"\"\"\n\n    def __init__(\n        self, *, message: str, brief: str, output: str | ContentPart | list[ContentPart] = \"\"\n    ):\n        super().__init__(\n            is_error=True,\n            output=([output] if isinstance(output, ContentPart) else output),\n            message=message,\n            display=[BriefDisplayBlock(text=brief)] if brief else [],\n        )\n\n\nclass CallableTool(Tool, ABC):\n    \"\"\"\n    The abstract base class of tools that can be called as callables.\n\n    The tool will be called with the arguments provided in the `ToolCall`.\n    If the arguments are given as a JSON array, it will be unpacked into positional arguments.\n    If the arguments are given as a JSON object, it will be unpacked into keyword arguments.\n    Otherwise, the arguments will be passed as a single argument.\n    \"\"\"\n\n    @property\n    def base(self) -> Tool:\n        \"\"\"The base tool definition.\"\"\"\n        return self\n\n    async def call(self, arguments: JsonType) -> ToolReturnValue:\n        from kosong.tooling.error import ToolValidateError\n\n        try:\n            jsonschema.validate(arguments, self.parameters)\n        except jsonschema.ValidationError as e:\n            return ToolValidateError(str(e))\n\n        if isinstance(arguments, list):\n            ret = await self.__call__(*arguments)\n        elif isinstance(arguments, dict):\n            ret = await self.__call__(**arguments)\n        else:\n            ret = await self.__call__(arguments)\n        if not isinstance(ret, ToolReturnValue):  # type: ignore[reportUnnecessaryIsInstance]\n            # let's do not trust the return type of the tool\n            ret = ToolError(\n                message=f\"Invalid return type: {type(ret)}\",\n                brief=\"Invalid return type\",\n            )\n        return ret\n\n    @abstractmethod\n    async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:\n        \"\"\"\n        @public\n\n        The implementation of the callable tool.\n        \"\"\"\n        ...\n\n\nclass _GenerateJsonSchemaNoTitles(GenerateJsonSchema):\n    \"\"\"Custom JSON schema generator that omits titles.\"\"\"\n\n    @override\n    def field_title_should_be_set(self, schema) -> bool:  # type: ignore[reportMissingParameterType]\n        return False\n\n    @override\n    def _update_class_schema(self, json_schema, cls, config) -> None:  # type: ignore[reportMissingParameterType]\n        super()._update_class_schema(json_schema, cls, config)\n        json_schema.pop(\"title\", None)\n\n\nclass CallableTool2[Params: BaseModel](ABC):\n    \"\"\"\n    The abstract base class of tools that can be called as callables, with typed parameters.\n\n    The tool will be called with the arguments provided in the `ToolCall`.\n    The arguments must be a JSON object, and will be validated by Pydantic to the `Params` type.\n    \"\"\"\n\n    name: str\n    \"\"\"The name of the tool.\"\"\"\n    description: str\n    \"\"\"The description of the tool.\"\"\"\n    params: type[Params]\n    \"\"\"The Pydantic model type of the tool parameters.\"\"\"\n\n    def __init__(\n        self,\n        name: str | None = None,\n        description: str | None = None,\n        params: type[Params] | None = None,\n    ) -> None:\n        cls = self.__class__\n\n        self.name = name or getattr(cls, \"name\", \"\")\n        if not self.name:\n            raise ValueError(\n                \"Tool name must be provided either as class variable or constructor argument\"\n            )\n        if not isinstance(self.name, str):  # type: ignore[reportUnnecessaryIsInstance]\n            raise ValueError(\"Tool name must be a string\")\n\n        self.description = description or getattr(cls, \"description\", \"\")\n        if not self.description:\n            raise ValueError(\n                \"Tool description must be provided either as class variable or constructor argument\"\n            )\n        if not isinstance(self.description, str):  # type: ignore[reportUnnecessaryIsInstance]\n            raise ValueError(\"Tool description must be a string\")\n\n        self.params = params or getattr(cls, \"params\", None)  # type: ignore\n        if not self.params:\n            raise ValueError(\n                \"Tool param must be provided either as class variable or constructor argument\"\n            )\n        if not isinstance(self.params, type) or not issubclass(self.params, BaseModel):  # type: ignore[reportUnnecessaryIsInstance]\n            raise ValueError(\"Tool params must be a subclass of pydantic.BaseModel\")\n\n        self._base = Tool(\n            name=self.name,\n            description=self.description,\n            parameters=deref_json_schema(\n                self.params.model_json_schema(schema_generator=_GenerateJsonSchemaNoTitles)\n            ),\n        )\n\n    @property\n    def base(self) -> Tool:\n        \"\"\"The base tool definition.\"\"\"\n        return self._base\n\n    async def call(self, arguments: JsonType) -> ToolReturnValue:\n        from kosong.tooling.error import ToolValidateError\n\n        try:\n            params = self.params.model_validate(arguments)\n        except pydantic.ValidationError as e:\n            return ToolValidateError(str(e))\n\n        ret = await self.__call__(params)\n        if not isinstance(ret, ToolReturnValue):  # type: ignore[reportUnnecessaryIsInstance]\n            # let's do not trust the return type of the tool\n            ret = ToolError(\n                message=f\"Invalid return type: {type(ret)}\",\n                brief=\"Invalid return type\",\n            )\n        return ret\n\n    @abstractmethod\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        \"\"\"\n        @public\n\n        The implementation of the callable tool.\n        \"\"\"\n        ...\n\n\nclass ToolResult(BaseModel):\n    \"\"\"The result of a tool call.\"\"\"\n\n    tool_call_id: str\n    \"\"\"The ID of the tool call.\"\"\"\n    return_value: ToolReturnValue\n    \"\"\"The actual return value of the tool call.\"\"\"\n\n\nToolResultFuture = Future[ToolResult]\ntype HandleResult = ToolResultFuture | ToolResult\n\n\n@runtime_checkable\nclass Toolset(Protocol):\n    \"\"\"\n    The interface of toolsets that can register tools and handle tool calls.\n    \"\"\"\n\n    @property\n    def tools(self) -> list[Tool]:\n        \"\"\"The list of tool definitions registered in this toolset.\"\"\"\n        ...\n\n    def handle(self, tool_call: ToolCall) -> HandleResult:\n        \"\"\"\n        Handle a tool call.\n        The result of the tool call, or the async future of the result, should be returned.\n        The result should be a `ToolReturnValue`.\n\n        This method MUST NOT do any blocking operations because it will be called during\n        consuming the chat response stream.\n        This method MUST NOT raise any exception except for `asyncio.CancelledError`. Any other\n        error should be returned as a `ToolReturnValue` with `is_error=True`.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "packages/kosong/src/kosong/tooling/empty.py",
    "content": "from typing import TYPE_CHECKING\n\nfrom kosong.message import ToolCall\nfrom kosong.tooling import HandleResult, Tool, ToolResult, Toolset\nfrom kosong.tooling.error import ToolNotFoundError\n\nif TYPE_CHECKING:\n\n    def type_check(empty: \"EmptyToolset\"):\n        _: Toolset = empty\n\n\nclass EmptyToolset:\n    \"\"\"A toolset implementation that always contains no tools.\"\"\"\n\n    @property\n    def tools(self) -> list[Tool]:\n        return []\n\n    def handle(self, tool_call: ToolCall) -> HandleResult:\n        return ToolResult(\n            tool_call_id=tool_call.id,\n            return_value=ToolNotFoundError(tool_call.function.name),\n        )\n"
  },
  {
    "path": "packages/kosong/src/kosong/tooling/error.py",
    "content": "from kosong.tooling import ToolError\n\n\nclass ToolNotFoundError(ToolError):\n    \"\"\"The tool was not found.\"\"\"\n\n    def __init__(self, tool_name: str):\n        super().__init__(\n            message=f\"Tool `{tool_name}` not found\",\n            brief=f\"Tool `{tool_name}` not found\",\n        )\n\n\nclass ToolParseError(ToolError):\n    \"\"\"The arguments of the tool are not valid JSON.\"\"\"\n\n    def __init__(self, message: str):\n        super().__init__(\n            message=f\"Error parsing JSON arguments: {message}\",\n            brief=\"Invalid arguments\",\n        )\n\n\nclass ToolValidateError(ToolError):\n    \"\"\"The arguments of the tool are not valid.\"\"\"\n\n    def __init__(self, message: str):\n        super().__init__(\n            message=f\"Error validating JSON arguments: {message}\",\n            brief=\"Invalid arguments\",\n        )\n\n\nclass ToolRuntimeError(ToolError):\n    \"\"\"The tool failed to run.\"\"\"\n\n    def __init__(self, message: str):\n        super().__init__(\n            message=f\"Error running tool: {message}\",\n            brief=\"Tool runtime error\",\n        )\n"
  },
  {
    "path": "packages/kosong/src/kosong/tooling/mcp.py",
    "content": "import mcp.types\n\nimport kosong.message\n\n\ndef convert_mcp_content(part: mcp.types.ContentBlock) -> kosong.message.ContentPart:\n    \"\"\"Convert MCP content block to kosong message content part.\n\n    Raises:\n        ValueError: If the content type or mime type is not supported.\n    \"\"\"\n    match part:\n        case mcp.types.TextContent(text=text):\n            return kosong.message.TextPart(text=text)\n        case mcp.types.ImageContent(data=data, mimeType=mimeType):\n            return kosong.message.ImageURLPart(\n                image_url=kosong.message.ImageURLPart.ImageURL(url=f\"data:{mimeType};base64,{data}\")\n            )\n\n        case mcp.types.AudioContent(data=data, mimeType=mimeType):\n            return kosong.message.AudioURLPart(\n                audio_url=kosong.message.AudioURLPart.AudioURL(url=f\"data:{mimeType};base64,{data}\")\n            )\n        case mcp.types.EmbeddedResource(\n            resource=mcp.types.BlobResourceContents(uri=_uri, mimeType=mimeType, blob=blob)\n        ):\n            mimeType = mimeType or \"application/octet-stream\"\n            if mimeType.startswith(\"image/\"):\n                return kosong.message.ImageURLPart(\n                    type=\"image_url\",\n                    image_url=kosong.message.ImageURLPart.ImageURL(\n                        url=f\"data:{mimeType};base64,{blob}\",\n                    ),\n                )\n            elif mimeType.startswith(\"audio/\"):\n                return kosong.message.AudioURLPart(\n                    type=\"audio_url\",\n                    audio_url=kosong.message.AudioURLPart.AudioURL(\n                        url=f\"data:{mimeType};base64,{blob}\"\n                    ),\n                )\n            elif mimeType.startswith(\"video/\"):\n                return kosong.message.VideoURLPart(\n                    type=\"video_url\",\n                    video_url=kosong.message.VideoURLPart.VideoURL(\n                        url=f\"data:{mimeType};base64,{blob}\"\n                    ),\n                )\n\n            else:\n                raise ValueError(f\"Unsupported mime type: {mimeType}\")\n        case mcp.types.ResourceLink(uri=uri, mimeType=mimeType, description=_description):\n            mimeType = mimeType or \"application/octet-stream\"\n            if mimeType.startswith(\"image/\"):\n                return kosong.message.ImageURLPart(\n                    type=\"image_url\",\n                    image_url=kosong.message.ImageURLPart.ImageURL(url=str(uri)),\n                )\n            elif mimeType.startswith(\"audio/\"):\n                return kosong.message.AudioURLPart(\n                    type=\"audio_url\",\n                    audio_url=kosong.message.AudioURLPart.AudioURL(url=str(uri)),\n                )\n            elif mimeType.startswith(\"video/\"):\n                return kosong.message.VideoURLPart(\n                    type=\"video_url\",\n                    video_url=kosong.message.VideoURLPart.VideoURL(url=str(uri)),\n                )\n            else:\n                raise ValueError(f\"Unsupported mime type: {mimeType}\")\n        case _:\n            raise ValueError(f\"Unsupported MCP tool result part: {part}\")\n"
  },
  {
    "path": "packages/kosong/src/kosong/tooling/simple.py",
    "content": "import asyncio\nimport inspect\nimport json\nfrom collections.abc import Iterable\nfrom typing import TYPE_CHECKING, Any, Self\n\nfrom kosong.message import ToolCall\nfrom kosong.tooling import (\n    CallableTool,\n    CallableTool2,\n    HandleResult,\n    Tool,\n    ToolResult,\n    ToolReturnValue,\n    Toolset,\n)\nfrom kosong.tooling.error import (\n    ToolNotFoundError,\n    ToolParseError,\n    ToolRuntimeError,\n)\nfrom kosong.utils.typing import JsonType\n\nif TYPE_CHECKING:\n\n    def type_check(\n        simple: \"SimpleToolset\",\n    ):\n        _: Toolset = simple\n\n\ntype ToolType = CallableTool | CallableTool2[Any]\n\"\"\"The tool type that can be added to the `SimpleToolset`.\"\"\"\n\n\nclass SimpleToolset:\n    \"\"\"A simple toolset that can handle tool calls concurrently.\"\"\"\n\n    _tool_dict: dict[str, ToolType]\n\n    def __init__(self, tools: Iterable[ToolType] | None = None):\n        \"\"\"Initialize the simple toolset with an optional iterable of tools.\"\"\"\n        self._tool_dict = {}\n        if tools:\n            for tool in tools:\n                self += tool\n\n    def __iadd__(self, tool: ToolType) -> Self:\n        \"\"\"\n        @public\n        Add a tool to the toolset.\n        \"\"\"\n        return_annotation = inspect.signature(tool.__call__).return_annotation\n\n        # Check if the return annotation is ToolReturnValue\n        # Supports both actual type and string annotation (when using\n        # `from __future__ import annotations`)\n        if return_annotation is ToolReturnValue:\n            pass\n        elif isinstance(return_annotation, str):\n            # String annotation - check if it matches ToolReturnValue\n            # Accept any suffix of the full module path, e.g.:\n            #   \"ToolReturnValue\", \"tooling.ToolReturnValue\", \"kosong.tooling.ToolReturnValue\"\n            full_name = f\"{ToolReturnValue.__module__}.ToolReturnValue\"\n            full_parts = full_name.split(\".\")\n            if not any(\n                return_annotation == \".\".join(full_parts[i:]) for i in range(len(full_parts))\n            ):\n                raise TypeError(\n                    f\"Expected tool `{tool.name}` to return `ToolReturnValue`, \"\n                    f\"but got `{return_annotation}`\"\n                )\n        else:\n            raise TypeError(\n                f\"Expected tool `{tool.name}` to return `ToolReturnValue`, \"\n                f\"but got `{return_annotation}`\"\n            )\n\n        self._tool_dict[tool.name] = tool\n        return self\n\n    def __add__(self, tool: ToolType) -> \"SimpleToolset\":\n        \"\"\"\n        @public\n        Return a new toolset with the given tool added.\n        \"\"\"\n        new_toolset = SimpleToolset()\n        new_toolset._tool_dict = self._tool_dict.copy()\n        new_toolset += tool\n        return new_toolset\n\n    def add(self, tool: ToolType) -> None:\n        \"\"\"\n        @public\n        Add a tool to the toolset.\n        \"\"\"\n        self += tool\n\n    def remove(self, tool_name: str) -> None:\n        \"\"\"\n        @public\n        Remove a tool from the toolset.\n        \"\"\"\n        if tool_name not in self._tool_dict:\n            raise KeyError(f\"Tool `{tool_name}` not found in the toolset.\")\n        del self._tool_dict[tool_name]\n\n    @property\n    def tools(self) -> list[Tool]:\n        return [tool.base for tool in self._tool_dict.values()]\n\n    def handle(self, tool_call: ToolCall) -> HandleResult:\n        if tool_call.function.name not in self._tool_dict:\n            return ToolResult(\n                tool_call_id=tool_call.id,\n                return_value=ToolNotFoundError(tool_call.function.name),\n            )\n\n        tool = self._tool_dict[tool_call.function.name]\n\n        try:\n            arguments: JsonType = json.loads(tool_call.function.arguments or \"{}\")\n        except json.JSONDecodeError as e:\n            return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e)))\n\n        async def _call():\n            try:\n                ret = await tool.call(arguments)\n                return ToolResult(tool_call_id=tool_call.id, return_value=ret)\n            except Exception as e:\n                return ToolResult(tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e)))\n\n        return asyncio.create_task(_call())\n"
  },
  {
    "path": "packages/kosong/src/kosong/utils/__init__.py",
    "content": ""
  },
  {
    "path": "packages/kosong/src/kosong/utils/aio.py",
    "content": "import inspect\nfrom collections.abc import Awaitable, Callable\nfrom typing import cast\n\ntype Callback[**Params, Return] = Callable[Params, Awaitable[Return] | Return]\n\n\nasync def callback[**Params, Return](\n    fn: Callback[Params, Return], *args: Params.args, **kwargs: Params.kwargs\n) -> Return:\n    ret = fn(*args, **kwargs)\n    if inspect.isawaitable(ret):\n        return await cast(Awaitable[Return], ret)\n    return ret\n"
  },
  {
    "path": "packages/kosong/src/kosong/utils/jsonschema.py",
    "content": "from __future__ import annotations\n\nimport copy\nfrom typing import cast\n\nfrom kosong.utils.typing import JsonType\n\ntype JsonDict = dict[str, JsonType]\n\n\ndef deref_json_schema(schema: JsonDict) -> JsonDict:\n    \"\"\"Expand local `$ref` entries in a JSON Schema without infinite recursion.\"\"\"\n    # Work on a deep copy so we never mutate the caller's schema.\n    full_schema: JsonDict = copy.deepcopy(schema)\n\n    def resolve_pointer(root: JsonDict, pointer: str) -> JsonType:\n        \"\"\"Resolve a JSON Pointer (e.g. ``#/$defs/User``) inside the schema.\"\"\"\n        parts = pointer.lstrip(\"#/\").split(\"/\")\n        current: JsonType = root\n        try:\n            for part in parts:\n                if isinstance(current, dict):\n                    current = current[part]\n                else:\n                    raise ValueError\n            return current\n        except (KeyError, TypeError, ValueError):\n            raise ValueError(f\"Unable to resolve reference path: {pointer}\") from None\n\n    def traverse(node: JsonType, root: JsonDict) -> JsonType:\n        \"\"\"Recursively traverse every node to inline local references.\"\"\"\n        if isinstance(node, dict):\n            # Replace local ``$ref`` entries with their referenced payload.\n            if \"$ref\" in node and isinstance(node[\"$ref\"], str):\n                ref_path = node[\"$ref\"]\n                if ref_path.startswith(\"#\"):\n                    # Resolve the local reference target.\n                    target = resolve_pointer(root, ref_path)\n                    # Recursively inline the target in case it contains more refs.\n                    ref = traverse(target, root)\n                    if not isinstance(ref, dict):\n                        msg = \"Local $ref must resolve to a JSON object\"\n                        raise TypeError(msg)\n                    node.pop(\"$ref\")\n                    node.update(ref)\n                    return node\n                else:\n                    # Ignore remote references such as http://...\n                    return node\n\n            # Traverse the remaining mapping entries.\n            return {k: traverse(v, root) for k, v in node.items()}\n\n        elif isinstance(node, list):\n            # Traverse list members (e.g. allOf, oneOf, items).\n            return [traverse(item, root) for item in node]\n\n        else:\n            return node\n\n    # Remove definition buckets to keep the resolved schema minimal.\n    resolved = cast(JsonDict, traverse(full_schema, full_schema))\n\n    # Comment these lines if you want to keep the emitted definitions.\n    resolved.pop(\"$defs\", None)\n    resolved.pop(\"definitions\", None)\n\n    return resolved\n"
  },
  {
    "path": "packages/kosong/src/kosong/utils/typing.py",
    "content": "from __future__ import annotations\n\ntype JsonType = None | int | float | str | bool | list[JsonType] | dict[str, JsonType]\n"
  },
  {
    "path": "packages/kosong/tests/api_snapshot_tests/common.py",
    "content": "\"\"\"Common test cases and utilities for snapshot tests.\"\"\"\n\nimport json\nfrom collections.abc import Sequence\nfrom typing import Any, TypedDict\n\nimport respx\n\nfrom kosong.chat_provider import ChatProvider\nfrom kosong.message import ImageURLPart, Message, TextPart, ToolCall\nfrom kosong.tooling import Tool\n\n__all__ = [\n    \"ADD_TOOL\",\n    \"B64_PNG\",\n    \"COMMON_CASES\",\n    \"MUL_TOOL\",\n    \"capture_request\",\n    \"make_anthropic_response\",\n    \"make_chat_completion_response\",\n    \"run_test_cases\",\n]\n\n\ndef make_anthropic_response(model: str = \"claude-sonnet-4-20250514\") -> dict[str, Any]:\n    \"\"\"Common response for Anthropic Messages API.\"\"\"\n    return {\n        \"id\": \"msg_test_123\",\n        \"type\": \"message\",\n        \"role\": \"assistant\",\n        \"model\": model,\n        \"content\": [{\"type\": \"text\", \"text\": \"Hello\"}],\n        \"stop_reason\": \"end_turn\",\n        \"usage\": {\"input_tokens\": 10, \"output_tokens\": 5},\n    }\n\n\ndef make_chat_completion_response(model: str = \"test-model\") -> dict[str, Any]:\n    \"\"\"Common response for OpenAI-compatible chat completion APIs.\"\"\"\n    return {\n        \"id\": \"chatcmpl-test123\",\n        \"object\": \"chat.completion\",\n        \"created\": 1234567890,\n        \"model\": model,\n        \"choices\": [\n            {\n                \"index\": 0,\n                \"message\": {\"role\": \"assistant\", \"content\": \"Hello\"},\n                \"finish_reason\": \"stop\",\n            }\n        ],\n        \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 5, \"total_tokens\": 15},\n    }\n\n\nB64_PNG = (\n    \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA\"\n    \"DUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n)\n\nADD_TOOL = Tool(\n    name=\"add\",\n    description=\"Add two integers.\",\n    parameters={\n        \"type\": \"object\",\n        \"properties\": {\n            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n            \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n        },\n        \"required\": [\"a\", \"b\"],\n    },\n)\n\nMUL_TOOL = Tool(\n    name=\"multiply\",\n    description=\"Multiply two integers.\",\n    parameters={\n        \"type\": \"object\",\n        \"properties\": {\n            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n            \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n        },\n        \"required\": [\"a\", \"b\"],\n    },\n)\n\n\nclass Case(TypedDict, total=False):\n    \"\"\"A test case for chat providers.\"\"\"\n\n    system: str\n    \"\"\"The system prompt.\"\"\"\n    tools: list[Tool]\n    \"\"\"The list of tools.\"\"\"\n    history: list[Message]\n    \"\"\"The message history.\"\"\"\n\n\n# Common test cases shared across providers\nCOMMON_CASES: dict[str, Case] = {\n    \"simple_user_message\": {\n        \"system\": \"You are helpful.\",\n        \"history\": [Message(role=\"user\", content=\"Hello!\")],\n    },\n    \"multi_turn_conversation\": {\n        \"history\": [\n            Message(role=\"user\", content=\"What is 2+2?\"),\n            Message(role=\"assistant\", content=\"2+2 equals 4.\"),\n            Message(role=\"user\", content=\"And 3+3?\"),\n        ],\n    },\n    \"multi_turn_with_system\": {\n        \"system\": \"You are a math tutor.\",\n        \"history\": [\n            Message(role=\"user\", content=\"What is 2+2?\"),\n            Message(role=\"assistant\", content=\"2+2 equals 4.\"),\n            Message(role=\"user\", content=\"And 3+3?\"),\n        ],\n    },\n    \"image_url\": {\n        \"history\": [\n            Message(\n                role=\"user\",\n                content=[\n                    TextPart(text=\"What's in this image?\"),\n                    ImageURLPart(\n                        image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")\n                    ),\n                ],\n            )\n        ],\n    },\n    \"tool_definition\": {\n        \"history\": [Message(role=\"user\", content=\"Add 2 and 3\")],\n        \"tools\": [ADD_TOOL, MUL_TOOL],\n    },\n    \"tool_call\": {\n        \"history\": [\n            Message(role=\"user\", content=\"Add 2 and 3\"),\n            Message(\n                role=\"assistant\",\n                content=\"I'll add those numbers for you.\",\n                tool_calls=[\n                    ToolCall(\n                        id=\"call_abc123\",\n                        function=ToolCall.FunctionBody(name=\"add\", arguments='{\"a\": 2, \"b\": 3}'),\n                    )\n                ],\n            ),\n            Message(role=\"tool\", content=\"5\", tool_call_id=\"call_abc123\"),\n        ],\n    },\n    \"tool_call_with_image\": {\n        \"history\": [\n            Message(role=\"user\", content=\"Add 2 and 3\"),\n            Message(\n                role=\"assistant\",\n                content=\"I'll add those numbers for you.\",\n                tool_calls=[\n                    ToolCall(\n                        id=\"call_abc123\",\n                        function=ToolCall.FunctionBody(name=\"add\", arguments='{\"a\": 2, \"b\": 3}'),\n                    )\n                ],\n            ),\n            Message(\n                role=\"tool\",\n                content=[\n                    TextPart(text=\"5\"),\n                    ImageURLPart(\n                        image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")\n                    ),\n                ],\n                tool_call_id=\"call_abc123\",\n            ),\n        ],\n    },\n    \"parallel_tool_calls\": {\n        \"tools\": [ADD_TOOL, MUL_TOOL],\n        \"history\": [\n            Message(role=\"user\", content=\"Calculate 2+3 and 4*5\"),\n            Message(\n                role=\"assistant\",\n                content=\"I'll calculate both.\",\n                tool_calls=[\n                    ToolCall(\n                        id=\"call_add\",\n                        function=ToolCall.FunctionBody(name=\"add\", arguments='{\"a\": 2, \"b\": 3}'),\n                    ),\n                    ToolCall(\n                        id=\"call_mul\",\n                        function=ToolCall.FunctionBody(\n                            name=\"multiply\", arguments='{\"a\": 4, \"b\": 5}'\n                        ),\n                    ),\n                ],\n            ),\n            Message(\n                role=\"tool\",\n                content=[\n                    TextPart(text=\"<system-reminder>This is a system reminder</system-reminder>\"),\n                    TextPart(text=\"5\"),\n                ],\n                tool_call_id=\"call_add\",\n            ),\n            Message(\n                role=\"tool\",\n                content=[\n                    TextPart(text=\"<system-reminder>This is a system reminder</system-reminder>\"),\n                    TextPart(text=\"20\"),\n                ],\n                tool_call_id=\"call_mul\",\n            ),\n        ],\n    },\n}\n\n\nasync def capture_request(\n    mock: respx.MockRouter,\n    provider: ChatProvider,\n    system: str,\n    tools: Sequence[Tool],\n    history: list[Message],\n) -> dict[str, Any]:\n    \"\"\"Generate and capture the request body.\"\"\"\n    stream = await provider.generate(system, tools, history)\n    async for _ in stream:\n        pass\n    request = mock.calls.last.request\n    assert request.content is not None\n    return json.loads(request.content.decode())\n\n\nasync def run_test_cases(\n    mock: respx.MockRouter,\n    provider: ChatProvider,\n    cases: dict[str, Case],\n    extract_keys: tuple[str, ...],\n) -> dict[str, dict[str, Any]]:\n    \"\"\"Run all test cases and return results dict for snapshot comparison.\"\"\"\n    results: dict[str, dict[str, Any]] = {}\n    for name, case in cases.items():\n        body = await capture_request(\n            mock,\n            provider,\n            case.get(\"system\", \"\"),\n            case.get(\"tools\", []),\n            case.get(\"history\", []),\n        )\n        results[name] = {k: v for k, v in body.items() if k in extract_keys}\n    return results\n"
  },
  {
    "path": "packages/kosong/tests/api_snapshot_tests/test_anthropic.py",
    "content": "\"\"\"Snapshot tests for Anthropic chat provider.\"\"\"\n\nimport json\n\nimport pytest\nimport respx\nfrom common import B64_PNG, COMMON_CASES, Case, make_anthropic_response, run_test_cases\nfrom httpx import Response\nfrom inline_snapshot import snapshot\n\npytest.importorskip(\"anthropic\", reason=\"Optional contrib dependency not installed\")\n\nfrom kosong.contrib.chat_provider.anthropic import Anthropic\nfrom kosong.message import ImageURLPart, Message, TextPart, ThinkPart\n\nTEST_CASES: dict[str, Case] = {\n    **COMMON_CASES,\n    \"assistant_with_thinking\": {\n        \"history\": [\n            Message(role=\"user\", content=\"What is 2+2?\"),\n            Message(\n                role=\"assistant\",\n                content=[\n                    ThinkPart(think=\"Let me think...\", encrypted=\"sig_abc123\"),\n                    TextPart(text=\"The answer is 4.\"),\n                ],\n            ),\n            Message(role=\"user\", content=\"Thanks!\"),\n        ],\n    },\n    \"thinking_without_signature_stripped\": {\n        \"history\": [\n            Message(role=\"user\", content=\"Hi\"),\n            Message(\n                role=\"assistant\",\n                content=[ThinkPart(think=\"Thinking...\"), TextPart(text=\"Hello!\")],\n            ),\n            Message(role=\"user\", content=\"Bye\"),\n        ],\n    },\n    \"base64_image\": {\n        \"history\": [\n            Message(\n                role=\"user\",\n                content=[\n                    TextPart(text=\"Describe:\"),\n                    ImageURLPart(\n                        image_url=ImageURLPart.ImageURL(url=f\"data:image/png;base64,{B64_PNG}\")\n                    ),\n                ],\n            )\n        ],\n    },\n    \"redacted_thinking\": {\n        \"history\": [\n            Message(role=\"user\", content=\"What is 2+2?\"),\n            Message(\n                role=\"assistant\",\n                content=[\n                    ThinkPart(think=\"\", encrypted=\"enc_redacted_sig_xyz\"),\n                    TextPart(text=\"4.\"),\n                ],\n            ),\n            Message(role=\"user\", content=\"Thanks!\"),\n        ],\n    },\n}\n\n\nasync def test_anthropic_message_conversion():\n    with respx.mock(base_url=\"https://api.anthropic.com\") as mock:\n        mock.post(\"/v1/messages\").mock(return_value=Response(200, json=make_anthropic_response()))\n        provider = Anthropic(\n            model=\"claude-sonnet-4-20250514\",\n            api_key=\"test-key\",\n            default_max_tokens=1024,\n            stream=False,\n        )\n        results = await run_test_cases(mock, provider, TEST_CASES, (\"messages\", \"system\", \"tools\"))\n\n        assert results == snapshot(\n            {\n                \"simple_user_message\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"Hello!\",\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        }\n                    ],\n                    \"system\": [\n                        {\n                            \"type\": \"text\",\n                            \"text\": \"You are helpful.\",\n                            \"cache_control\": {\"type\": \"ephemeral\"},\n                        }\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_conversation\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"What is 2+2?\"}]},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [{\"type\": \"text\", \"text\": \"2+2 equals 4.\"}],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"And 3+3?\",\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_with_system\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"What is 2+2?\"}]},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [{\"type\": \"text\", \"text\": \"2+2 equals 4.\"}],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"And 3+3?\",\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"system\": [\n                        {\n                            \"text\": \"You are a math tutor.\",\n                            \"type\": \"text\",\n                            \"cache_control\": {\"type\": \"ephemeral\"},\n                        }\n                    ],\n                    \"tools\": [],\n                },\n                \"image_url\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"What's in this image?\"},\n                                {\n                                    \"type\": \"image\",\n                                    \"source\": {\n                                        \"type\": \"url\",\n                                        \"url\": \"https://example.com/image.png\",\n                                    },\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                },\n                            ],\n                        }\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_definition\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"Add 2 and 3\",\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        }\n                    ],\n                    \"tools\": [\n                        {\n                            \"name\": \"add\",\n                            \"description\": \"Add two integers.\",\n                            \"input_schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                    \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                        },\n                        {\n                            \"name\": \"multiply\",\n                            \"description\": \"Multiply two integers.\",\n                            \"input_schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                    \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                            \"cache_control\": {\"type\": \"ephemeral\"},\n                        },\n                    ],\n                },\n                \"tool_call_with_image\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Add 2 and 3\"}]},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"I'll add those numbers for you.\"},\n                                {\n                                    \"type\": \"tool_use\",\n                                    \"id\": \"call_abc123\",\n                                    \"name\": \"add\",\n                                    \"input\": {\"a\": 2, \"b\": 3},\n                                },\n                            ],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"tool_result\",\n                                    \"tool_use_id\": \"call_abc123\",\n                                    \"content\": [\n                                        {\"type\": \"text\", \"text\": \"5\"},\n                                        {\n                                            \"type\": \"image\",\n                                            \"source\": {\n                                                \"type\": \"url\",\n                                                \"url\": \"https://example.com/image.png\",\n                                            },\n                                        },\n                                    ],\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_call\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Add 2 and 3\"}]},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"I'll add those numbers for you.\"},\n                                {\n                                    \"type\": \"tool_use\",\n                                    \"id\": \"call_abc123\",\n                                    \"name\": \"add\",\n                                    \"input\": {\"a\": 2, \"b\": 3},\n                                },\n                            ],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"tool_result\",\n                                    \"tool_use_id\": \"call_abc123\",\n                                    \"content\": [{\"type\": \"text\", \"text\": \"5\"}],\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"parallel_tool_calls\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [{\"type\": \"text\", \"text\": \"Calculate 2+3 and 4*5\"}],\n                        },\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"I'll calculate both.\"},\n                                {\n                                    \"type\": \"tool_use\",\n                                    \"id\": \"call_add\",\n                                    \"name\": \"add\",\n                                    \"input\": {\"a\": 2, \"b\": 3},\n                                },\n                                {\n                                    \"type\": \"tool_use\",\n                                    \"id\": \"call_mul\",\n                                    \"name\": \"multiply\",\n                                    \"input\": {\"a\": 4, \"b\": 5},\n                                },\n                            ],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"tool_result\",\n                                    \"tool_use_id\": \"call_add\",\n                                    \"content\": [\n                                        {\n                                            \"type\": \"text\",\n                                            \"text\": \"<system-reminder>This is a system reminder\"\n                                            \"</system-reminder>\",\n                                        },\n                                        {\"type\": \"text\", \"text\": \"5\"},\n                                    ],\n                                }\n                            ],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"tool_result\",\n                                    \"tool_use_id\": \"call_mul\",\n                                    \"content\": [\n                                        {\n                                            \"type\": \"text\",\n                                            \"text\": \"<system-reminder>This is a system reminder\"\n                                            \"</system-reminder>\",\n                                        },\n                                        {\"type\": \"text\", \"text\": \"20\"},\n                                    ],\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"tools\": [\n                        {\n                            \"name\": \"add\",\n                            \"description\": \"Add two integers.\",\n                            \"input_schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                    \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                        },\n                        {\n                            \"name\": \"multiply\",\n                            \"description\": \"Multiply two integers.\",\n                            \"input_schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                    \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                            \"cache_control\": {\"type\": \"ephemeral\"},\n                        },\n                    ],\n                },\n                \"assistant_with_thinking\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [{\"type\": \"text\", \"text\": \"What is 2+2?\"}],\n                        },\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [\n                                {\n                                    \"type\": \"thinking\",\n                                    \"thinking\": \"Let me think...\",\n                                    \"signature\": \"sig_abc123\",\n                                },\n                                {\"type\": \"text\", \"text\": \"The answer is 4.\"},\n                            ],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"Thanks!\",\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"thinking_without_signature_stripped\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [{\"type\": \"text\", \"text\": \"Hi\"}],\n                        },\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [{\"type\": \"text\", \"text\": \"Hello!\"}],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"Bye\",\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"base64_image\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"Describe:\"},\n                                {\n                                    \"type\": \"image\",\n                                    \"source\": {\n                                        \"type\": \"base64\",\n                                        \"data\": B64_PNG,\n                                        \"media_type\": \"image/png\",\n                                    },\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                },\n                            ],\n                        }\n                    ],\n                    \"tools\": [],\n                },\n                \"redacted_thinking\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [{\"type\": \"text\", \"text\": \"What is 2+2?\"}],\n                        },\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": [\n                                {\n                                    \"type\": \"thinking\",\n                                    \"thinking\": \"\",\n                                    \"signature\": \"enc_redacted_sig_xyz\",\n                                },\n                                {\"type\": \"text\", \"text\": \"4.\"},\n                            ],\n                        },\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"Thanks!\",\n                                    \"cache_control\": {\"type\": \"ephemeral\"},\n                                }\n                            ],\n                        },\n                    ],\n                    \"tools\": [],\n                },\n            }\n        )\n\n\nasync def test_anthropic_generation_kwargs():\n    with respx.mock(base_url=\"https://api.anthropic.com\") as mock:\n        mock.post(\"/v1/messages\").mock(return_value=Response(200, json=make_anthropic_response()))\n        provider = Anthropic(\n            model=\"claude-sonnet-4-20250514\",\n            api_key=\"test-key\",\n            default_max_tokens=1024,\n            stream=False,\n        ).with_generation_kwargs(temperature=0.7, top_p=0.9, max_tokens=2048)\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert (body[\"temperature\"], body[\"top_p\"], body[\"max_tokens\"]) == snapshot(\n            (0.7, 0.9, 2048)\n        )\n\n\nasync def test_anthropic_with_thinking():\n    with respx.mock(base_url=\"https://api.anthropic.com\") as mock:\n        mock.post(\"/v1/messages\").mock(return_value=Response(200, json=make_anthropic_response()))\n        provider = Anthropic(\n            model=\"claude-sonnet-4-20250514\",\n            api_key=\"test-key\",\n            default_max_tokens=1024,\n            stream=False,\n        ).with_thinking(\"high\")\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"thinking\"] == snapshot({\"type\": \"enabled\", \"budget_tokens\": 32000})\n\n\nasync def test_anthropic_opus_46_adaptive_thinking():\n    \"\"\"Opus 4.6 models should use adaptive thinking instead of budget-based thinking.\"\"\"\n    with respx.mock(base_url=\"https://api.anthropic.com\") as mock:\n        mock.post(\"/v1/messages\").mock(return_value=Response(200, json=make_anthropic_response()))\n        provider = Anthropic(\n            model=\"claude-opus-4-6-20260205\",\n            api_key=\"test-key\",\n            default_max_tokens=1024,\n            stream=False,\n        ).with_thinking(\"high\")\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"thinking\"] == snapshot({\"type\": \"adaptive\"})\n        # Adaptive thinking should not include interleaved-thinking beta header\n        beta_header = mock.calls.last.request.headers.get(\"anthropic-beta\", \"\")\n        assert \"interleaved-thinking-2025-05-14\" not in beta_header\n\n\nasync def test_anthropic_opus_46_thinking_off():\n    \"\"\"Opus 4.6 with thinking off should still use disabled.\"\"\"\n    with respx.mock(base_url=\"https://api.anthropic.com\") as mock:\n        mock.post(\"/v1/messages\").mock(return_value=Response(200, json=make_anthropic_response()))\n        provider = Anthropic(\n            model=\"claude-opus-4-6-20260205\",\n            api_key=\"test-key\",\n            default_max_tokens=1024,\n            stream=False,\n        ).with_thinking(\"off\")\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"thinking\"] == snapshot({\"type\": \"disabled\"})\n\n\nasync def test_anthropic_metadata():\n    \"\"\"Metadata should be forwarded to the Anthropic API request.\"\"\"\n    with respx.mock(base_url=\"https://api.anthropic.com\") as mock:\n        mock.post(\"/v1/messages\").mock(return_value=Response(200, json=make_anthropic_response()))\n        provider = Anthropic(\n            model=\"claude-sonnet-4-20250514\",\n            api_key=\"test-key\",\n            default_max_tokens=1024,\n            stream=False,\n            metadata={\"user_id\": \"test-session-id\"},\n        )\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"metadata\"] == snapshot({\"user_id\": \"test-session-id\"})\n\n\nasync def test_anthropic_metadata_omitted_when_none():\n    \"\"\"Metadata should not be included in the request when not provided.\"\"\"\n    with respx.mock(base_url=\"https://api.anthropic.com\") as mock:\n        mock.post(\"/v1/messages\").mock(return_value=Response(200, json=make_anthropic_response()))\n        provider = Anthropic(\n            model=\"claude-sonnet-4-20250514\",\n            api_key=\"test-key\",\n            default_max_tokens=1024,\n            stream=False,\n        )\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert \"metadata\" not in body\n\n\nasync def test_anthropic_opus_46_thinking_effort_property():\n    \"\"\"thinking_effort should return 'high' for adaptive thinking config.\"\"\"\n    provider = Anthropic(\n        model=\"claude-opus-4-6-20260205\",\n        api_key=\"test-key\",\n        default_max_tokens=1024,\n        stream=False,\n    ).with_thinking(\"high\")\n    assert provider.thinking_effort == \"high\"\n\n    provider_off = Anthropic(\n        model=\"claude-opus-4-6-20260205\",\n        api_key=\"test-key\",\n        default_max_tokens=1024,\n        stream=False,\n    ).with_thinking(\"off\")\n    assert provider_off.thinking_effort == \"off\"\n"
  },
  {
    "path": "packages/kosong/tests/api_snapshot_tests/test_google_genai.py",
    "content": "\"\"\"Snapshot tests for Google GenAI (Gemini) chat provider.\"\"\"\n\nimport json\nfrom typing import Any\n\nimport pytest\nimport respx\nfrom common import COMMON_CASES, Case, run_test_cases\nfrom httpx import Response\nfrom inline_snapshot import snapshot\n\npytest.importorskip(\"google.genai\", reason=\"Optional contrib dependency not installed\")\n\nfrom google.genai import _api_client\n\nfrom kosong.message import Message, TextPart, ToolCall\n\n# Force google-genai to use httpx so respx can mock requests.\n_api_client.has_aiohttp = False\n\nfrom kosong.contrib.chat_provider.google_genai import GoogleGenAI  # noqa: E402\n\n\ndef make_response() -> dict[str, Any]:\n    return {\n        \"candidates\": [\n            {\n                \"content\": {\"parts\": [{\"text\": \"Hello\"}], \"role\": \"model\"},\n                \"finishReason\": \"STOP\",\n            }\n        ],\n        \"usageMetadata\": {\n            \"promptTokenCount\": 10,\n            \"candidatesTokenCount\": 5,\n            \"totalTokenCount\": 15,\n        },\n        \"modelVersion\": \"gemini-2.5-flash\",\n    }\n\n\nTEST_CASES: dict[str, Case] = {\n    # Google GenAI doesn't support image_url in the same way, use subset of common cases\n    **{k: v for k, v in COMMON_CASES.items() if \"image\" not in k},\n    \"tool_call_with_thought_signature\": {\n        \"history\": [\n            Message(role=\"user\", content=\"Add 2 and 3\"),\n            Message(\n                role=\"assistant\",\n                content=[TextPart(text=\"I'll add those.\")],\n                tool_calls=[\n                    ToolCall(\n                        id=\"add_call_sig\",\n                        function=ToolCall.FunctionBody(name=\"add\", arguments='{\"a\": 2, \"b\": 3}'),\n                        extras={\"thought_signature_b64\": \"dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==\"},\n                    )\n                ],\n            ),\n        ],\n    },\n}\n\n\nasync def test_google_genai_message_conversion():\n    with respx.mock(base_url=\"https://generativelanguage.googleapis.com\") as mock:\n        mock.route(method=\"POST\", path__regex=r\"/v1beta/models/.+:generateContent\").mock(\n            return_value=Response(200, json=make_response())\n        )\n        provider = GoogleGenAI(model=\"gemini-2.5-flash\", api_key=\"test-key\", stream=False)\n        results = await run_test_cases(\n            mock, provider, TEST_CASES, (\"contents\", \"systemInstruction\", \"tools\")\n        )\n\n        assert results == snapshot(\n            {\n                \"simple_user_message\": {\n                    \"contents\": [{\"parts\": [{\"text\": \"Hello!\"}], \"role\": \"user\"}],\n                    \"systemInstruction\": {\n                        \"parts\": [{\"text\": \"You are helpful.\"}],\n                        \"role\": \"user\",\n                    },\n                },\n                \"multi_turn_conversation\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"What is 2+2?\"}], \"role\": \"user\"},\n                        {\"parts\": [{\"text\": \"2+2 equals 4.\"}], \"role\": \"model\"},\n                        {\"parts\": [{\"text\": \"And 3+3?\"}], \"role\": \"user\"},\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                },\n                \"multi_turn_with_system\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"What is 2+2?\"}], \"role\": \"user\"},\n                        {\"parts\": [{\"text\": \"2+2 equals 4.\"}], \"role\": \"model\"},\n                        {\"parts\": [{\"text\": \"And 3+3?\"}], \"role\": \"user\"},\n                    ],\n                    \"systemInstruction\": {\n                        \"parts\": [{\"text\": \"You are a math tutor.\"}],\n                        \"role\": \"user\",\n                    },\n                },\n                \"tool_definition\": {\n                    \"contents\": [{\"parts\": [{\"text\": \"Add 2 and 3\"}], \"role\": \"user\"}],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                    \"tools\": [\n                        {\n                            \"functionDeclarations\": [\n                                {\n                                    \"name\": \"add\",\n                                    \"description\": \"Add two integers.\",\n                                    \"parameters_json_schema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                                {\n                                    \"description\": \"Multiply two integers.\",\n                                    \"name\": \"multiply\",\n                                    \"parameters_json_schema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                            ]\n                        }\n                    ],\n                },\n                \"tool_call\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"Add 2 and 3\"}], \"role\": \"user\"},\n                        {\n                            \"parts\": [\n                                {\"text\": \"I'll add those numbers for you.\"},\n                                {\n                                    \"functionCall\": {\n                                        \"id\": \"call_abc123\",\n                                        \"args\": {\"a\": 2, \"b\": 3},\n                                        \"name\": \"add\",\n                                    }\n                                },\n                            ],\n                            \"role\": \"model\",\n                        },\n                        {\n                            \"parts\": [\n                                {\n                                    \"functionResponse\": {\n                                        \"parts\": [],\n                                        \"id\": \"call_abc123\",\n                                        \"name\": \"add\",\n                                        \"response\": {\"output\": \"5\"},\n                                    }\n                                }\n                            ],\n                            \"role\": \"user\",\n                        },\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                },\n                \"parallel_tool_calls\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"Calculate 2+3 and 4*5\"}], \"role\": \"user\"},\n                        {\n                            \"parts\": [\n                                {\"text\": \"I'll calculate both.\"},\n                                {\n                                    \"functionCall\": {\n                                        \"id\": \"call_add\",\n                                        \"name\": \"add\",\n                                        \"args\": {\"a\": 2, \"b\": 3},\n                                    }\n                                },\n                                {\n                                    \"functionCall\": {\n                                        \"id\": \"call_mul\",\n                                        \"name\": \"multiply\",\n                                        \"args\": {\"a\": 4, \"b\": 5},\n                                    }\n                                },\n                            ],\n                            \"role\": \"model\",\n                        },\n                        {\n                            \"parts\": [\n                                {\n                                    \"functionResponse\": {\n                                        \"parts\": [],\n                                        \"id\": \"call_add\",\n                                        \"name\": \"add\",\n                                        \"response\": {\n                                            \"output\": \"<system-reminder>This is a system reminder\"\n                                            \"</system-reminder>5\"\n                                        },\n                                    }\n                                },\n                                {\n                                    \"functionResponse\": {\n                                        \"parts\": [],\n                                        \"id\": \"call_mul\",\n                                        \"name\": \"multiply\",\n                                        \"response\": {\n                                            \"output\": \"<system-reminder>This is a system reminder\"\n                                            \"</system-reminder>20\"\n                                        },\n                                    }\n                                },\n                            ],\n                            \"role\": \"user\",\n                        },\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                    \"tools\": [\n                        {\n                            \"functionDeclarations\": [\n                                {\n                                    \"description\": \"Add two integers.\",\n                                    \"name\": \"add\",\n                                    \"parameters_json_schema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                                {\n                                    \"description\": \"Multiply two integers.\",\n                                    \"name\": \"multiply\",\n                                    \"parameters_json_schema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                            ]\n                        }\n                    ],\n                },\n                \"tool_call_with_thought_signature\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"Add 2 and 3\"}], \"role\": \"user\"},\n                        {\n                            \"parts\": [\n                                {\"text\": \"I'll add those.\"},\n                                {\n                                    \"functionCall\": {\n                                        \"id\": \"add_call_sig\",\n                                        \"name\": \"add\",\n                                        \"args\": {\"a\": 2, \"b\": 3},\n                                    },\n                                    \"thoughtSignature\": \"dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==\",\n                                },\n                            ],\n                            \"role\": \"model\",\n                        },\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                },\n            }\n        )\n\n\nasync def test_google_genai_vertexai_message_conversion():\n    with respx.mock(base_url=\"https://aiplatform.googleapis.com\") as mock:\n        mock.route(\n            method=\"POST\",\n            path__regex=r\"/v1beta1/publishers/google/models/gemini-3-pro-preview:generateContent\",\n        ).mock(return_value=Response(200, json=make_response()))\n        provider = GoogleGenAI(\n            model=\"gemini-3-pro-preview\",\n            api_key=\"test-key\",\n            stream=False,\n            vertexai=True,\n        )\n        results = await run_test_cases(\n            mock, provider, TEST_CASES, (\"contents\", \"systemInstruction\", \"tools\")\n        )\n        assert results == snapshot(\n            {\n                \"simple_user_message\": {\n                    \"contents\": [{\"parts\": [{\"text\": \"Hello!\"}], \"role\": \"user\"}],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"You are helpful.\"}], \"role\": \"user\"},\n                },\n                \"multi_turn_conversation\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"What is 2+2?\"}], \"role\": \"user\"},\n                        {\"parts\": [{\"text\": \"2+2 equals 4.\"}], \"role\": \"model\"},\n                        {\"parts\": [{\"text\": \"And 3+3?\"}], \"role\": \"user\"},\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                },\n                \"multi_turn_with_system\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"What is 2+2?\"}], \"role\": \"user\"},\n                        {\"parts\": [{\"text\": \"2+2 equals 4.\"}], \"role\": \"model\"},\n                        {\"parts\": [{\"text\": \"And 3+3?\"}], \"role\": \"user\"},\n                    ],\n                    \"systemInstruction\": {\n                        \"parts\": [{\"text\": \"You are a math tutor.\"}],\n                        \"role\": \"user\",\n                    },\n                },\n                \"tool_definition\": {\n                    \"contents\": [{\"parts\": [{\"text\": \"Add 2 and 3\"}], \"role\": \"user\"}],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                    \"tools\": [\n                        {\n                            \"functionDeclarations\": [\n                                {\n                                    \"description\": \"Add two integers.\",\n                                    \"name\": \"add\",\n                                    \"parametersJsonSchema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                                {\n                                    \"description\": \"Multiply two integers.\",\n                                    \"name\": \"multiply\",\n                                    \"parametersJsonSchema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                            ]\n                        }\n                    ],\n                },\n                \"tool_call\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"Add 2 and 3\"}], \"role\": \"user\"},\n                        {\n                            \"parts\": [\n                                {\"text\": \"I'll add those numbers for you.\"},\n                                {\n                                    \"function_call\": {\n                                        \"id\": \"call_abc123\",\n                                        \"args\": {\"a\": 2, \"b\": 3},\n                                        \"name\": \"add\",\n                                    }\n                                },\n                            ],\n                            \"role\": \"model\",\n                        },\n                        {\n                            \"parts\": [\n                                {\n                                    \"function_response\": {\n                                        \"parts\": [],\n                                        \"id\": \"call_abc123\",\n                                        \"name\": \"add\",\n                                        \"response\": {\"output\": \"5\"},\n                                    }\n                                }\n                            ],\n                            \"role\": \"user\",\n                        },\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                },\n                \"parallel_tool_calls\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"Calculate 2+3 and 4*5\"}], \"role\": \"user\"},\n                        {\n                            \"parts\": [\n                                {\"text\": \"I'll calculate both.\"},\n                                {\n                                    \"function_call\": {\n                                        \"id\": \"call_add\",\n                                        \"args\": {\"a\": 2, \"b\": 3},\n                                        \"name\": \"add\",\n                                    }\n                                },\n                                {\n                                    \"function_call\": {\n                                        \"id\": \"call_mul\",\n                                        \"args\": {\"a\": 4, \"b\": 5},\n                                        \"name\": \"multiply\",\n                                    }\n                                },\n                            ],\n                            \"role\": \"model\",\n                        },\n                        {\n                            \"parts\": [\n                                {\n                                    \"function_response\": {\n                                        \"parts\": [],\n                                        \"id\": \"call_add\",\n                                        \"name\": \"add\",\n                                        \"response\": {\n                                            \"output\": \"<system-reminder>This is a system reminder</system-reminder>5\"  # noqa: E501\n                                        },\n                                    }\n                                },\n                                {\n                                    \"function_response\": {\n                                        \"parts\": [],\n                                        \"id\": \"call_mul\",\n                                        \"name\": \"multiply\",\n                                        \"response\": {\n                                            \"output\": \"<system-reminder>This is a system reminder</system-reminder>20\"  # noqa: E501\n                                        },\n                                    }\n                                },\n                            ],\n                            \"role\": \"user\",\n                        },\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                    \"tools\": [\n                        {\n                            \"functionDeclarations\": [\n                                {\n                                    \"description\": \"Add two integers.\",\n                                    \"name\": \"add\",\n                                    \"parametersJsonSchema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                                {\n                                    \"description\": \"Multiply two integers.\",\n                                    \"name\": \"multiply\",\n                                    \"parametersJsonSchema\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                            \"b\": {\n                                                \"type\": \"integer\",\n                                                \"description\": \"Second number\",\n                                            },\n                                        },\n                                        \"required\": [\"a\", \"b\"],\n                                    },\n                                },\n                            ]\n                        }\n                    ],\n                },\n                \"tool_call_with_thought_signature\": {\n                    \"contents\": [\n                        {\"parts\": [{\"text\": \"Add 2 and 3\"}], \"role\": \"user\"},\n                        {\n                            \"parts\": [\n                                {\"text\": \"I'll add those.\"},\n                                {\n                                    \"function_call\": {\n                                        \"id\": \"add_call_sig\",\n                                        \"args\": {\"a\": 2, \"b\": 3},\n                                        \"name\": \"add\",\n                                    },\n                                    \"thought_signature\": \"dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==\",\n                                },\n                            ],\n                            \"role\": \"model\",\n                        },\n                    ],\n                    \"systemInstruction\": {\"parts\": [{\"text\": \"\"}], \"role\": \"user\"},\n                },\n            }\n        )\n\n\nasync def test_google_genai_generation_kwargs():\n    with respx.mock(base_url=\"https://generativelanguage.googleapis.com\") as mock:\n        mock.route(method=\"POST\", path__regex=r\"/v1beta/models/.+:generateContent\").mock(\n            return_value=Response(200, json=make_response())\n        )\n        provider = GoogleGenAI(\n            model=\"gemini-2.5-flash\", api_key=\"test-key\", stream=False\n        ).with_generation_kwargs(temperature=0.7, max_output_tokens=2048)\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        config = body.get(\"generationConfig\", {})\n        assert (config.get(\"temperature\"), config.get(\"maxOutputTokens\")) == snapshot((0.7, 2048))\n\n\nasync def test_google_genai_with_thinking():\n    with respx.mock(base_url=\"https://generativelanguage.googleapis.com\") as mock:\n        mock.route(method=\"POST\", path__regex=r\"/v1beta/models/.+:generateContent\").mock(\n            return_value=Response(200, json=make_response())\n        )\n        provider = GoogleGenAI(\n            model=\"gemini-2.5-flash\", api_key=\"test-key\", stream=False\n        ).with_thinking(\"high\")\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body.get(\"generationConfig\", {}).get(\"thinkingConfig\") == snapshot(\n            {\"include_thoughts\": True, \"thinking_budget\": 32000}\n        )\n"
  },
  {
    "path": "packages/kosong/tests/api_snapshot_tests/test_kimi.py",
    "content": "\"\"\"Snapshot tests for Kimi chat provider.\"\"\"\n\nimport json\n\nimport respx\nfrom common import COMMON_CASES, Case, make_chat_completion_response, run_test_cases\nfrom httpx import Response\nfrom inline_snapshot import snapshot\n\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.message import Message, TextPart, ThinkPart\nfrom kosong.tooling import Tool\n\nBUILTIN_TOOL = Tool(\n    name=\"$web_search\",\n    description=\"Search the web\",\n    parameters={\"type\": \"object\", \"properties\": {}},\n)\n\nTEST_CASES: dict[str, Case] = {\n    **COMMON_CASES,\n    \"builtin_tool\": {\n        \"history\": [Message(role=\"user\", content=\"Search for something\")],\n        \"tools\": [BUILTIN_TOOL],\n    },\n    \"assistant_with_reasoning\": {\n        \"history\": [\n            Message(role=\"user\", content=\"What is 2+2?\"),\n            Message(\n                role=\"assistant\",\n                content=[\n                    ThinkPart(think=\"Let me think...\"),\n                    TextPart(text=\"The answer is 4.\"),\n                ],\n            ),\n            Message(role=\"user\", content=\"Thanks!\"),\n        ],\n    },\n}\n\n\nasync def test_kimi_message_conversion():\n    with respx.mock(base_url=\"https://api.moonshot.ai\") as mock:\n        mock.post(\"/v1/chat/completions\").mock(\n            return_value=Response(200, json=make_chat_completion_response(\"kimi-k2\"))\n        )\n        provider = Kimi(model=\"kimi-k2-turbo-preview\", api_key=\"test-key\", stream=False)\n        results = await run_test_cases(mock, provider, TEST_CASES, (\"messages\", \"tools\"))\n\n        assert results == snapshot(\n            {\n                \"simple_user_message\": {\n                    \"messages\": [\n                        {\"role\": \"system\", \"content\": \"You are helpful.\"},\n                        {\"role\": \"user\", \"content\": \"Hello!\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_conversation\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n                        {\"role\": \"assistant\", \"content\": \"2+2 equals 4.\"},\n                        {\"role\": \"user\", \"content\": \"And 3+3?\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_with_system\": {\n                    \"messages\": [\n                        {\"role\": \"system\", \"content\": \"You are a math tutor.\"},\n                        {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n                        {\"role\": \"assistant\", \"content\": \"2+2 equals 4.\"},\n                        {\"role\": \"user\", \"content\": \"And 3+3?\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"image_url\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"What's in this image?\"},\n                                {\n                                    \"type\": \"image_url\",\n                                    \"image_url\": {\n                                        \"url\": \"https://example.com/image.png\",\n                                        \"id\": None,\n                                    },\n                                },\n                            ],\n                        }\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_definition\": {\n                    \"messages\": [{\"role\": \"user\", \"content\": \"Add 2 and 3\"}],\n                    \"tools\": [\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"add\",\n                                \"description\": \"Add two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\n                                            \"type\": \"integer\",\n                                            \"description\": \"First number\",\n                                        },\n                                        \"b\": {\n                                            \"type\": \"integer\",\n                                            \"description\": \"Second number\",\n                                        },\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"multiply\",\n                                \"description\": \"Multiply two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                        \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                    ],\n                },\n                \"tool_call_with_image\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"Add 2 and 3\"},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": \"I'll add those numbers for you.\",\n                            \"tool_calls\": [\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_abc123\",\n                                    \"function\": {\"name\": \"add\", \"arguments\": '{\"a\": 2, \"b\": 3}'},\n                                }\n                            ],\n                        },\n                        {\n                            \"role\": \"tool\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"5\"},\n                                {\n                                    \"type\": \"image_url\",\n                                    \"image_url\": {\n                                        \"url\": \"https://example.com/image.png\",\n                                        \"id\": None,\n                                    },\n                                },\n                            ],\n                            \"tool_call_id\": \"call_abc123\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_call\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"Add 2 and 3\"},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": \"I'll add those numbers for you.\",\n                            \"tool_calls\": [\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_abc123\",\n                                    \"function\": {\"name\": \"add\", \"arguments\": '{\"a\": 2, \"b\": 3}'},\n                                }\n                            ],\n                        },\n                        {\"role\": \"tool\", \"content\": \"5\", \"tool_call_id\": \"call_abc123\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"parallel_tool_calls\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"Calculate 2+3 and 4*5\"},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": \"I'll calculate both.\",\n                            \"tool_calls\": [\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_add\",\n                                    \"function\": {\n                                        \"name\": \"add\",\n                                        \"arguments\": '{\"a\": 2, \"b\": 3}',\n                                    },\n                                },\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_mul\",\n                                    \"function\": {\n                                        \"name\": \"multiply\",\n                                        \"arguments\": '{\"a\": 4, \"b\": 5}',\n                                    },\n                                },\n                            ],\n                        },\n                        {\n                            \"role\": \"tool\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"<system-reminder>This is a system reminder\"\n                                    \"</system-reminder>\",\n                                },\n                                {\"type\": \"text\", \"text\": \"5\"},\n                            ],\n                            \"tool_call_id\": \"call_add\",\n                        },\n                        {\n                            \"role\": \"tool\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"<system-reminder>This is a system reminder\"\n                                    \"</system-reminder>\",\n                                },\n                                {\"type\": \"text\", \"text\": \"20\"},\n                            ],\n                            \"tool_call_id\": \"call_mul\",\n                        },\n                    ],\n                    \"tools\": [\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"add\",\n                                \"description\": \"Add two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                        \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"multiply\",\n                                \"description\": \"Multiply two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                        \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                    ],\n                },\n                \"builtin_tool\": {\n                    \"messages\": [{\"role\": \"user\", \"content\": \"Search for something\"}],\n                    \"tools\": [\n                        {\n                            \"type\": \"builtin_function\",\n                            \"function\": {\"name\": \"$web_search\"},\n                        }\n                    ],\n                },\n                \"assistant_with_reasoning\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": \"The answer is 4.\",\n                            \"reasoning_content\": \"Let me think...\",\n                        },\n                        {\"role\": \"user\", \"content\": \"Thanks!\"},\n                    ],\n                    \"tools\": [],\n                },\n            }\n        )\n\n\nasync def test_kimi_generation_kwargs():\n    with respx.mock(base_url=\"https://api.moonshot.ai\") as mock:\n        mock.post(\"/v1/chat/completions\").mock(\n            return_value=Response(200, json=make_chat_completion_response())\n        )\n        provider = Kimi(\n            model=\"kimi-k2-turbo-preview\", api_key=\"test-key\", stream=False\n        ).with_generation_kwargs(temperature=0.7, max_tokens=2048)\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert (body[\"temperature\"], body[\"max_tokens\"]) == snapshot((0.7, 2048))\n\n\nasync def test_kimi_with_thinking():\n    with respx.mock(base_url=\"https://api.moonshot.ai\") as mock:\n        mock.post(\"/v1/chat/completions\").mock(\n            return_value=Response(200, json=make_chat_completion_response())\n        )\n        provider = Kimi(\n            model=\"kimi-k2-turbo-preview\", api_key=\"test-key\", stream=False\n        ).with_thinking(\"high\")\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"reasoning_effort\"] == snapshot(\"high\")\n"
  },
  {
    "path": "packages/kosong/tests/api_snapshot_tests/test_openai_legacy.py",
    "content": "\"\"\"Snapshot tests for OpenAI Legacy (Chat Completions API) chat provider.\"\"\"\n\nimport json\n\nimport respx\nfrom common import COMMON_CASES, Case, make_chat_completion_response, run_test_cases\nfrom httpx import Response\nfrom inline_snapshot import snapshot\n\nfrom kosong.contrib.chat_provider.openai_legacy import OpenAILegacy\nfrom kosong.message import Message, TextPart, ThinkPart\n\nTEST_CASES: dict[str, Case] = {**COMMON_CASES}\n\n\nasync def test_openai_legacy_message_conversion():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/chat/completions\").mock(\n            return_value=Response(200, json=make_chat_completion_response(\"gpt-4.1\"))\n        )\n        provider = OpenAILegacy(model=\"gpt-4.1\", api_key=\"test-key\", stream=False)\n        results = await run_test_cases(mock, provider, TEST_CASES, (\"messages\", \"tools\"))\n\n        assert results == snapshot(\n            {\n                \"simple_user_message\": {\n                    \"messages\": [\n                        {\"role\": \"system\", \"content\": \"You are helpful.\"},\n                        {\"role\": \"user\", \"content\": \"Hello!\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_conversation\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n                        {\"role\": \"assistant\", \"content\": \"2+2 equals 4.\"},\n                        {\"role\": \"user\", \"content\": \"And 3+3?\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_with_system\": {\n                    \"messages\": [\n                        {\"role\": \"system\", \"content\": \"You are a math tutor.\"},\n                        {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n                        {\"role\": \"assistant\", \"content\": \"2+2 equals 4.\"},\n                        {\"role\": \"user\", \"content\": \"And 3+3?\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"image_url\": {\n                    \"messages\": [\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"What's in this image?\"},\n                                {\n                                    \"type\": \"image_url\",\n                                    \"image_url\": {\n                                        \"url\": \"https://example.com/image.png\",\n                                        \"id\": None,\n                                    },\n                                },\n                            ],\n                        }\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_definition\": {\n                    \"messages\": [{\"role\": \"user\", \"content\": \"Add 2 and 3\"}],\n                    \"tools\": [\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"add\",\n                                \"description\": \"Add two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\n                                            \"type\": \"integer\",\n                                            \"description\": \"First number\",\n                                        },\n                                        \"b\": {\n                                            \"type\": \"integer\",\n                                            \"description\": \"Second number\",\n                                        },\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"multiply\",\n                                \"description\": \"Multiply two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                        \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                    ],\n                },\n                \"tool_call_with_image\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"Add 2 and 3\"},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": \"I'll add those numbers for you.\",\n                            \"tool_calls\": [\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_abc123\",\n                                    \"function\": {\"name\": \"add\", \"arguments\": '{\"a\": 2, \"b\": 3}'},\n                                }\n                            ],\n                        },\n                        {\n                            \"role\": \"tool\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": \"5\"},\n                                {\n                                    \"type\": \"image_url\",\n                                    \"image_url\": {\n                                        \"url\": \"https://example.com/image.png\",\n                                        \"id\": None,\n                                    },\n                                },\n                            ],\n                            \"tool_call_id\": \"call_abc123\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_call\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"Add 2 and 3\"},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": \"I'll add those numbers for you.\",\n                            \"tool_calls\": [\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_abc123\",\n                                    \"function\": {\"name\": \"add\", \"arguments\": '{\"a\": 2, \"b\": 3}'},\n                                }\n                            ],\n                        },\n                        {\"role\": \"tool\", \"content\": \"5\", \"tool_call_id\": \"call_abc123\"},\n                    ],\n                    \"tools\": [],\n                },\n                \"parallel_tool_calls\": {\n                    \"messages\": [\n                        {\"role\": \"user\", \"content\": \"Calculate 2+3 and 4*5\"},\n                        {\n                            \"role\": \"assistant\",\n                            \"content\": \"I'll calculate both.\",\n                            \"tool_calls\": [\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_add\",\n                                    \"function\": {\n                                        \"name\": \"add\",\n                                        \"arguments\": '{\"a\": 2, \"b\": 3}',\n                                    },\n                                },\n                                {\n                                    \"type\": \"function\",\n                                    \"id\": \"call_mul\",\n                                    \"function\": {\n                                        \"name\": \"multiply\",\n                                        \"arguments\": '{\"a\": 4, \"b\": 5}',\n                                    },\n                                },\n                            ],\n                        },\n                        {\n                            \"role\": \"tool\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"<system-reminder>This is a system reminder\"\n                                    \"</system-reminder>\",\n                                },\n                                {\"type\": \"text\", \"text\": \"5\"},\n                            ],\n                            \"tool_call_id\": \"call_add\",\n                        },\n                        {\n                            \"role\": \"tool\",\n                            \"content\": [\n                                {\n                                    \"type\": \"text\",\n                                    \"text\": \"<system-reminder>This is a system reminder\"\n                                    \"</system-reminder>\",\n                                },\n                                {\"type\": \"text\", \"text\": \"20\"},\n                            ],\n                            \"tool_call_id\": \"call_mul\",\n                        },\n                    ],\n                    \"tools\": [\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"add\",\n                                \"description\": \"Add two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                        \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                        {\n                            \"type\": \"function\",\n                            \"function\": {\n                                \"name\": \"multiply\",\n                                \"description\": \"Multiply two integers.\",\n                                \"parameters\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                        \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                    },\n                                    \"required\": [\"a\", \"b\"],\n                                },\n                            },\n                        },\n                    ],\n                },\n            }\n        )\n\n\nasync def test_openai_legacy_reasoning_content():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/chat/completions\").mock(\n            return_value=Response(200, json=make_chat_completion_response())\n        )\n        provider = OpenAILegacy(\n            model=\"deepseek-reasoner\",\n            api_key=\"test-key\",\n            stream=False,\n            reasoning_key=\"reasoning_content\",\n        )\n        history = [\n            Message(role=\"user\", content=\"What is 2+2?\"),\n            Message(\n                role=\"assistant\",\n                content=[ThinkPart(think=\"Thinking...\"), TextPart(text=\"4.\")],\n            ),\n            Message(role=\"user\", content=\"Thanks!\"),\n        ]\n        stream = await provider.generate(\"\", [], history)\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"messages\"] == snapshot(\n            [\n                {\"role\": \"user\", \"content\": \"What is 2+2?\"},\n                {\n                    \"role\": \"assistant\",\n                    \"content\": \"4.\",\n                    \"reasoning_content\": \"Thinking...\",\n                },\n                {\"role\": \"user\", \"content\": \"Thanks!\"},\n            ]\n        )\n\n\nasync def test_openai_legacy_generation_kwargs():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/chat/completions\").mock(\n            return_value=Response(200, json=make_chat_completion_response())\n        )\n        provider = OpenAILegacy(\n            model=\"gpt-4.1\", api_key=\"test-key\", stream=False\n        ).with_generation_kwargs(temperature=0.7, max_tokens=2048)\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert (body[\"temperature\"], body[\"max_tokens\"]) == snapshot((0.7, 2048))\n\n\nasync def test_openai_legacy_with_thinking():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/chat/completions\").mock(\n            return_value=Response(200, json=make_chat_completion_response())\n        )\n        provider = OpenAILegacy(model=\"gpt-4.1\", api_key=\"test-key\", stream=False).with_thinking(\n            \"high\"\n        )\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"reasoning_effort\"] == snapshot(\"high\")\n"
  },
  {
    "path": "packages/kosong/tests/api_snapshot_tests/test_openai_responses.py",
    "content": "\"\"\"Snapshot tests for OpenAI Responses API chat provider.\"\"\"\n\nimport json\nfrom typing import Any\n\nimport respx\nfrom common import COMMON_CASES, Case, run_test_cases\nfrom httpx import Response\nfrom inline_snapshot import snapshot\n\nfrom kosong.contrib.chat_provider.openai_responses import OpenAIResponses\nfrom kosong.message import Message, TextPart, ThinkPart\n\n\ndef make_response() -> dict[str, Any]:\n    return {\n        \"id\": \"resp_test123\",\n        \"object\": \"response\",\n        \"created_at\": 1234567890,\n        \"status\": \"completed\",\n        \"model\": \"gpt-4.1\",\n        \"output\": [\n            {\n                \"type\": \"message\",\n                \"id\": \"msg_test\",\n                \"role\": \"assistant\",\n                \"content\": [{\"type\": \"output_text\", \"text\": \"Hello\", \"annotations\": []}],\n            }\n        ],\n        \"usage\": {\"input_tokens\": 10, \"output_tokens\": 5, \"total_tokens\": 15},\n    }\n\n\nTEST_CASES: dict[str, Case] = {\n    **COMMON_CASES,\n    \"assistant_with_reasoning\": {\n        \"history\": [\n            Message(role=\"user\", content=\"What is 2+2?\"),\n            Message(\n                role=\"assistant\",\n                content=[\n                    ThinkPart(think=\"Thinking...\", encrypted=\"enc_abc\"),\n                    TextPart(text=\"4.\"),\n                ],\n            ),\n            Message(role=\"user\", content=\"Thanks!\"),\n        ],\n    },\n}\n\n\nasync def test_openai_responses_message_conversion():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/responses\").mock(return_value=Response(200, json=make_response()))\n        provider = OpenAIResponses(model=\"gpt-4.1\", api_key=\"test-key\", stream=False)\n        results = await run_test_cases(mock, provider, TEST_CASES, (\"input\", \"tools\"))\n\n        assert results == snapshot(\n            {\n                \"simple_user_message\": {\n                    \"input\": [\n                        {\"role\": \"developer\", \"content\": \"You are helpful.\"},\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"Hello!\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_conversation\": {\n                    \"input\": [\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"What is 2+2?\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [\n                                {\"type\": \"output_text\", \"text\": \"2+2 equals 4.\", \"annotations\": []}\n                            ],\n                            \"role\": \"assistant\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"And 3+3?\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"multi_turn_with_system\": {\n                    \"input\": [\n                        {\"role\": \"developer\", \"content\": \"You are a math tutor.\"},\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"What is 2+2?\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [\n                                {\"type\": \"output_text\", \"text\": \"2+2 equals 4.\", \"annotations\": []}\n                            ],\n                            \"role\": \"assistant\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"And 3+3?\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"image_url\": {\n                    \"input\": [\n                        {\n                            \"content\": [\n                                {\"type\": \"input_text\", \"text\": \"What's in this image?\"},\n                                {\n                                    \"type\": \"input_image\",\n                                    \"detail\": \"auto\",\n                                    \"image_url\": \"https://example.com/image.png\",\n                                },\n                            ],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        }\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_definition\": {\n                    \"input\": [\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"Add 2 and 3\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        }\n                    ],\n                    \"tools\": [\n                        {\n                            \"type\": \"function\",\n                            \"name\": \"add\",\n                            \"description\": \"Add two integers.\",\n                            \"parameters\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\n                                        \"type\": \"integer\",\n                                        \"description\": \"First number\",\n                                    },\n                                    \"b\": {\n                                        \"type\": \"integer\",\n                                        \"description\": \"Second number\",\n                                    },\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                            \"strict\": False,\n                        },\n                        {\n                            \"type\": \"function\",\n                            \"name\": \"multiply\",\n                            \"description\": \"Multiply two integers.\",\n                            \"parameters\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                    \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                            \"strict\": False,\n                        },\n                    ],\n                },\n                \"tool_call_with_image\": {\n                    \"input\": [\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"Add 2 and 3\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [\n                                {\n                                    \"type\": \"output_text\",\n                                    \"text\": \"I'll add those numbers for you.\",\n                                    \"annotations\": [],\n                                }\n                            ],\n                            \"role\": \"assistant\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"arguments\": '{\"a\": 2, \"b\": 3}',\n                            \"call_id\": \"call_abc123\",\n                            \"name\": \"add\",\n                            \"type\": \"function_call\",\n                        },\n                        {\n                            \"call_id\": \"call_abc123\",\n                            \"output\": [\n                                {\"type\": \"input_text\", \"text\": \"5\"},\n                                {\n                                    \"type\": \"input_image\",\n                                    \"image_url\": \"https://example.com/image.png\",\n                                },\n                            ],\n                            \"type\": \"function_call_output\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"tool_call\": {\n                    \"input\": [\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"Add 2 and 3\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [\n                                {\n                                    \"type\": \"output_text\",\n                                    \"text\": \"I'll add those numbers for you.\",\n                                    \"annotations\": [],\n                                }\n                            ],\n                            \"role\": \"assistant\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"arguments\": '{\"a\": 2, \"b\": 3}',\n                            \"call_id\": \"call_abc123\",\n                            \"name\": \"add\",\n                            \"type\": \"function_call\",\n                        },\n                        {\n                            \"call_id\": \"call_abc123\",\n                            \"output\": [{\"type\": \"input_text\", \"text\": \"5\"}],\n                            \"type\": \"function_call_output\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n                \"parallel_tool_calls\": {\n                    \"input\": [\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"Calculate 2+3 and 4*5\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [\n                                {\n                                    \"type\": \"output_text\",\n                                    \"text\": \"I'll calculate both.\",\n                                    \"annotations\": [],\n                                }\n                            ],\n                            \"role\": \"assistant\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"arguments\": '{\"a\": 2, \"b\": 3}',\n                            \"call_id\": \"call_add\",\n                            \"name\": \"add\",\n                            \"type\": \"function_call\",\n                        },\n                        {\n                            \"arguments\": '{\"a\": 4, \"b\": 5}',\n                            \"call_id\": \"call_mul\",\n                            \"name\": \"multiply\",\n                            \"type\": \"function_call\",\n                        },\n                        {\n                            \"call_id\": \"call_add\",\n                            \"output\": [\n                                {\n                                    \"type\": \"input_text\",\n                                    \"text\": \"<system-reminder>This is a system reminder\"\n                                    \"</system-reminder>\",\n                                },\n                                {\"type\": \"input_text\", \"text\": \"5\"},\n                            ],\n                            \"type\": \"function_call_output\",\n                        },\n                        {\n                            \"call_id\": \"call_mul\",\n                            \"output\": [\n                                {\n                                    \"type\": \"input_text\",\n                                    \"text\": \"<system-reminder>This is a system reminder\"\n                                    \"</system-reminder>\",\n                                },\n                                {\"type\": \"input_text\", \"text\": \"20\"},\n                            ],\n                            \"type\": \"function_call_output\",\n                        },\n                    ],\n                    \"tools\": [\n                        {\n                            \"type\": \"function\",\n                            \"name\": \"add\",\n                            \"description\": \"Add two integers.\",\n                            \"parameters\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                    \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                            \"strict\": False,\n                        },\n                        {\n                            \"type\": \"function\",\n                            \"name\": \"multiply\",\n                            \"description\": \"Multiply two integers.\",\n                            \"parameters\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"a\": {\"type\": \"integer\", \"description\": \"First number\"},\n                                    \"b\": {\"type\": \"integer\", \"description\": \"Second number\"},\n                                },\n                                \"required\": [\"a\", \"b\"],\n                            },\n                            \"strict\": False,\n                        },\n                    ],\n                },\n                \"assistant_with_reasoning\": {\n                    \"input\": [\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"What is 2+2?\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"summary\": [{\"type\": \"summary_text\", \"text\": \"Thinking...\"}],\n                            \"type\": \"reasoning\",\n                            \"encrypted_content\": \"enc_abc\",\n                        },\n                        {\n                            \"content\": [\n                                {\n                                    \"type\": \"output_text\",\n                                    \"text\": \"4.\",\n                                    \"annotations\": [],\n                                }\n                            ],\n                            \"role\": \"assistant\",\n                            \"type\": \"message\",\n                        },\n                        {\n                            \"content\": [{\"type\": \"input_text\", \"text\": \"Thanks!\"}],\n                            \"role\": \"user\",\n                            \"type\": \"message\",\n                        },\n                    ],\n                    \"tools\": [],\n                },\n            }\n        )\n\n\nasync def test_openai_responses_generation_kwargs():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/responses\").mock(return_value=Response(200, json=make_response()))\n        provider = OpenAIResponses(\n            model=\"gpt-4.1\", api_key=\"test-key\", stream=False\n        ).with_generation_kwargs(temperature=0.7, max_output_tokens=2048)\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert (body[\"temperature\"], body[\"max_output_tokens\"]) == snapshot((0.7, 2048))\n\n\nasync def test_openai_responses_omits_reasoning_by_default():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/responses\").mock(return_value=Response(200, json=make_response()))\n        provider = OpenAIResponses(model=\"gpt-4.1\", api_key=\"test-key\", stream=False)\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert \"reasoning\" not in body\n        assert \"include\" not in body\n\n\nasync def test_openai_responses_with_thinking_off_omits_reasoning():\n    \"\"\"with_thinking(\"off\") should also omit reasoning from the request,\n    since thinking_effort_to_reasoning_effort(\"off\") returns None.\"\"\"\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/responses\").mock(return_value=Response(200, json=make_response()))\n        provider = OpenAIResponses(model=\"gpt-4.1\", api_key=\"test-key\", stream=False).with_thinking(\n            \"off\"\n        )\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Hi\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert \"reasoning\" not in body\n        assert \"include\" not in body\n\n\nasync def test_openai_responses_with_thinking_low():\n    \"\"\"with_thinking(\"low\") should send reasoning with effort=\"low\".\"\"\"\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/responses\").mock(return_value=Response(200, json=make_response()))\n        provider = OpenAIResponses(model=\"gpt-4.1\", api_key=\"test-key\", stream=False).with_thinking(\n            \"low\"\n        )\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"reasoning\"] == snapshot({\"effort\": \"low\", \"summary\": \"auto\"})\n        assert body[\"include\"] == snapshot([\"reasoning.encrypted_content\"])\n\n\nasync def test_openai_responses_with_thinking():\n    with respx.mock(base_url=\"https://api.openai.com\") as mock:\n        mock.post(\"/v1/responses\").mock(return_value=Response(200, json=make_response()))\n        provider = OpenAIResponses(model=\"gpt-4.1\", api_key=\"test-key\", stream=False).with_thinking(\n            \"high\"\n        )\n        stream = await provider.generate(\"\", [], [Message(role=\"user\", content=\"Think\")])\n        async for _ in stream:\n            pass\n        body = json.loads(mock.calls.last.request.content.decode())\n        assert body[\"reasoning\"] == snapshot({\"effort\": \"high\", \"summary\": \"auto\"})\n"
  },
  {
    "path": "packages/kosong/tests/test_chat_provider.py",
    "content": "import asyncio\n\nfrom kosong.chat_provider import APIStatusError, StreamedMessagePart\nfrom kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.chat_provider.mock import MockChatProvider\nfrom kosong.message import Message, TextPart\n\n\ndef test_mock_chat_provider():\n    input_parts: list[StreamedMessagePart] = [\n        TextPart(text=\"Hello, world!\"),\n    ]\n\n    async def generate() -> list[StreamedMessagePart]:\n        chat_provider = MockChatProvider(message_parts=input_parts)\n        parts: list[StreamedMessagePart] = []\n        async for part in await chat_provider.generate(system_prompt=\"\", tools=[], history=[]):\n            parts.append(part)\n        return parts\n\n    output_parts = asyncio.run(generate())\n    assert output_parts == input_parts\n\n\nasync def test_chaos_chat_provider():\n    base = Kimi(model=\"dummy\", api_key=\"sk-1234567890\")\n    chat_provider = ChaosChatProvider(\n        base,\n        chaos_config=ChaosConfig(error_probability=1.0),\n    )\n    for _ in range(3):\n        try:\n            parts: list[StreamedMessagePart] = []\n            async for part in await chat_provider.generate(\n                system_prompt=\"\",\n                tools=[],\n                history=[Message(role=\"user\", content=[TextPart(text=\"Hello, world!\")])],\n            ):\n                parts.append(part)\n            raise AssertionError(\"Expected APIStatusError\")\n        except APIStatusError:\n            pass\n"
  },
  {
    "path": "packages/kosong/tests/test_context.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom kosong.contrib.context.linear import JsonlLinearStorage, LinearContext, MemoryLinearStorage\nfrom kosong.message import Message\n\n\ndef test_linear_context():\n    context = LinearContext(\n        storage=MemoryLinearStorage(),\n    )\n    assert context.history == []\n\n    async def run():\n        await context.add_message(Message(role=\"user\", content=\"abc\"))\n        await context.add_message(Message(role=\"assistant\", content=\"def\"))\n        return context.history\n\n    history = asyncio.run(run())\n    assert history == [\n        Message(role=\"user\", content=\"abc\"),\n        Message(role=\"assistant\", content=\"def\"),\n    ]\n\n\ndef test_linear_context_with_jsonl_storage():\n    test_path = Path(__file__).parent / \"test.jsonl\"\n    if test_path.exists():\n        test_path.unlink()\n\n    async def run():\n        storage = JsonlLinearStorage(path=test_path)\n        context = LinearContext(\n            storage=storage,\n        )\n        await context.add_message(Message(role=\"user\", content=\"abc\"))\n        await context.add_message(Message(role=\"assistant\", content=\"def\"))\n        return context.history\n\n    history = asyncio.run(run())\n    assert history == [\n        Message(role=\"user\", content=\"abc\"),\n        Message(role=\"assistant\", content=\"def\"),\n    ]\n\n    with open(test_path) as f:\n        expected = \"\"\"\\\n{\"role\":\"user\",\"content\":\"abc\"}\n{\"role\":\"assistant\",\"content\":\"def\"}\n\"\"\"\n        assert f.read() == expected\n\n    test_path.unlink()\n"
  },
  {
    "path": "packages/kosong/tests/test_echo_chat_provider.py",
    "content": "import pytest\n\nfrom kosong import generate\nfrom kosong.chat_provider import ChatProviderError, StreamedMessagePart, TokenUsage\nfrom kosong.chat_provider.echo import EchoChatProvider\nfrom kosong.message import (\n    AudioURLPart,\n    ImageURLPart,\n    Message,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n    VideoURLPart,\n)\n\n\nasync def test_echo_chat_provider_streams_parts():\n    dsl = \"\\n\".join(\n        [\n            \"id: echo-42\",\n            'usage: {\"input_other\": 10, \"output\": 2, \"input_cache_read\": 3}',\n            \"text: Hello,\",\n            \"text:  world!\",\n            \"think: thinking...\",\n            'image_url: {\"url\": \"https://example.com/image.png\", \"id\": \"img-1\"}',\n            \"audio_url: https://example.com/audio.mp3\",\n            \"video_url: https://example.com/video.mp4\",\n            (\n                'tool_call: {\"id\": \"call-1\", \"name\": \"search\", '\n                '\"arguments\": \"{\\\\\"q\\\\\":\\\\\"python\\\\\"\", \"extras\": {\"source\": \"test\"}}'\n            ),\n            'tool_call_part: {\"arguments_part\": \"}\"}',\n        ]\n    )\n\n    provider = EchoChatProvider()\n    history = [Message(role=\"user\", content=dsl)]\n\n    parts: list[StreamedMessagePart] = []\n    stream = await provider.generate(system_prompt=\"\", tools=[], history=history)\n    async for part in stream:\n        parts.append(part)\n\n    assert stream.id == \"echo-42\"\n    assert stream.usage == TokenUsage(\n        input_other=10,\n        output=2,\n        input_cache_read=3,\n        input_cache_creation=0,\n    )\n    assert parts == [\n        TextPart(text=\"Hello,\"),\n        TextPart(text=\" world!\"),\n        ThinkPart(think=\"thinking...\", encrypted=None),\n        ImageURLPart(\n            image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\", id=\"img-1\")\n        ),\n        AudioURLPart(audio_url=AudioURLPart.AudioURL(url=\"https://example.com/audio.mp3\", id=None)),\n        VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video.mp4\", id=None)),\n        ToolCall(\n            id=\"call-1\",\n            function=ToolCall.FunctionBody(name=\"search\", arguments='{\"q\":\"python\"'),\n            extras={\"source\": \"test\"},\n        ),\n        ToolCallPart(arguments_part=\"}\"),\n    ]\n\n\nasync def test_echo_chat_provider_with_generate_merge_tool_call():\n    dsl = \"\"\"\n    text: Hello\n    tool_call: {\"id\": \"tc-1\", \"name\": \"get_weather\", \"arguments\": null}\n    tool_call_part: {\"arguments_part\": \"{\"}\n    tool_call_part: {\"arguments_part\": \"\\\\\"city\\\\\":\\\\\"Hangzhou\\\\\"\"}\n    tool_call_part: {\"arguments_part\": \"}\"}\n    tool_call_part:\n    \"\"\"\n\n    provider = EchoChatProvider()\n    history = [Message(role=\"user\", content=dsl)]\n\n    result = await generate(\n        chat_provider=provider,\n        system_prompt=\"\",\n        tools=[],\n        history=history,\n    )\n    message = result.message\n\n    assert message.content == [TextPart(text=\"Hello\")]\n    assert message.tool_calls == [\n        ToolCall(\n            id=\"tc-1\",\n            function=ToolCall.FunctionBody(name=\"get_weather\", arguments='{\"city\":\"Hangzhou\"}'),\n        )\n    ]\n    assert result.usage is None\n\n\nasync def test_echo_chat_provider_rejects_non_string_arguments():\n    dsl = \"\"\"\n    tool_call: {\"id\": \"call-1\", \"name\": \"search\", \"arguments\": {\"q\": \"python\"}}\n    \"\"\"\n    provider = EchoChatProvider()\n    history = [Message(role=\"user\", content=dsl)]\n\n    with pytest.raises(ChatProviderError):\n        await provider.generate(system_prompt=\"\", tools=[], history=history)\n\n\nasync def test_echo_chat_provider_requires_user_message():\n    provider = EchoChatProvider()\n    history = [Message(role=\"tool\", content=\"tool output\")]\n\n    with pytest.raises(ChatProviderError):\n        await provider.generate(system_prompt=\"\", tools=[], history=history)\n\n\nasync def test_echo_chat_provider_requires_dsl_content():\n    provider = EchoChatProvider()\n    history = [Message(role=\"user\", content=\"\")]\n\n    with pytest.raises(ChatProviderError):\n        await provider.generate(system_prompt=\"\", tools=[], history=history)\n"
  },
  {
    "path": "packages/kosong/tests/test_generate.py",
    "content": "import asyncio\nfrom copy import deepcopy\n\nfrom kosong import generate\nfrom kosong.chat_provider import StreamedMessagePart\nfrom kosong.chat_provider.mock import MockChatProvider\nfrom kosong.message import ImageURLPart, TextPart, ToolCall, ToolCallPart\n\n\ndef test_generate():\n    chat_provider = MockChatProvider(\n        message_parts=[\n            TextPart(text=\"Hello, \"),\n            TextPart(text=\"world\"),\n            TextPart(text=\"!\"),\n            ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")),\n            TextPart(text=\"Another text.\"),\n            TextPart(text=\"\"),\n            ToolCall(\n                id=\"get_weather#123\",\n                function=ToolCall.FunctionBody(name=\"get_weather\", arguments=None),\n            ),\n            ToolCallPart(arguments_part=\"{\"),\n            ToolCallPart(arguments_part='\"city\":'),\n            ToolCallPart(arguments_part='\"Beijing\"'),\n            ToolCallPart(arguments_part=\"}\"),\n            ToolCallPart(arguments_part=None),\n        ]\n    )\n    message = asyncio.run(generate(chat_provider, system_prompt=\"\", tools=[], history=[])).message\n    assert message.content == [\n        TextPart(text=\"Hello, world!\"),\n        ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")),\n        TextPart(text=\"Another text.\"),\n    ]\n    assert message.tool_calls == [\n        ToolCall(\n            id=\"get_weather#123\",\n            function=ToolCall.FunctionBody(name=\"get_weather\", arguments='{\"city\":\"Beijing\"}'),\n        ),\n    ]\n\n\ndef test_generate_with_callbacks():\n    input_parts: list[StreamedMessagePart] = [\n        TextPart(text=\"Hello, \"),\n        TextPart(text=\"world\"),\n        TextPart(text=\"!\"),\n        ToolCall(\n            id=\"get_weather#123\",\n            function=ToolCall.FunctionBody(name=\"get_weather\", arguments=None),\n        ),\n        ToolCallPart(arguments_part=\"{\"),\n        ToolCallPart(arguments_part='\"city\":'),\n        ToolCallPart(arguments_part='\"Beijing\"'),\n        ToolCallPart(arguments_part=\"}\"),\n        ToolCall(\n            id=\"get_time#123\",\n            function=ToolCall.FunctionBody(name=\"get_time\", arguments=\"\"),\n        ),\n    ]\n    chat_provider = MockChatProvider(message_parts=deepcopy(input_parts))\n\n    output_parts: list[StreamedMessagePart] = []\n    output_tool_calls: list[ToolCall] = []\n\n    async def on_message_part(part: StreamedMessagePart):\n        output_parts.append(part)\n\n    async def on_tool_call(tool_call: ToolCall):\n        output_tool_calls.append(tool_call)\n\n    message = asyncio.run(\n        generate(\n            chat_provider,\n            system_prompt=\"\",\n            tools=[],\n            history=[],\n            on_message_part=on_message_part,\n            on_tool_call=on_tool_call,\n        )\n    ).message\n    assert output_parts == input_parts\n    assert output_tool_calls == message.tool_calls\n"
  },
  {
    "path": "packages/kosong/tests/test_json_schema_deref.py",
    "content": "from __future__ import annotations\n\nfrom typing import Literal\n\nfrom inline_snapshot import snapshot\nfrom pydantic import BaseModel, Field\n\nfrom kosong.utils.jsonschema import deref_json_schema\nfrom kosong.utils.typing import JsonType\n\nJsonSchema = dict[str, JsonType]\n\n\ndef test_no_ref():\n    class Params(BaseModel):\n        id: str = Field(description=\"The ID of the action.\")\n        action: str = Field(description=\"The action to be performed.\")\n\n    resolved = deref_json_schema(Params.model_json_schema())\n    assert resolved == snapshot(\n        {\n            \"properties\": {\n                \"id\": {\"description\": \"The ID of the action.\", \"title\": \"Id\", \"type\": \"string\"},\n                \"action\": {\n                    \"description\": \"The action to be performed.\",\n                    \"title\": \"Action\",\n                    \"type\": \"string\",\n                },\n            },\n            \"required\": [\"id\", \"action\"],\n            \"title\": \"Params\",\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_simple_ref():\n    class Todo(BaseModel):\n        title: str = Field(description=\"The title of the todo item.\")\n        status: Literal[\"pending\", \"completed\"] = Field(description=\"The status of the todo item.\")\n\n    class Params(BaseModel):\n        todos: list[Todo] = Field(description=\"A list of todo items.\")\n\n    resolved = deref_json_schema(Params.model_json_schema())\n    assert resolved == snapshot(\n        {\n            \"properties\": {\n                \"todos\": {\n                    \"description\": \"A list of todo items.\",\n                    \"items\": {\n                        \"properties\": {\n                            \"title\": {\n                                \"description\": \"The title of the todo item.\",\n                                \"title\": \"Title\",\n                                \"type\": \"string\",\n                            },\n                            \"status\": {\n                                \"description\": \"The status of the todo item.\",\n                                \"enum\": [\"pending\", \"completed\"],\n                                \"title\": \"Status\",\n                                \"type\": \"string\",\n                            },\n                        },\n                        \"required\": [\"title\", \"status\"],\n                        \"title\": \"Todo\",\n                        \"type\": \"object\",\n                    },\n                    \"title\": \"Todos\",\n                    \"type\": \"array\",\n                }\n            },\n            \"required\": [\"todos\"],\n            \"title\": \"Params\",\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_nested_ref():\n    class Address(BaseModel):\n        street: str = Field(description=\"The street address.\")\n        city: str = Field(description=\"The city.\")\n        zip_code: str = Field(description=\"The ZIP code.\")\n\n    class User(BaseModel):\n        name: str = Field(description=\"The name of the user.\")\n        email: str = Field(description=\"The email of the user.\")\n        address: Address = Field(description=\"The address of the user.\")\n\n    class Params(BaseModel):\n        users: list[User] = Field(description=\"A list of users.\")\n\n    resolved = deref_json_schema(Params.model_json_schema())\n    assert resolved == snapshot(\n        {\n            \"properties\": {\n                \"users\": {\n                    \"description\": \"A list of users.\",\n                    \"items\": {\n                        \"properties\": {\n                            \"name\": {\n                                \"description\": \"The name of the user.\",\n                                \"title\": \"Name\",\n                                \"type\": \"string\",\n                            },\n                            \"email\": {\n                                \"description\": \"The email of the user.\",\n                                \"title\": \"Email\",\n                                \"type\": \"string\",\n                            },\n                            \"address\": {\n                                \"description\": \"The address of the user.\",\n                                \"properties\": {\n                                    \"street\": {\n                                        \"description\": \"The street address.\",\n                                        \"title\": \"Street\",\n                                        \"type\": \"string\",\n                                    },\n                                    \"city\": {\n                                        \"description\": \"The city.\",\n                                        \"title\": \"City\",\n                                        \"type\": \"string\",\n                                    },\n                                    \"zip_code\": {\n                                        \"description\": \"The ZIP code.\",\n                                        \"title\": \"Zip Code\",\n                                        \"type\": \"string\",\n                                    },\n                                },\n                                \"required\": [\"street\", \"city\", \"zip_code\"],\n                                \"title\": \"Address\",\n                                \"type\": \"object\",\n                            },\n                        },\n                        \"required\": [\"name\", \"email\", \"address\"],\n                        \"title\": \"User\",\n                        \"type\": \"object\",\n                    },\n                    \"title\": \"Users\",\n                    \"type\": \"array\",\n                }\n            },\n            \"required\": [\"users\"],\n            \"title\": \"Params\",\n            \"type\": \"object\",\n        }\n    )\n"
  },
  {
    "path": "packages/kosong/tests/test_kimi_stream_usage.py",
    "content": "from openai.types.chat import ChatCompletionChunk\n\nfrom kosong.chat_provider.kimi import extract_usage_from_chunk\n\n\ndef test_kimi_extracts_choice_usage_in_stream_chunk() -> None:\n    chunk = ChatCompletionChunk.model_validate(\n        {\n            \"id\": \"chatcmpl-6970b5d02fa474c1767e8767\",\n            \"object\": \"chat.completion.chunk\",\n            \"created\": 1768994256,\n            \"model\": \"kimi-k2-turbo-preview\",\n            \"choices\": [\n                {\n                    \"index\": 0,\n                    \"delta\": {},\n                    \"finish_reason\": \"stop\",\n                    \"usage\": {\n                        \"prompt_tokens\": 8,\n                        \"completion_tokens\": 11,\n                        \"total_tokens\": 19,\n                        \"cached_tokens\": 8,\n                    },\n                }\n            ],\n            \"system_fingerprint\": \"fpv0_10a6da87\",\n        }\n    )\n    usage = extract_usage_from_chunk(chunk)\n    assert usage is not None\n    assert usage.prompt_tokens == 8\n    assert usage.completion_tokens == 11\n    assert usage.total_tokens == 19\n"
  },
  {
    "path": "packages/kosong/tests/test_message.py",
    "content": "from inline_snapshot import snapshot\n\nfrom kosong.message import (\n    AudioURLPart,\n    ImageURLPart,\n    Message,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    VideoURLPart,\n)\n\n\ndef test_plain_text_message():\n    message = Message(role=\"user\", content=\"Hello, world!\")\n    dumped = message.model_dump(exclude_none=True)\n    assert dumped == snapshot({\"role\": \"user\", \"content\": \"Hello, world!\"})\n    assert Message.model_validate(dumped) == message\n\n\ndef test_message_with_single_part():\n    message = Message(\n        role=\"assistant\",\n        content=ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")),\n    )\n    dumped = message.model_dump(exclude_none=True)\n    assert dumped == snapshot(\n        {\n            \"role\": \"assistant\",\n            \"content\": [\n                {\n                    \"type\": \"image_url\",\n                    \"image_url\": {\"url\": \"https://example.com/image.png\", \"id\": None},\n                }\n            ],\n        }\n    )\n    assert Message.model_validate(dumped) == message\n\n\ndef test_message_with_tool_calls():\n    message = Message(\n        role=\"assistant\",\n        content=[TextPart(text=\"Hello, world!\")],\n        tool_calls=[\n            ToolCall(id=\"123\", function=ToolCall.FunctionBody(name=\"function\", arguments=\"{}\"))\n        ],\n    )\n    dumped = message.model_dump(exclude_none=True)\n    assert dumped == snapshot(\n        {\n            \"role\": \"assistant\",\n            \"content\": \"Hello, world!\",\n            \"tool_calls\": [\n                {\n                    \"type\": \"function\",\n                    \"id\": \"123\",\n                    \"function\": {\"name\": \"function\", \"arguments\": \"{}\"},\n                }\n            ],\n        }\n    )\n    assert Message.model_validate(dumped) == message\n\n\ndef test_message_with_no_content():\n    message = Message(\n        role=\"assistant\",\n        content=[],\n        tool_calls=[\n            ToolCall(id=\"123\", function=ToolCall.FunctionBody(name=\"function\", arguments=\"{}\"))\n        ],\n    )\n\n    assert message.model_dump(exclude_none=True) == snapshot(\n        {\n            \"role\": \"assistant\",\n            \"content\": [],\n            \"tool_calls\": [\n                {\n                    \"type\": \"function\",\n                    \"id\": \"123\",\n                    \"function\": {\"name\": \"function\", \"arguments\": \"{}\"},\n                }\n            ],\n        }\n    )\n\n\ndef test_message_with_complex_content():\n    message = Message(\n        role=\"user\",\n        content=[\n            TextPart(text=\"Hello, world!\"),\n            ThinkPart(think=\"I think I need to think about this.\"),\n            ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")),\n            AudioURLPart(audio_url=AudioURLPart.AudioURL(url=\"https://example.com/audio.mp3\")),\n            VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video.mp4\")),\n        ],\n        tool_calls=[\n            ToolCall(id=\"123\", function=ToolCall.FunctionBody(name=\"function\", arguments=\"{}\")),\n        ],\n    )\n    dumped = message.model_dump(exclude_none=True)\n    assert dumped == snapshot(\n        {\n            \"role\": \"user\",\n            \"content\": [\n                {\"type\": \"text\", \"text\": \"Hello, world!\"},\n                {\n                    \"type\": \"think\",\n                    \"think\": \"I think I need to think about this.\",\n                    \"encrypted\": None,\n                },\n                {\n                    \"type\": \"image_url\",\n                    \"image_url\": {\"url\": \"https://example.com/image.png\", \"id\": None},\n                },\n                {\n                    \"type\": \"audio_url\",\n                    \"audio_url\": {\"url\": \"https://example.com/audio.mp3\", \"id\": None},\n                },\n                {\n                    \"type\": \"video_url\",\n                    \"video_url\": {\"url\": \"https://example.com/video.mp4\", \"id\": None},\n                },\n            ],\n            \"tool_calls\": [\n                {\n                    \"type\": \"function\",\n                    \"id\": \"123\",\n                    \"function\": {\"name\": \"function\", \"arguments\": \"{}\"},\n                }\n            ],\n        }\n    )\n    assert Message.model_validate(dumped) == message\n\n\ndef test_deserialize_from_json_plain_text():\n    data = {\n        \"role\": \"user\",\n        \"content\": \"Hello, world!\",\n    }\n    message = Message.model_validate(data)\n    assert message == snapshot(Message(role=\"user\", content=[TextPart(text=\"Hello, world!\")]))\n\n\ndef test_deserialize_from_json_with_content_and_tool_calls():\n    data = {\n        \"role\": \"assistant\",\n        \"content\": [\n            {\n                \"type\": \"text\",\n                \"text\": \"Hello, world!\",\n            }\n        ],\n        \"tool_calls\": [\n            {\n                \"type\": \"function\",\n                \"id\": \"tc_123\",\n                \"function\": {\"name\": \"do_something\", \"arguments\": '{\"x\":1}'},\n            }\n        ],\n    }\n    message = Message.model_validate(data)\n    assert message == snapshot(\n        Message(\n            role=\"assistant\",\n            content=[TextPart(text=\"Hello, world!\")],\n            tool_calls=[\n                ToolCall(\n                    id=\"tc_123\",\n                    function=ToolCall.FunctionBody(name=\"do_something\", arguments='{\"x\":1}'),\n                )\n            ],\n        )\n    )\n\n\ndef test_deserialize_from_json_none_content_with_tool_calls():\n    data = {\n        \"role\": \"assistant\",\n        \"content\": None,\n        \"tool_calls\": [\n            {\n                \"type\": \"function\",\n                \"id\": \"tc_456\",\n                \"function\": {\"name\": \"do_other\", \"arguments\": \"{}\"},\n            }\n        ],\n    }\n    message = Message.model_validate(data)\n    assert message == snapshot(\n        Message(\n            role=\"assistant\",\n            content=[],\n            tool_calls=[\n                ToolCall(\n                    id=\"tc_456\", function=ToolCall.FunctionBody(name=\"do_other\", arguments=\"{}\")\n                )\n            ],\n        )\n    )\n\n\ndef test_deserialize_from_json_with_content_but_no_tool_calls():\n    data = {\n        \"role\": \"user\",\n        \"content\": [\n            {\n                \"type\": \"text\",\n                \"text\": \"Only content, no tools.\",\n            }\n        ],\n    }\n    message = Message.model_validate(data)\n    assert message == snapshot(\n        Message(role=\"user\", content=[TextPart(text=\"Only content, no tools.\")])\n    )\n\n\ndef test_message_with_empty_list_content():\n    \"\"\"Test that content=[] serializes to None and deserializes back to [].\"\"\"\n    # Create message with empty list content\n    message = Message(role=\"assistant\", content=[])\n\n    # Serialize - empty list should become None\n    dumped = message.model_dump()\n    assert dumped == snapshot(\n        {\n            \"role\": \"assistant\",\n            \"name\": None,\n            \"content\": [],\n            \"tool_calls\": None,\n            \"tool_call_id\": None,\n            \"partial\": None,\n        }\n    )\n\n    # Deserialize back - None should become empty list\n    assert Message.model_validate(dumped) == snapshot(Message(role=\"assistant\", content=[]))\n\n    # Test with tool_calls\n    message_with_tools = Message(\n        role=\"assistant\",\n        content=[],\n        tool_calls=[\n            ToolCall(id=\"123\", function=ToolCall.FunctionBody(name=\"test_func\", arguments=\"{}\"))\n        ],\n    )\n    dumped = message_with_tools.model_dump()\n    assert dumped == snapshot(\n        {\n            \"role\": \"assistant\",\n            \"name\": None,\n            \"content\": [],\n            \"tool_calls\": [\n                {\n                    \"type\": \"function\",\n                    \"id\": \"123\",\n                    \"function\": {\"name\": \"test_func\", \"arguments\": \"{}\"},\n                    \"extras\": None,\n                }\n            ],\n            \"tool_call_id\": None,\n            \"partial\": None,\n        }\n    )\n    assert Message.model_validate(dumped) == snapshot(\n        Message(\n            role=\"assistant\",\n            content=[],\n            tool_calls=[\n                ToolCall(id=\"123\", function=ToolCall.FunctionBody(name=\"test_func\", arguments=\"{}\"))\n            ],\n        )\n    )\n\n\ndef test_message_extract_text():\n    message = Message(\n        role=\"user\",\n        content=[\n            TextPart(text=\"Hello, \"),\n            TextPart(text=\"world\"),\n            ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\")),\n            TextPart(text=\"!\"),\n            ThinkPart(think=\"This is a thought.\"),\n        ],\n    )\n    extracted_text = message.extract_text()\n    assert extracted_text == snapshot(\"Hello, world!\")\n    extracted_text = message.extract_text(sep=\"\\n\")\n    assert extracted_text == snapshot(\"\"\"\\\nHello, \\n\\\nworld\n!\\\n\"\"\")\n"
  },
  {
    "path": "packages/kosong/tests/test_openai_common.py",
    "content": "import asyncio\nfrom typing import Any\n\nimport httpx\nimport pytest\n\nfrom kosong.chat_provider import APIConnectionError, openai_common\nfrom kosong.contrib.chat_provider.openai_legacy import OpenAILegacy\n\n\ndef test_create_openai_client_does_not_inject_max_retries(monkeypatch: pytest.MonkeyPatch) -> None:\n    captured: dict[str, Any] = {}\n\n    class FakeAsyncOpenAI:\n        def __init__(self, **kwargs: Any) -> None:\n            captured.update(kwargs)\n\n    monkeypatch.setattr(openai_common, \"AsyncOpenAI\", FakeAsyncOpenAI)\n\n    openai_common.create_openai_client(\n        api_key=\"test-key\",\n        base_url=\"https://example.com/v1\",\n        client_kwargs={\"timeout\": 3},\n    )\n\n    assert captured[\"api_key\"] == \"test-key\"\n    assert captured[\"base_url\"] == \"https://example.com/v1\"\n    assert captured[\"timeout\"] == 3\n    assert \"max_retries\" not in captured\n\n\n@pytest.mark.asyncio\nasync def test_retry_recovery_does_not_close_shared_http_client() -> None:\n    http_client = httpx.AsyncClient()\n    provider = OpenAILegacy(\n        model=\"gpt-4.1\",\n        api_key=\"test-key\",\n        http_client=http_client,\n    )\n\n    provider.on_retryable_error(APIConnectionError(\"Connection error.\"))\n    await asyncio.sleep(0)\n    await asyncio.sleep(0)\n\n    assert provider.client._client is http_client  # type: ignore[reportPrivateUsage]\n    assert http_client.is_closed is False\n    await http_client.aclose()\n"
  },
  {
    "path": "packages/kosong/tests/test_scripted_echo_chat_provider.py",
    "content": "import pytest\n\nfrom kosong import generate\nfrom kosong.chat_provider import ChatProviderError, StreamedMessagePart, TokenUsage\nfrom kosong.chat_provider.echo import ScriptedEchoChatProvider\nfrom kosong.message import (\n    AudioURLPart,\n    ImageURLPart,\n    Message,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n    VideoURLPart,\n)\n\n\nasync def test_scripted_echo_chat_provider_streams_parts():\n    dsl = \"\\n\".join(\n        [\n            \"id: scripted-1\",\n            'usage: {\"input_other\": 4, \"output\": 1, \"input_cache_read\": 2}',\n            \"text: Hello,\",\n            \"text:  world!\",\n            \"think: thinking...\",\n            'image_url: {\"url\": \"https://example.com/image.png\", \"id\": \"img-1\"}',\n            \"audio_url: https://example.com/audio.mp3\",\n            \"video_url: https://example.com/video.mp4\",\n            (\n                'tool_call: {\"id\": \"call-1\", \"name\": \"search\", '\n                '\"arguments\": \"{\\\\\"q\\\\\":\\\\\"python\\\\\"\", \"extras\": {\"source\": \"test\"}}'\n            ),\n            'tool_call_part: {\"arguments_part\": \"}\"}',\n        ]\n    )\n    second_dsl = \"\\n\".join(\n        [\n            \"id: scripted-2\",\n            \"text: second turn\",\n        ]\n    )\n\n    provider = ScriptedEchoChatProvider([dsl, second_dsl])\n    history = [Message(role=\"tool\", content=\"tool output\")]\n\n    parts: list[StreamedMessagePart] = []\n    stream = await provider.generate(system_prompt=\"\", tools=[], history=history)\n    async for part in stream:\n        parts.append(part)\n\n    assert stream.id == \"scripted-1\"\n    assert stream.usage == TokenUsage(\n        input_other=4,\n        output=1,\n        input_cache_read=2,\n        input_cache_creation=0,\n    )\n    assert parts == [\n        TextPart(text=\"Hello,\"),\n        TextPart(text=\" world!\"),\n        ThinkPart(think=\"thinking...\", encrypted=None),\n        ImageURLPart(\n            image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\", id=\"img-1\")\n        ),\n        AudioURLPart(audio_url=AudioURLPart.AudioURL(url=\"https://example.com/audio.mp3\", id=None)),\n        VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video.mp4\", id=None)),\n        ToolCall(\n            id=\"call-1\",\n            function=ToolCall.FunctionBody(name=\"search\", arguments='{\"q\":\"python\"'),\n            extras={\"source\": \"test\"},\n        ),\n        ToolCallPart(arguments_part=\"}\"),\n    ]\n\n    second_stream = await provider.generate(system_prompt=\"\", tools=[], history=[])\n    second_parts = [part async for part in second_stream]\n\n    assert second_stream.id == \"scripted-2\"\n    assert second_stream.usage is None\n    assert second_parts == [TextPart(text=\"second turn\")]\n\n\nasync def test_scripted_echo_chat_provider_exhausted():\n    provider = ScriptedEchoChatProvider([\"text: only once\"])\n\n    await provider.generate(system_prompt=\"\", tools=[], history=[])\n\n    with pytest.raises(ChatProviderError):\n        await provider.generate(system_prompt=\"\", tools=[], history=[])\n\n\nasync def test_scripted_echo_chat_provider_with_generate_merge_tool_call():\n    dsl = \"\"\"\n    text: Hello\n    tool_call: {\"id\": \"tc-1\", \"name\": \"get_weather\", \"arguments\": null}\n    tool_call_part: {\"arguments_part\": \"{\"}\n    tool_call_part: {\"arguments_part\": \"\\\\\"city\\\\\":\\\\\"Hangzhou\\\\\"\"}\n    tool_call_part: {\"arguments_part\": \"}\"}\n    tool_call_part:\n    \"\"\"\n\n    provider = ScriptedEchoChatProvider([dsl])\n    history = [Message(role=\"tool\", content=\"tool output\")]\n\n    result = await generate(\n        chat_provider=provider,\n        system_prompt=\"\",\n        tools=[],\n        history=history,\n    )\n    message = result.message\n\n    assert message.content == [TextPart(text=\"Hello\")]\n    assert message.tool_calls == [\n        ToolCall(\n            id=\"tc-1\",\n            function=ToolCall.FunctionBody(name=\"get_weather\", arguments='{\"city\":\"Hangzhou\"}'),\n        )\n    ]\n    assert result.usage is None\n\n\nasync def test_scripted_echo_chat_provider_rejects_non_string_arguments():\n    dsl = \"\"\"\n    tool_call: {\"id\": \"call-1\", \"name\": \"search\", \"arguments\": {\"q\": \"python\"}}\n    \"\"\"\n    provider = ScriptedEchoChatProvider([dsl])\n\n    with pytest.raises(ChatProviderError):\n        await provider.generate(system_prompt=\"\", tools=[], history=[])\n\n\nasync def test_scripted_echo_chat_provider_requires_dsl_content():\n    provider = ScriptedEchoChatProvider([\"# comment only\\n```\"])\n\n    with pytest.raises(ChatProviderError):\n        await provider.generate(system_prompt=\"\", tools=[], history=[])\n"
  },
  {
    "path": "packages/kosong/tests/test_step.py",
    "content": "import asyncio\nfrom typing import override\n\nfrom kosong import step\nfrom kosong.chat_provider import StreamedMessagePart\nfrom kosong.chat_provider.mock import MockChatProvider\nfrom kosong.message import TextPart, ToolCall\nfrom kosong.tooling import CallableTool, ParametersType, ToolOk, ToolResult, ToolReturnValue\nfrom kosong.tooling.simple import SimpleToolset\n\n\ndef test_step():\n    class PlusTool(CallableTool):\n        name: str = \"plus\"\n        description: str = \"This is a plus tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {\n                \"a\": {\"type\": \"integer\"},\n                \"b\": {\"type\": \"integer\"},\n            },\n        }\n\n        @override\n        async def __call__(self, a: int, b: int) -> ToolReturnValue:\n            return ToolOk(output=str(a + b))\n\n    plus_tool_call = ToolCall(\n        id=\"plus#123\",\n        function=ToolCall.FunctionBody(name=\"plus\", arguments='{\"a\": 1, \"b\": 2}'),\n    )\n    input_parts: list[StreamedMessagePart] = [\n        TextPart(text=\"Hello, world!\"),\n        plus_tool_call,\n    ]\n    chat_provider = MockChatProvider(message_parts=input_parts)\n    toolset = SimpleToolset([PlusTool()])\n\n    output_parts: list[StreamedMessagePart] = []\n    collected_tool_results: list[ToolResult] = []\n\n    def on_message_part(part: StreamedMessagePart):\n        output_parts.append(part)\n\n    def on_tool_result(result: ToolResult):\n        collected_tool_results.append(result)\n\n    async def run():\n        step_result = await step(\n            chat_provider,\n            system_prompt=\"\",\n            toolset=toolset,\n            history=[],\n            on_message_part=on_message_part,\n            on_tool_result=on_tool_result,\n        )\n        tool_results = await step_result.tool_results()\n        return step_result, tool_results\n\n    step_result, tool_results = asyncio.run(run())\n    assert step_result.message.content == [TextPart(text=\"Hello, world!\")]\n    assert step_result.tool_calls == [plus_tool_call]\n    assert output_parts == input_parts\n    assert tool_results == [ToolResult(tool_call_id=\"plus#123\", return_value=ToolOk(output=\"3\"))]\n    assert collected_tool_results == tool_results\n"
  },
  {
    "path": "packages/kosong/tests/test_tool_call.py",
    "content": "import asyncio\nimport inspect\nimport json\nfrom typing import override\n\nfrom inline_snapshot import snapshot\nfrom pydantic import BaseModel, Field\n\nfrom kosong.message import ToolCall\nfrom kosong.tooling import (\n    BriefDisplayBlock,\n    CallableTool,\n    CallableTool2,\n    ParametersType,\n    ToolError,\n    ToolOk,\n    ToolResult,\n    ToolResultFuture,\n    ToolReturnValue,\n)\nfrom kosong.tooling.error import (\n    ToolNotFoundError,\n    ToolParseError,\n    ToolRuntimeError,\n    ToolValidateError,\n)\nfrom kosong.tooling.simple import SimpleToolset\n\n\ndef test_callable_tool_int_argument():\n    class TestTool(CallableTool):\n        name: str = \"test\"\n        description: str = \"This is a test tool\"\n        parameters: ParametersType = {\n            \"type\": \"integer\",\n        }\n\n        @override\n        async def __call__(self, test: int) -> ToolReturnValue:\n            return ToolOk(output=f\"Test tool called with {test}\")\n\n    tool = TestTool()\n    assert asyncio.run(tool.call(1)) == ToolOk(output=\"Test tool called with 1\")\n\n\ndef test_callable_tool_list_argument():\n    class TestTool(CallableTool):\n        name: str = \"test\"\n        description: str = \"This is a test tool\"\n        parameters: ParametersType = {\n            \"type\": \"array\",\n            \"items\": {\n                \"type\": \"string\",\n            },\n        }\n\n        @override\n        async def __call__(self, a: str, b: str) -> ToolReturnValue:\n            return ToolOk(output=\"Test tool called with a and b\")\n\n    tool = TestTool()\n    assert asyncio.run(tool.call([\"a\", \"b\"])) == ToolOk(output=\"Test tool called with a and b\")\n\n\ndef test_callable_tool_dict_argument():\n    class TestTool(CallableTool):\n        name: str = \"test\"\n        description: str = \"This is a test tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {\n                \"a\": {\"type\": \"string\"},\n                \"b\": {\"type\": \"integer\"},\n            },\n        }\n\n        @override\n        async def __call__(self, a: str, b: int) -> ToolReturnValue:\n            return ToolOk(output=f\"Test tool called with {a} and {b}\")\n\n    tool = TestTool()\n    assert asyncio.run(tool.call({\"a\": \"a\", \"b\": 1})) == ToolOk(\n        output=\"Test tool called with a and 1\"\n    )\n\n\ndef test_simple_toolset():\n    class PlusTool(CallableTool):\n        name: str = \"plus\"\n        description: str = \"This is a plus tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {\n                \"a\": {\"type\": \"integer\"},\n                \"b\": {\"type\": \"integer\"},\n            },\n            \"required\": [\"a\", \"b\"],\n        }\n\n        @override\n        async def __call__(self, a: int, b: int) -> ToolReturnValue:\n            return ToolOk(output=str(a + b))\n\n    class CompareTool(CallableTool):\n        name: str = \"compare\"\n        description: str = \"This is a compare tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {\n                \"a\": {\"type\": \"integer\"},\n                \"b\": {\"type\": \"integer\"},\n            },\n            \"required\": [\"a\", \"b\"],\n        }\n\n        @override\n        async def __call__(self, a: int, b: int) -> ToolReturnValue:\n            return ToolOk(output=\"greater\" if a > b else \"less\" if a < b else \"equal\")\n\n    class RaiseTool(CallableTool):\n        name: str = \"raise\"\n        description: str = \"This is a raise tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {},\n        }\n\n        @override\n        async def __call__(self) -> ToolReturnValue:\n            raise Exception(\"test exception\")\n\n    class ErrorTool(CallableTool):\n        name: str = \"error\"\n        description: str = \"This is a error tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {},\n        }\n\n        @override\n        async def __call__(self) -> ToolReturnValue:\n            return ToolError(message=\"test error\", brief=\"Error\")\n\n    class InvalidReturnTypeTool(CallableTool):\n        name: str = \"invalid_return_type\"\n        description: str = \"This is a invalid return type tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {},\n        }\n\n        @override\n        async def __call__(self) -> str:  # type: ignore[reportIncompatibleMethodOverride]\n            return \"invalid return type\"\n\n    toolset = SimpleToolset([PlusTool()])\n    toolset += CompareTool()\n    toolset += RaiseTool()\n    toolset.add(ErrorTool())\n    assert toolset.tools[0].name == \"plus\"\n    assert toolset.tools[1].name == \"compare\"\n    assert toolset.tools[2].name == \"raise\"\n    assert toolset.tools[3].name == \"error\"\n\n    try:\n        toolset += InvalidReturnTypeTool()\n    except TypeError as e:\n        assert str(e) == (\n            \"Expected tool `invalid_return_type` to return `ToolReturnValue`, \"\n            \"but got `<class 'str'>`\"\n        )\n    else:\n        raise AssertionError(\"Expected TypeError\")\n\n    tool_calls = [\n        ToolCall(\n            id=\"1\",\n            function=ToolCall.FunctionBody(\n                name=\"plus\",\n                arguments=json.dumps({\"a\": 1, \"b\": 2}),\n            ),\n        ),\n        ToolCall(\n            id=\"2\",\n            function=ToolCall.FunctionBody(\n                name=\"compare\",\n                arguments='{\"a\": 1, b: 2}',\n            ),\n        ),\n        ToolCall(\n            id=\"3\",\n            function=ToolCall.FunctionBody(\n                name=\"plus\",\n                arguments='{\"a\": 1}',\n            ),\n        ),\n        ToolCall(\n            id=\"4\",\n            function=ToolCall.FunctionBody(\n                name=\"raise\",\n                arguments=None,\n            ),\n        ),\n        ToolCall(\n            id=\"5\",\n            function=ToolCall.FunctionBody(\n                name=\"not_found\",\n                arguments=None,\n            ),\n        ),\n        ToolCall(\n            id=\"6\",\n            function=ToolCall.FunctionBody(\n                name=\"error\",\n                arguments=None,\n            ),\n        ),\n    ]\n\n    async def run() -> list[ToolResult]:\n        futures: list[ToolResultFuture] = []\n        for tool_call in tool_calls:\n            result = toolset.handle(tool_call)\n            if isinstance(result, ToolResult):\n                future = ToolResultFuture()\n                future.set_result(result)\n                futures.append(future)\n            else:\n                futures.append(result)\n        return await asyncio.gather(*futures)\n\n    results = asyncio.run(run())\n    assert results[0].tool_call_id == \"1\"\n    assert results[0].return_value == ToolOk(output=\"3\")\n    assert isinstance(results[1].return_value, ToolParseError)\n    assert isinstance(results[2].return_value, ToolValidateError)\n    assert isinstance(results[3].return_value, ToolRuntimeError)\n    assert isinstance(results[4].return_value, ToolNotFoundError)\n    assert isinstance(results[5].return_value, ToolError)\n    assert results[5].return_value.message == \"test error\"\n    assert results[5].return_value.display == snapshot([BriefDisplayBlock(text=\"Error\")])\n\n\ndef test_callable_tool_2():\n    class TestParams(BaseModel):\n        a: int = Field(description=\"The first argument\")\n        b: int = Field(default=0, description=\"The second argument\")\n        c: str = Field(default=\"\", alias=\"-c\", description=\"The third argument\")\n\n    class TestTool(CallableTool2[TestParams]):\n        name: str = \"test\"\n        description: str = \"This is a test tool\"\n        params: type[TestParams] = TestParams\n\n        @override\n        async def __call__(self, params: TestParams) -> ToolReturnValue:\n            return ToolOk(output=f\"Test tool called with {params.a} and {params.b}\")\n\n    tool = TestTool()\n    assert tool.base.name == \"test\"\n    assert tool.base.description == \"This is a test tool\"\n    assert tool.base.parameters == {\n        \"type\": \"object\",\n        \"properties\": {\n            \"a\": {\"type\": \"integer\", \"description\": \"The first argument\"},\n            \"b\": {\"type\": \"integer\", \"description\": \"The second argument\", \"default\": 0},\n            \"-c\": {\"type\": \"string\", \"description\": \"The third argument\", \"default\": \"\"},\n        },\n        \"required\": [\"a\"],\n    }\n\n    assert asyncio.run(tool.call({\"a\": 1, \"b\": 2})) == ToolOk(\n        output=\"Test tool called with 1 and 2\"\n    )\n    assert asyncio.run(tool.call({\"a\": 1})) == ToolOk(output=\"Test tool called with 1 and 0\")\n    assert isinstance(asyncio.run(tool.call({\"b\": 2})), ToolValidateError)\n\n\ndef test_simple_toolset_sub():\n    class TestParams(BaseModel):\n        pass\n\n    class TestTool(CallableTool2[TestParams]):\n        name: str = \"test\"\n        description: str = \"This is a test tool\"\n        params: type[TestParams] = TestParams\n\n        @override\n        async def __call__(self, params: TestParams) -> ToolReturnValue:\n            return ToolOk(output=\"Test tool called\")\n\n    toolset = SimpleToolset([TestTool()])\n    assert len(toolset.tools) == 1\n    toolset.remove(TestTool.name)\n    assert len(toolset.tools) == 0\n\n\n# Tests for both real type and string annotations support\n# These tests verify that SimpleToolset works correctly in both scenarios:\n# 1. When type annotations are actual type objects (normal case)\n# 2. When type annotations are strings (with `from __future__ import annotations`)\n\n\ndef test_simple_toolset_with_real_type_annotation_callable_tool():\n    \"\"\"Test that SimpleToolset works with CallableTool when using real type annotation.\"\"\"\n\n    class TestTool(CallableTool):\n        name: str = \"test_real\"\n        description: str = \"This is a test tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {},\n        }\n\n        @override\n        async def __call__(self) -> ToolReturnValue:\n            return ToolOk(output=\"test\")\n\n    # Verify the annotation is actually a type (not string)\n    assert inspect.signature(TestTool().__call__).return_annotation is ToolReturnValue\n\n    toolset = SimpleToolset()\n    toolset += TestTool()\n    assert len(toolset.tools) == 1\n    assert toolset.tools[0].name == \"test_real\"\n\n\ndef test_simple_toolset_with_string_annotation_callable_tool():\n    \"\"\"Test that SimpleToolset works with CallableTool when using string annotation.\"\"\"\n\n    class TestTool(CallableTool):\n        name: str = \"test_str\"\n        description: str = \"This is a test tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {},\n        }\n\n        @override\n        async def __call__(self) -> \"ToolReturnValue\":  # type: ignore[reportIncompatibleMethodOverride]\n            return ToolOk(output=\"test\")\n\n    # Verify the annotation is actually a string\n    assert isinstance(inspect.signature(TestTool().__call__).return_annotation, str)\n\n    toolset = SimpleToolset()\n    toolset += TestTool()\n    assert len(toolset.tools) == 1\n    assert toolset.tools[0].name == \"test_str\"\n\n\ndef test_simple_toolset_with_invalid_string_annotation_rejected():\n    \"\"\"Test that SimpleToolset rejects invalid string annotations.\"\"\"\n\n    class TestTool(CallableTool):\n        name: str = \"test_invalid\"\n        description: str = \"This is a test tool\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {},\n        }\n\n        @override\n        async def __call__(self) -> \"InvalidType\":  # noqa: F821  # type: ignore[reportUnknownParameterType]\n            return ToolOk(output=\"test\")  # type: ignore[return-value]\n\n    tool_instance = TestTool()\n    sig = inspect.signature(tool_instance.__call__)  # type: ignore[reportUnknownMemberType, reportUnknownArgumentType]\n    # Verify the annotation is actually a string\n    assert isinstance(sig.return_annotation, str)\n\n    toolset = SimpleToolset()\n    try:\n        toolset += TestTool()\n        raise AssertionError(\"Expected TypeError for invalid string annotation\")\n    except TypeError as e:\n        assert \"InvalidType\" in str(e)\n\n\ndef test_simple_toolset_with_real_type_annotation_callable_tool2():\n    \"\"\"Test that SimpleToolset works with CallableTool2 when using real type annotation.\"\"\"\n\n    class TestParams(BaseModel):\n        value: int = Field(description=\"A test value\")\n\n    class TestTool(CallableTool2[TestParams]):\n        name: str = \"test2_real\"\n        description: str = \"This is a test tool 2\"\n        params: type[TestParams] = TestParams\n\n        @override\n        async def __call__(self, params: TestParams) -> ToolReturnValue:\n            return ToolOk(output=f\"value: {params.value}\")\n\n    # Verify the annotation is actually a type (not string)\n    assert inspect.signature(TestTool().__call__).return_annotation is ToolReturnValue\n\n    toolset = SimpleToolset()\n    toolset += TestTool()\n    assert len(toolset.tools) == 1\n    assert toolset.tools[0].name == \"test2_real\"\n\n\ndef test_simple_toolset_with_string_annotation_callable_tool2():\n    \"\"\"Test that SimpleToolset works with CallableTool2 when using string annotation.\"\"\"\n\n    class TestParams(BaseModel):\n        value: int = Field(description=\"A test value\")\n\n    class TestTool(CallableTool2[TestParams]):\n        name: str = \"test2_str\"\n        description: str = \"This is a test tool 2\"\n        params: type[TestParams] = TestParams\n\n        @override\n        async def __call__(self, params: TestParams) -> \"ToolReturnValue\":\n            return ToolOk(output=f\"value: {params.value}\")\n\n    # Verify the annotation is actually a string\n    assert isinstance(inspect.signature(TestTool().__call__).return_annotation, str)\n\n    toolset = SimpleToolset()\n    toolset += TestTool()\n    assert len(toolset.tools) == 1\n    assert toolset.tools[0].name == \"test2_str\"\n\n\nasync def _test_handle_async_with_string_annotation():\n    \"\"\"Helper async function to test tool handling with string annotation.\"\"\"\n\n    class TestTool(CallableTool):\n        name: str = \"add_str\"\n        description: str = \"Add two numbers\"\n        parameters: ParametersType = {\n            \"type\": \"object\",\n            \"properties\": {\n                \"a\": {\"type\": \"integer\"},\n                \"b\": {\"type\": \"integer\"},\n            },\n            \"required\": [\"a\", \"b\"],\n        }\n\n        @override\n        async def __call__(self, a: int, b: int) -> \"ToolReturnValue\":\n            return ToolOk(output=str(a + b))\n\n    # Verify the annotation is actually a string\n    assert isinstance(inspect.signature(TestTool().__call__).return_annotation, str)\n\n    toolset = SimpleToolset([TestTool()])\n    tool_call = ToolCall(\n        id=\"1\",\n        function=ToolCall.FunctionBody(\n            name=\"add_str\",\n            arguments='{\"a\": 2, \"b\": 3}',\n        ),\n    )\n\n    result = toolset.handle(tool_call)\n    if asyncio.isfuture(result):\n        result = await result\n    return result\n\n\ndef test_simple_toolset_with_string_annotation_handle():\n    \"\"\"Test that tools with string annotations can be called correctly.\"\"\"\n    result = asyncio.run(_test_handle_async_with_string_annotation())\n    assert result.return_value == ToolOk(output=\"5\")\n"
  },
  {
    "path": "packages/kosong/tests/test_tool_result.py",
    "content": "from inline_snapshot import snapshot\n\nfrom kosong.message import ImageURLPart, TextPart\nfrom kosong.tooling import (\n    BriefDisplayBlock,\n    ToolError,\n    ToolOk,\n    ToolReturnValue,\n    UnknownDisplayBlock,\n)\nfrom kosong.tooling.error import ToolNotFoundError\n\n\ndef test_tool_return_value():\n    ret = ToolReturnValue(\n        is_error=False,\n        output=[\n            TextPart(type=\"text\", text=\"output text\"),\n            ImageURLPart(\n                type=\"image_url\",\n                image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\"),\n            ),\n        ],\n        message=\"This is a successful tool call.\",\n        display=[\n            BriefDisplayBlock(text=\"a brief msg for user\"),\n        ],\n        extras={\"key1\": \"value1\", \"key2\": 42},\n    )\n    dump = ret.model_dump(mode=\"json\", exclude_none=True)\n    assert dump == snapshot(\n        {\n            \"is_error\": False,\n            \"output\": [\n                {\"type\": \"text\", \"text\": \"output text\"},\n                {\n                    \"type\": \"image_url\",\n                    \"image_url\": {\"url\": \"https://example.com/image.png\"},\n                },\n            ],\n            \"message\": \"This is a successful tool call.\",\n            \"display\": [{\"type\": \"brief\", \"text\": \"a brief msg for user\"}],\n            \"extras\": {\"key1\": \"value1\", \"key2\": 42},\n        }\n    )\n\n    assert ToolReturnValue.model_validate(dump) == ret\n\n\ndef test_tool_ok():\n    ret = ToolOk(\n        output=\"output text\",\n        message=\"This is a successful tool call.\",\n        brief=\"a brief msg for user\",\n    )\n    assert isinstance(ret, ToolReturnValue)\n    assert ret.model_dump(mode=\"json\", exclude_none=True) == snapshot(\n        {\n            \"is_error\": False,\n            \"output\": \"output text\",\n            \"message\": \"This is a successful tool call.\",\n            \"display\": [{\"type\": \"brief\", \"text\": \"a brief msg for user\"}],\n        }\n    )\n\n\ndef test_tool_error():\n    ret = ToolError(\n        message=\"This is a failed tool call.\",\n        brief=\"a brief error msg for user\",\n        output=\"error output text\",\n    )\n    assert isinstance(ret, ToolReturnValue)\n    assert ret.model_dump(mode=\"json\", exclude_none=True) == snapshot(\n        {\n            \"is_error\": True,\n            \"output\": \"error output text\",\n            \"message\": \"This is a failed tool call.\",\n            \"display\": [{\"type\": \"brief\", \"text\": \"a brief error msg for user\"}],\n        }\n    )\n\n\ndef test_tool_ok_with_content_parts():\n    ret = ToolOk(\n        output=[\n            TextPart(type=\"text\", text=\"output text\"),\n            ImageURLPart(\n                type=\"image_url\",\n                image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.png\"),\n            ),\n        ],\n        message=\"This is a successful tool call.\",\n        brief=\"a brief msg for user\",\n    )\n    assert isinstance(ret, ToolReturnValue)\n    assert ret.model_dump(mode=\"json\", exclude_none=True) == snapshot(\n        {\n            \"is_error\": False,\n            \"output\": [\n                {\"type\": \"text\", \"text\": \"output text\"},\n                {\n                    \"type\": \"image_url\",\n                    \"image_url\": {\"url\": \"https://example.com/image.png\"},\n                },\n            ],\n            \"message\": \"This is a successful tool call.\",\n            \"display\": [{\"type\": \"brief\", \"text\": \"a brief msg for user\"}],\n        }\n    )\n\n\ndef test_tool_error_subclass():\n    ret = ToolNotFoundError(tool_name=\"non_existent_tool\")\n    assert isinstance(ret, ToolReturnValue)\n    assert isinstance(ret, ToolError)\n    assert ret.model_dump(mode=\"json\", exclude_none=True) == snapshot(\n        {\n            \"is_error\": True,\n            \"output\": \"\",\n            \"message\": \"Tool `non_existent_tool` not found\",\n            \"display\": [{\"type\": \"brief\", \"text\": \"Tool `non_existent_tool` not found\"}],\n        }\n    )\n\n\ndef test_unknown_display_block():\n    payload = {\n        \"is_error\": False,\n        \"output\": \"ok\",\n        \"message\": \"done\",\n        \"display\": [\n            {\"type\": \"fancy\", \"title\": \"Hello\", \"payload\": {\"a\": 1}, \"list\": [1, 2]},\n        ],\n    }\n    ret = ToolReturnValue.model_validate(payload)\n    assert ret.display == snapshot(\n        [\n            UnknownDisplayBlock(\n                type=\"fancy\", data={\"title\": \"Hello\", \"payload\": {\"a\": 1}, \"list\": [1, 2]}\n            )\n        ]\n    )\n    assert ret.model_dump(mode=\"json\", exclude_none=True) == snapshot(\n        {\n            \"is_error\": False,\n            \"output\": \"ok\",\n            \"message\": \"done\",\n            \"display\": [\n                {\n                    \"type\": \"fancy\",\n                    \"data\": {\"title\": \"Hello\", \"payload\": {\"a\": 1}, \"list\": [1, 2]},\n                }\n            ],\n        }\n    )\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"kimi-cli\"\nversion = \"1.24.0\"\ndescription = \"Kimi Code CLI is your next CLI agent.\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\ndependencies = [\n    \"agent-client-protocol==0.8.0\",\n    \"aiofiles>=24.0,<26.0\",\n    \"aiohttp==3.13.3\",\n    \"typer==0.21.1\",\n    \"kosong[contrib]==0.45.0\",\n    # loguru stays >=0.6.0 because notify-py (via batrachian-toad) caps it at <=0.6.0 on 3.14+.\n    \"loguru>=0.6.0,<0.8\",\n    \"prompt-toolkit==3.0.52\",\n    \"pillow==12.1.0\",\n    \"pyyaml==6.0.3\",\n    \"rich==14.2.0\",\n    \"ripgrepy==2.2.0\",\n    \"streamingjson==0.0.5\",\n    \"trafilatura==2.0.0\",\n    # lxml is used by trafilatura/htmldate/justext; keep pinned for binary wheels.\n    \"lxml==6.0.2\",\n    \"tenacity==9.1.2\",\n    \"fastmcp==2.12.5\",\n    \"pydantic==2.12.5\",\n    \"httpx[socks]==0.28.1\",\n    \"pykaos==0.7.0\",\n    \"batrachian-toad==0.5.23; python_version >= \\\"3.14\\\"\",\n    \"tomlkit==0.14.0\",\n    \"jinja2==3.1.6\",\n    \"pyobjc-framework-cocoa>=12.1 ; sys_platform == 'darwin'\",\n    \"fastapi>=0.115.0\",\n    \"uvicorn[standard]>=0.32.0\",\n    \"scalar-fastapi>=1.5.0\",\n    \"websockets>=14.0\",\n    \"keyring>=25.7.0\",\n    \"setproctitle>=1.3.0\",\n]\n\n[dependency-groups]\ndev = [\n    \"pyinstaller==6.18.0\",\n    \"inline-snapshot[black]>=0.31.1\",\n    \"pyright>=1.1.407\",\n    \"ty>=0.0.9\",\n    \"pytest>=9.0.2\",\n    \"pytest-asyncio>=1.3.0\",\n    \"ruff>=0.14.10\",\n]\n\n[build-system]\nrequires = [\"uv_build>=0.8.5,<0.10.0\"]\nbuild-backend = \"uv_build\"\n\n[tool.uv.build-backend]\nmodule-name = [\"kimi_cli\"]\nsource-exclude = [\"examples/**/*\", \"tests/**/*\", \"src/kimi_cli/deps/**/*\"]\n\n[tool.uv.workspace]\nmembers = [\n    \"packages/kosong\",\n    \"packages/kaos\",\n    \"packages/kimi-code\",\n    \"sdks/kimi-sdk\",\n]\n\n[tool.uv.sources]\nkosong = { workspace = true }\npykaos = { workspace = true }\nkimi-cli = { workspace = true }\n\n[project.scripts]\nkimi = \"kimi_cli.__main__:main\"\nkimi-cli = \"kimi_cli.__main__:main\"\n\n[tool.ruff]\nline-length = 100\n\n[tool.ruff.lint]\nselect = [\n    \"E\",   # pycodestyle\n    \"F\",   # Pyflakes\n    \"UP\",  # pyupgrade\n    \"B\",   # flake8-bugbear\n    \"SIM\", # flake8-simplify\n    \"I\",   # isort\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"tests/**/*.py\" = [\"E501\"]\n\"tests_e2e/**/*.py\" = [\"E501\"]\n\"src/kimi_cli/web/api/**/*.py\" = [\"B008\"]  # FastAPI Depends() is standard usage\n\n[tool.pyright]\ntypeCheckingMode = \"standard\"\npythonVersion = \"3.14\"\ninclude = [\n    \"src/**/*.py\",\n    \"tests/**/*.py\",\n    \"tests_ai/scripts/**/*.py\",\n    \"tests_e2e/**/*.py\",\n]\nstrict = [\"src/kimi_cli/**/*.py\"]\n\n[tool.ty.environment]\npython-version = \"3.14\"\n\n[tool.ty.src]\ninclude = [\n    \"src/**/*.py\",\n    \"tests/**/*.py\",\n    \"tests_ai/scripts/**/*.py\",\n    \"tests_e2e/**/*.py\",\n]\n\n[tool.typos.files]\nextend-exclude = [\"kimi.spec\", \"pyinstaller.py\"]\n\n[tool.typos.default.extend-words]\ndatas = \"datas\"\nSeeked = \"Seeked\"\nseeked = \"seeked\"\niterm = \"iterm\"\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\nasyncio_mode = auto\n"
  },
  {
    "path": "scripts/build_vis.py",
    "content": "from __future__ import annotations\n\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nROOT = Path(__file__).resolve().parents[1]\nVIS_DIR = ROOT / \"vis\"\nDIST_DIR = VIS_DIR / \"dist\"\nNODE_MODULES = VIS_DIR / \"node_modules\"\nSTATIC_DIR = ROOT / \"src\" / \"kimi_cli\" / \"vis\" / \"static\"\n\n\nREQUIRED_VIS_TYPE_FILES = (\n    NODE_MODULES / \"vite\" / \"client.d.ts\",\n    NODE_MODULES / \"typescript\" / \"lib\" / \"typescript.d.ts\",\n)\n\n\ndef has_required_vis_type_files() -> bool:\n    return all(path.is_file() for path in REQUIRED_VIS_TYPE_FILES)\n\n\ndef resolve_npm() -> str | None:\n    candidates = [\"npm\"]\n    if os.name == \"nt\":\n        candidates.extend([\"npm.cmd\", \"npm.exe\", \"npm.bat\"])\n    for candidate in candidates:\n        npm = shutil.which(candidate)\n        if npm:\n            return npm\n    return None\n\n\ndef check_node_version() -> bool:\n    \"\"\"Vite 7 requires Node.js ^20.19.0 || >=22.12.0.\"\"\"\n    node = shutil.which(\"node\")\n    if not node:\n        return False\n    try:\n        result = subprocess.run([node, \"--version\"], capture_output=True, text=True, check=False)\n        version = result.stdout.strip().lstrip(\"v\")\n        parts = [int(x) for x in version.split(\".\")[:3]]\n        major, minor = parts[0], parts[1] if len(parts) > 1 else 0\n        ok = (major == 20 and minor >= 19) or (major >= 22 and (major > 22 or minor >= 12))\n        if not ok:\n            print(\n                f\"Node.js ^20.19.0 or >=22.12.0 required (Vite 7), found v{version}\",\n                file=sys.stderr,\n            )\n            return False\n    except Exception:\n        pass\n    return True\n\n\ndef run_npm(npm: str, args: list[str]) -> int:\n    try:\n        result = subprocess.run([npm, *args], check=False)\n    except FileNotFoundError:\n        print(\n            \"npm not found or failed to execute. Install Node.js (npm) and ensure it is on PATH.\",\n            file=sys.stderr,\n        )\n        return 1\n    return result.returncode\n\n\ndef main() -> int:\n    npm = resolve_npm()\n    if npm is None:\n        print(\"npm not found. Install Node.js (npm) to build the vis UI.\", file=sys.stderr)\n        return 1\n\n    if not check_node_version():\n        return 1\n\n    needs_install = (not NODE_MODULES.exists()) or (not has_required_vis_type_files())\n    if needs_install:\n        if NODE_MODULES.exists():\n            print(\"vis dependencies are incomplete; reinstalling with devDependencies...\")\n        returncode = run_npm(npm, [\"--prefix\", str(VIS_DIR), \"ci\", \"--include=dev\"])\n        if returncode != 0:\n            return returncode\n\n    returncode = run_npm(npm, [\"--prefix\", str(VIS_DIR), \"run\", \"build\"])\n    if returncode != 0:\n        return returncode\n\n    if not DIST_DIR.exists():\n        print(\"vis/dist not found after build. Check the vis build output.\", file=sys.stderr)\n        return 1\n\n    if STATIC_DIR.exists():\n        shutil.rmtree(STATIC_DIR)\n    STATIC_DIR.parent.mkdir(parents=True, exist_ok=True)\n    shutil.copytree(DIST_DIR, STATIC_DIR)\n\n    print(f\"Synced vis UI to {STATIC_DIR}\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "scripts/build_web.py",
    "content": "from __future__ import annotations\n\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport tomllib\nfrom pathlib import Path\n\nROOT = Path(__file__).resolve().parents[1]\nWEB_DIR = ROOT / \"web\"\nDIST_DIR = WEB_DIR / \"dist\"\nNODE_MODULES = WEB_DIR / \"node_modules\"\nSTATIC_DIR = ROOT / \"src\" / \"kimi_cli\" / \"web\" / \"static\"\n\nSTRICT_VERSION = os.environ.get(\"KIMI_WEB_STRICT_VERSION\", \"\").lower() in {\"1\", \"true\", \"yes\"}\n\nREQUIRED_WEB_TYPE_FILES = (\n    NODE_MODULES / \"vite\" / \"client.d.ts\",\n    NODE_MODULES / \"@types\" / \"node\" / \"index.d.ts\",\n)\n\n\ndef read_pyproject_version() -> str:\n    with (ROOT / \"pyproject.toml\").open(\"rb\") as handle:\n        data = tomllib.load(handle)\n    return str(data[\"project\"][\"version\"])\n\n\ndef find_version_in_dist(version: str) -> bool:\n    search_suffixes = {\".js\", \".css\", \".html\", \".map\"}\n    version_with_prefix = f\"v{version}\"\n    found_plain = False\n\n    for path in DIST_DIR.rglob(\"*\"):\n        if not path.is_file() or path.suffix not in search_suffixes:\n            continue\n        try:\n            content = path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n        except OSError:\n            continue\n        if version_with_prefix in content:\n            return True\n        if version in content:\n            found_plain = True\n\n    return found_plain\n\n\ndef resolve_npm() -> str | None:\n    candidates = [\"npm\"]\n    if os.name == \"nt\":\n        candidates.extend([\"npm.cmd\", \"npm.exe\", \"npm.bat\"])\n    for candidate in candidates:\n        npm = shutil.which(candidate)\n        if npm:\n            return npm\n    return None\n\n\ndef run_npm(npm: str, args: list[str]) -> int:\n    try:\n        result = subprocess.run([npm, *args], check=False)\n    except FileNotFoundError:\n        print(\n            \"npm not found or failed to execute. Install Node.js (npm) and ensure it is on PATH.\",\n            file=sys.stderr,\n        )\n        return 1\n    return result.returncode\n\n\ndef has_required_web_type_files() -> bool:\n    return all(path.is_file() for path in REQUIRED_WEB_TYPE_FILES)\n\n\ndef main() -> int:\n    npm = resolve_npm()\n    if npm is None:\n        print(\"npm not found. Install Node.js (npm) to build the web UI.\", file=sys.stderr)\n        return 1\n\n    expected_version = read_pyproject_version()\n    explicit_expected = os.environ.get(\"KIMI_WEB_EXPECT_VERSION\")\n    if explicit_expected and explicit_expected != expected_version:\n        print(\n            f\"web version mismatch: pyproject={expected_version}, expected={explicit_expected}\",\n            file=sys.stderr,\n        )\n        return 1\n\n    needs_install = (not NODE_MODULES.exists()) or (not has_required_web_type_files())\n    if needs_install:\n        if NODE_MODULES.exists():\n            print(\"web dependencies are incomplete; reinstalling with devDependencies...\")\n        returncode = run_npm(npm, [\"--prefix\", str(WEB_DIR), \"ci\", \"--include=dev\"])\n        if returncode != 0:\n            return returncode\n\n    returncode = run_npm(npm, [\"--prefix\", str(WEB_DIR), \"run\", \"build\"])\n    if returncode != 0:\n        return returncode\n\n    if not DIST_DIR.exists():\n        print(\"web/dist not found after build. Check the web build output.\", file=sys.stderr)\n        return 1\n    if STRICT_VERSION and not find_version_in_dist(expected_version):\n        print(\n            f\"web version not found in build output; expected version {expected_version}\",\n            file=sys.stderr,\n        )\n        return 1\n\n    if STATIC_DIR.exists():\n        shutil.rmtree(STATIC_DIR)\n    STATIC_DIR.parent.mkdir(parents=True, exist_ok=True)\n    shutil.copytree(DIST_DIR, STATIC_DIR)\n\n    print(f\"Synced web UI to {STATIC_DIR}\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "scripts/check_kimi_dependency_versions.py",
    "content": "from __future__ import annotations\n\nimport argparse\nimport re\nimport sys\nimport tomllib\nfrom pathlib import Path\n\n\ndef load_project_table(pyproject_path: Path) -> dict:\n    with pyproject_path.open(\"rb\") as handle:\n        data = tomllib.load(handle)\n\n    project = data.get(\"project\")\n    if not isinstance(project, dict):\n        raise ValueError(f\"Missing [project] table in {pyproject_path}\")\n\n    return project\n\n\ndef load_project_version(pyproject_path: Path) -> str:\n    project = load_project_table(pyproject_path)\n    version = project.get(\"version\")\n    if not isinstance(version, str) or not version:\n        raise ValueError(f\"Missing project.version in {pyproject_path}\")\n    return version\n\n\ndef find_pinned_dependency(deps: list[str], name: str) -> str | None:\n    pattern = re.compile(rf\"^{re.escape(name)}(?:\\[[^\\]]+\\])?(.+)$\")\n    for dep in deps:\n        match = pattern.match(dep)\n        if not match:\n            continue\n        spec = match.group(1)\n        pinned = re.match(r\"^==(.+)$\", spec)\n        if pinned:\n            return pinned.group(1)\n        return None\n    return None\n\n\ndef main() -> int:\n    parser = argparse.ArgumentParser(description=\"Validate kimi-cli dependency versions.\")\n    parser.add_argument(\"--root-pyproject\", type=Path, required=True)\n    parser.add_argument(\"--kosong-pyproject\", type=Path, required=True)\n    parser.add_argument(\"--pykaos-pyproject\", type=Path, required=True)\n    args = parser.parse_args()\n\n    try:\n        root_project = load_project_table(args.root_pyproject)\n    except ValueError as exc:\n        print(f\"error: {exc}\", file=sys.stderr)\n        return 1\n\n    deps = root_project.get(\"dependencies\", [])\n    if not isinstance(deps, list):\n        print(\n            f\"error: project.dependencies must be a list in {args.root_pyproject}\",\n            file=sys.stderr,\n        )\n        return 1\n\n    errors: list[str] = []\n    for name, pyproject_path in (\n        (\"kosong\", args.kosong_pyproject),\n        (\"pykaos\", args.pykaos_pyproject),\n    ):\n        try:\n            package_version = load_project_version(pyproject_path)\n        except ValueError as exc:\n            errors.append(str(exc))\n            continue\n\n        pinned_version = find_pinned_dependency(deps, name)\n        if pinned_version is None:\n            errors.append(f\"Missing pinned dependency for {name} in {args.root_pyproject}.\")\n            continue\n\n        if pinned_version != package_version:\n            errors.append(\n                f\"{name} version mismatch: root depends on {pinned_version}, \"\n                f\"but {pyproject_path} has {package_version}.\"\n            )\n\n    if errors:\n        for error in errors:\n            print(f\"error: {error}\", file=sys.stderr)\n        return 1\n\n    print(\"ok: kimi-cli dependencies match workspace package versions\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "scripts/check_version_tag.py",
    "content": "from __future__ import annotations\n\nimport argparse\nimport re\nimport sys\nimport tomllib\nfrom pathlib import Path\n\n\ndef load_project_version(pyproject_path: Path) -> str:\n    with pyproject_path.open(\"rb\") as handle:\n        data = tomllib.load(handle)\n\n    project = data.get(\"project\")\n    if not isinstance(project, dict):\n        raise ValueError(f\"Missing [project] table in {pyproject_path}\")\n\n    version = project.get(\"version\")\n    if not isinstance(version, str) or not version:\n        raise ValueError(f\"Missing project.version in {pyproject_path}\")\n\n    return version\n\n\ndef main() -> int:\n    parser = argparse.ArgumentParser(description=\"Validate tag version against pyproject.\")\n    parser.add_argument(\"--pyproject\", type=Path, required=True)\n    parser.add_argument(\"--expected-version\", required=True)\n    args = parser.parse_args()\n\n    semver_re = re.compile(r\"^\\d+\\.\\d+\\.\\d+$\")\n    if not semver_re.match(args.expected_version):\n        print(\n            f\"error: expected version must include patch (x.y.z): {args.expected_version}\",\n            file=sys.stderr,\n        )\n        return 1\n\n    try:\n        project_version = load_project_version(args.pyproject)\n    except ValueError as exc:\n        print(f\"error: {exc}\", file=sys.stderr)\n        return 1\n\n    if not semver_re.match(project_version):\n        print(\n            \"error: project version must include patch (x.y.z): \"\n            f\"{args.pyproject} has {project_version}\",\n            file=sys.stderr,\n        )\n        return 1\n\n    if project_version != args.expected_version:\n        print(\n            \"error: version mismatch: \"\n            f\"{args.pyproject} has {project_version}, expected {args.expected_version}\",\n            file=sys.stderr,\n        )\n        return 1\n\n    print(f\"ok: {args.pyproject} matches expected version {args.expected_version}\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "scripts/cleanup_tmp_sessions.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Clean up .kimi sessions whose workdir is under a temporary directory.\n\nThis script handles two cases:\n  1. Entries in kimi.json whose path is a tmp directory -> remove entry + session dir.\n  2. Orphan session directories on disk that have no matching kimi.json entry\n     (e.g. leftover from previously cleaned entries or tests).\n\nTemporary directories are detected by checking if the path starts with\ncommon tmp prefixes: /tmp, /private/tmp, /var/folders, /private/var/folders.\n\nUsage:\n    python scripts/cleanup_tmp_sessions.py          # dry-run (default)\n    python scripts/cleanup_tmp_sessions.py --apply   # actually delete\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport shutil\nimport sys\nfrom hashlib import md5\nfrom pathlib import Path\n\nKIMI_DIR = Path.home() / \".kimi\"\nMETADATA_FILE = KIMI_DIR / \"kimi.json\"\nSESSIONS_DIR = KIMI_DIR / \"sessions\"\n\nTMP_PREFIXES = (\n    \"/tmp/\",\n    \"/private/tmp/\",\n    \"/var/folders/\",\n    \"/private/var/folders/\",\n)\n\n\ndef is_tmp_path(path: str) -> bool:\n    \"\"\"Return True if *path* looks like a temporary directory.\"\"\"\n    if path in (\"/tmp\", \"/private/tmp\"):\n        return True\n    return any(path.startswith(p) for p in TMP_PREFIXES)\n\n\ndef work_dir_hash(path: str, kaos: str = \"local\") -> str:\n    h = md5(path.encode(\"utf-8\")).hexdigest()\n    return h if kaos == \"local\" else f\"{kaos}_{h}\"\n\n\ndef dir_total_size(d: Path) -> int:\n    return sum(f.stat().st_size for f in d.rglob(\"*\") if f.is_file())\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        description=__doc__,\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n    )\n    parser.add_argument(\"--apply\", action=\"store_true\", help=\"Actually delete (default is dry-run)\")\n    args = parser.parse_args()\n\n    if not METADATA_FILE.exists():\n        print(f\"Metadata file not found: {METADATA_FILE}\")\n        sys.exit(1)\n\n    with open(METADATA_FILE, encoding=\"utf-8\") as f:\n        metadata = json.load(f)\n\n    work_dirs: list[dict] = metadata.get(\"work_dirs\", [])\n\n    # --- Phase 1: tmp entries in kimi.json ---\n    tmp_entries: list[dict] = []\n    keep_entries: list[dict] = []\n    keep_hashes: set[str] = set()\n    for wd in work_dirs:\n        if is_tmp_path(wd.get(\"path\", \"\")):\n            tmp_entries.append(wd)\n        else:\n            keep_entries.append(wd)\n            keep_hashes.add(work_dir_hash(wd[\"path\"], wd.get(\"kaos\", \"local\")))\n\n    tmp_dirs: list[Path] = []\n    for wd in tmp_entries:\n        h = work_dir_hash(wd[\"path\"], wd.get(\"kaos\", \"local\"))\n        session_dir = SESSIONS_DIR / h\n        if session_dir.is_dir():\n            tmp_dirs.append(session_dir)\n\n    # --- Phase 2: orphan directories (on disk but not in kimi.json) ---\n    orphan_dirs: list[Path] = []\n    if SESSIONS_DIR.is_dir():\n        for d in SESSIONS_DIR.iterdir():\n            if d.is_dir() and d.name not in keep_hashes and d not in tmp_dirs:\n                orphan_dirs.append(d)\n\n    all_dirs_to_remove = tmp_dirs + orphan_dirs\n    if not all_dirs_to_remove and not tmp_entries:\n        print(\"No temporary or orphan sessions found. Nothing to do.\")\n        return\n\n    mode = \"DRY-RUN\" if not args.apply else \"APPLY\"\n\n    # Report phase 1\n    if tmp_entries:\n        n_entries, n_dirs = len(tmp_entries), len(tmp_dirs)\n        print(f\"[{mode}] Phase 1: {n_entries} tmp workdir entries, {n_dirs} dirs.\")\n        for wd in tmp_entries[:10]:\n            print(f\"  {wd['path']}\")\n        if len(tmp_entries) > 10:\n            print(f\"  ... and {len(tmp_entries) - 10} more\")\n        print()\n\n    # Report phase 2\n    if orphan_dirs:\n        n_orphans = len(orphan_dirs)\n        print(f\"[{mode}] Phase 2: {n_orphans} orphan session dirs.\")\n        for d in orphan_dirs[:5]:\n            subdirs = list(d.iterdir())\n            print(f\"  {d.name}/ ({len(subdirs)} session(s))\")\n        if len(orphan_dirs) > 5:\n            print(f\"  ... and {len(orphan_dirs) - 5} more\")\n        print()\n\n    total_size = sum(dir_total_size(d) for d in all_dirs_to_remove)\n    print(f\"Total: {len(all_dirs_to_remove)} directories, {total_size / 1024 / 1024:.1f} MB\")\n\n    if not args.apply:\n        print(\"\\nRe-run with --apply to delete.\")\n        return\n\n    # Delete session directories\n    for d in all_dirs_to_remove:\n        shutil.rmtree(d)\n    print(f\"\\nRemoved {len(all_dirs_to_remove)} session directories.\")\n\n    # Update metadata (remove tmp entries)\n    if tmp_entries:\n        metadata[\"work_dirs\"] = keep_entries\n        with open(METADATA_FILE, \"w\", encoding=\"utf-8\") as f:\n            json.dump(metadata, f, ensure_ascii=False, indent=2)\n        print(f\"Updated {METADATA_FILE.name}: {len(work_dirs)} -> {len(keep_entries)} work_dirs.\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/install.ps1",
    "content": "$ErrorActionPreference = \"Stop\"\n\nfunction Install-Uv {\n  Invoke-RestMethod -Uri \"https://astral.sh/uv/install.ps1\" | Invoke-Expression\n}\n\nif (Get-Command uv -ErrorAction SilentlyContinue) {\n  $uvBin = \"uv\"\n} else {\n  Install-Uv\n  $uvBin = \"uv\"\n}\n\nif (-not (Get-Command $uvBin -ErrorAction SilentlyContinue)) {\n  Write-Error \"Error: uv not found after installation.\"\n  exit 1\n}\n\n& $uvBin tool install --python 3.13 kimi-cli\n"
  },
  {
    "path": "scripts/install.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\ninstall_uv() {\n  if command -v curl >/dev/null 2>&1; then\n    curl -fsSL https://astral.sh/uv/install.sh | sh\n    return\n  fi\n\n  if command -v wget >/dev/null 2>&1; then\n    wget -qO- https://astral.sh/uv/install.sh | sh\n    return\n  fi\n\n  echo \"Error: curl or wget is required to install uv.\" >&2\n  exit 1\n}\n\nif command -v uv >/dev/null 2>&1; then\n  UV_BIN=\"uv\"\nelse\n  install_uv\n  UV_BIN=\"uv\"\nfi\n\nif ! command -v \"$UV_BIN\" >/dev/null 2>&1; then\n  echo \"Error: uv not found after installation.\" >&2\n  exit 1\nfi\n\n\"$UV_BIN\" tool install --python 3.13 kimi-cli\n"
  },
  {
    "path": "sdks/kimi-sdk/CHANGELOG.md",
    "content": "# Changelog\n\n## Unreleased\n\n## 0.2.1 (2026-01-24)\n\n- Relax kosong dependency to support kosong 0.40.x\n\n## 0.2.0 (2026-01-21)\n\n- Export `KimiFiles` class to support video file uploads\n\n## 0.1.2 (2026-01-21)\n\n- Update kosong dependency upper bound to support kosong 0.39.x\n\n## 0.1.1 (2026-01-16)\n\n- Fix kosong dependency version constraint to support kosong 0.38.x\n\n## 0.1.0 (2026-01-08)\n\n- Initial release.\n"
  },
  {
    "path": "sdks/kimi-sdk/LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "sdks/kimi-sdk/NOTICE",
    "content": "Kimi SDK\nCopyright 2025 Moonshot AI\n\nThis product includes software developed at\nMoonshot AI (https://www.moonshot.ai/).\n"
  },
  {
    "path": "sdks/kimi-sdk/README.md",
    "content": "# Kimi SDK\n\nKimi SDK provides a convenient way to access the Kimi API and build agent workflows in Python.\n\n## Installation\n\nKimi SDK requires Python 3.12 or higher. We recommend using uv as the package manager.\n\n```bash\nuv init --python 3.12  # or higher\n```\n\nThen add Kimi SDK as a dependency:\n\n```bash\nuv add kimi-sdk\n```\n\n## Examples\n\n### Simple chat completion\n\n```python\nimport asyncio\n\nfrom kimi_sdk import Kimi, Message, generate\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    history = [\n        Message(role=\"user\", content=\"Who are you?\"),\n    ]\n\n    result = await generate(\n        chat_provider=kimi,\n        system_prompt=\"You are a helpful assistant.\",\n        tools=[],\n        history=history,\n    )\n    print(result.message)\n    print(result.usage)\n\n\nasyncio.run(main())\n```\n\n### Streaming output\n\n```python\nimport asyncio\n\nfrom kimi_sdk import Kimi, Message, StreamedMessagePart, generate\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    history = [\n        Message(role=\"user\", content=\"Who are you?\"),\n    ]\n\n    def output(message_part: StreamedMessagePart) -> None:\n        print(message_part)\n\n    result = await generate(\n        chat_provider=kimi,\n        system_prompt=\"You are a helpful assistant.\",\n        tools=[],\n        history=history,\n        on_message_part=output,\n    )\n    print(result.message)\n    print(result.usage)\n\n\nasyncio.run(main())\n```\n\n### Upload video\n\n```python\nimport asyncio\nfrom pathlib import Path\nfrom kimi_sdk import Kimi, Message, TextPart, generate\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    video_path = Path(\"demo.mp4\")\n    video_part = await kimi.files.upload_video(\n        data=video_path.read_bytes(),\n        mime_type=\"video/mp4\",\n    )\n\n    history = [\n        Message(\n            role=\"user\",\n            content=[\n                TextPart(text=\"Please describe this video.\"),\n                video_part,\n            ],\n        ),\n    ]\n\n    result = await generate(\n        chat_provider=kimi,\n        system_prompt=\"You are a helpful assistant.\",\n        tools=[],\n        history=history,\n    )\n    print(result.message)\n    print(result.usage)\n\n\nasyncio.run(main())\n```\n\n### Tool calling with `step`\n\n```python\nimport asyncio\n\nfrom pydantic import BaseModel\n\nfrom kimi_sdk import CallableTool2, Kimi, Message, SimpleToolset, StepResult, ToolOk, ToolReturnValue, step\n\n\nclass AddToolParams(BaseModel):\n    a: int\n    b: int\n\n\nclass AddTool(CallableTool2[AddToolParams]):\n    name: str = \"add\"\n    description: str = \"Add two integers.\"\n    params: type[AddToolParams] = AddToolParams\n\n    async def __call__(self, params: AddToolParams) -> ToolReturnValue:\n        return ToolOk(output=str(params.a + params.b))\n\n\nasync def main() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    toolset = SimpleToolset()\n    toolset += AddTool()\n\n    history = [\n        Message(role=\"user\", content=\"Please add 2 and 3 with the add tool.\"),\n    ]\n\n    result: StepResult = await step(\n        chat_provider=kimi,\n        system_prompt=\"You are a precise math tutor.\",\n        toolset=toolset,\n        history=history,\n    )\n    print(result.message)\n    print(await result.tool_results())\n\n\nasyncio.run(main())\n```\n\n## Environment variables\n\n- `KIMI_API_KEY`: API key for the Kimi API.\n- `KIMI_BASE_URL`: Override the API base URL (defaults to `https://api.moonshot.ai/v1`).\n"
  },
  {
    "path": "sdks/kimi-sdk/pyproject.toml",
    "content": "[project]\nname = \"kimi-sdk\"\nversion = \"0.2.1\"\ndescription = \"A lightweight Python SDK for the Kimi API.\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\ndependencies = [\"kosong>=0.37.0\"]\n\n[dependency-groups]\ndev = [\n    \"httpx>=0.28.1,<0.29.0\",\n    \"inline-snapshot[black]>=0.31.1\",\n    \"pdoc>=16.0.0\",\n    \"pyright>=1.1.407\",\n    \"ty>=0.0.7\",\n    \"pytest>=9.0.2\",\n    \"pytest-asyncio>=1.3.0\",\n    \"ruff>=0.14.10\",\n]\n\n[build-system]\nrequires = [\"uv_build>=0.8.5,<0.10.0\"]\nbuild-backend = \"uv_build\"\n\n[tool.uv.build-backend]\nmodule-name = [\"kimi_sdk\"]\nsource-exclude = [\"tests/**/*\"]\n\n[tool.ruff]\nline-length = 100\n\n[tool.ruff.lint]\nselect = [\n    \"E\",   # pycodestyle\n    \"F\",   # Pyflakes\n    \"UP\",  # pyupgrade\n    \"B\",   # flake8-bugbear\n    \"SIM\", # flake8-simplify\n    \"I\",   # isort\n]\n\n[tool.pyright]\ntypeCheckingMode = \"strict\"\npythonVersion = \"3.14\"\ninclude = [\"src/**/*.py\", \"tests/**/*.py\"]\n\n[tool.ty.environment]\npython-version = \"3.14\"\n\n[tool.ty.src]\ninclude = [\"src/**/*.py\", \"tests/**/*.py\"]\n"
  },
  {
    "path": "sdks/kimi-sdk/src/kimi_sdk/__init__.py",
    "content": "\"\"\"\nKimi SDK provides a convenient way to access the Kimi API and build agent workflows.\n\nKey features:\n\n- `generate` creates a completion stream and merges message parts into a `Message`\n  with optional `TokenUsage`.\n- `step` layers tool dispatch over `generate`, returning `StepResult` and tool outputs.\n- Message structures, content parts, and tool abstractions live in this module.\n\nExample (minimal agent loop):\n\n```python\nimport asyncio\n\nfrom kimi_sdk import Kimi, Message, SimpleToolset, StepResult, ToolResult, step\n\n\ndef tool_result_to_message(result: ToolResult) -> Message:\n    return Message(\n        role=\"tool\",\n        tool_call_id=result.tool_call_id,\n        content=result.return_value.output,\n    )\n\n\nasync def agent_loop() -> None:\n    kimi = Kimi(\n        base_url=\"https://api.moonshot.ai/v1\",\n        api_key=\"your_kimi_api_key_here\",\n        model=\"kimi-k2-turbo-preview\",\n    )\n\n    toolset = SimpleToolset()\n    history: list[Message] = []\n    system_prompt = \"You are a helpful assistant.\"\n\n    while True:\n        user_input = input(\"You: \").strip()\n        if not user_input:\n            continue\n        if user_input.lower() in {\"exit\", \"quit\"}:\n            break\n\n        history.append(Message(role=\"user\", content=user_input))\n\n        while True:\n            result: StepResult = await step(\n                chat_provider=kimi,\n                system_prompt=system_prompt,\n                toolset=toolset,\n                history=history,\n            )\n\n            history.append(result.message)\n            tool_results = await result.tool_results()\n            for tool_result in tool_results:\n                history.append(tool_result_to_message(tool_result))\n\n            if text := result.message.extract_text():\n                print(\"Assistant:\", text)\n\n            if not result.tool_calls:\n                break\n\n\nasyncio.run(agent_loop())\n```\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom kosong import GenerateResult, StepResult, generate, step\nfrom kosong.chat_provider import (\n    APIConnectionError,\n    APIEmptyResponseError,\n    APIStatusError,\n    APITimeoutError,\n    ChatProviderError,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.chat_provider.kimi import Kimi, KimiFiles, KimiStreamedMessage\nfrom kosong.message import (\n    AudioURLPart,\n    ContentPart,\n    ImageURLPart,\n    Message,\n    Role,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n    VideoURLPart,\n)\nfrom kosong.tooling import (\n    BriefDisplayBlock,\n    CallableTool,\n    CallableTool2,\n    DisplayBlock,\n    Tool,\n    ToolError,\n    ToolOk,\n    ToolResult,\n    ToolResultFuture,\n    ToolReturnValue,\n    Toolset,\n    UnknownDisplayBlock,\n)\nfrom kosong.tooling.simple import SimpleToolset\n\n__all__ = [\n    # providers\n    \"Kimi\",\n    \"KimiFiles\",\n    \"KimiStreamedMessage\",\n    \"StreamedMessagePart\",\n    \"ThinkingEffort\",\n    # provider errors\n    \"APIConnectionError\",\n    \"APIEmptyResponseError\",\n    \"APIStatusError\",\n    \"APITimeoutError\",\n    \"ChatProviderError\",\n    # messages and content parts\n    \"Message\",\n    \"Role\",\n    \"ContentPart\",\n    \"TextPart\",\n    \"ThinkPart\",\n    \"ImageURLPart\",\n    \"AudioURLPart\",\n    \"VideoURLPart\",\n    \"ToolCall\",\n    \"ToolCallPart\",\n    # tooling\n    \"Tool\",\n    \"CallableTool\",\n    \"CallableTool2\",\n    \"Toolset\",\n    \"SimpleToolset\",\n    \"ToolReturnValue\",\n    \"ToolOk\",\n    \"ToolError\",\n    \"ToolResult\",\n    \"ToolResultFuture\",\n    # display blocks\n    \"DisplayBlock\",\n    \"BriefDisplayBlock\",\n    \"UnknownDisplayBlock\",\n    # generation\n    \"generate\",\n    \"step\",\n    \"GenerateResult\",\n    \"StepResult\",\n    \"TokenUsage\",\n]\n"
  },
  {
    "path": "sdks/kimi-sdk/src/kimi_sdk/py.typed",
    "content": ""
  },
  {
    "path": "sdks/kimi-sdk/tests/test_smoke.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport pytest\n\nfrom kimi_sdk import Kimi, Message, generate\n\n\ndef _chat_completion_response() -> dict[str, object]:\n    return {\n        \"id\": \"chatcmpl-test123\",\n        \"object\": \"chat.completion\",\n        \"created\": 1234567890,\n        \"model\": \"kimi-k2-turbo-preview\",\n        \"choices\": [\n            {\n                \"index\": 0,\n                \"message\": {\"role\": \"assistant\", \"content\": \"Hello\"},\n                \"finish_reason\": \"stop\",\n            }\n        ],\n        \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 5, \"total_tokens\": 15},\n    }\n\n\n@pytest.mark.asyncio\nasync def test_generate_smoke() -> None:\n    def handler(request: httpx.Request) -> httpx.Response:\n        assert request.url.path == \"/v1/chat/completions\"\n        return httpx.Response(200, json=_chat_completion_response())\n\n    transport = httpx.MockTransport(handler)\n    async with httpx.AsyncClient(transport=transport) as http_client:\n        kimi = Kimi(\n            model=\"kimi-k2-turbo-preview\",\n            api_key=\"test-key\",\n            stream=False,\n            http_client=http_client,\n        )\n        result = await generate(\n            chat_provider=kimi,\n            system_prompt=\"You are helpful.\",\n            tools=[],\n            history=[Message(role=\"user\", content=\"Hi\")],\n        )\n\n    assert result.message.role == \"assistant\"\n    assert result.message.extract_text() == \"Hello\"\n    assert result.usage is not None\n    assert result.usage.input_other == 10\n    assert result.usage.output == 5\n"
  },
  {
    "path": "src/kimi_cli/__init__.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any, cast\n\n\nclass _LazyLogger:\n    \"\"\"Import loguru only when logging is actually used.\"\"\"\n\n    def __init__(self) -> None:\n        self._logger: Any | None = None\n\n    def _get(self) -> Any:\n        if self._logger is None:\n            from loguru import logger as real_logger\n\n            # Disable logging by default for library usage.\n            # Application entry points (e.g., kimi_cli.cli) should call logger.enable(\"kimi_cli\")\n            # to enable logging.\n            real_logger.disable(\"kimi_cli\")\n            self._logger = real_logger\n        return self._logger\n\n    def __getattr__(self, name: str) -> Any:\n        return getattr(self._get(), name)\n\n\nlogger = cast(Any, _LazyLogger())\n\n__all__ = [\"logger\"]\n"
  },
  {
    "path": "src/kimi_cli/__main__.py",
    "content": "from __future__ import annotations\n\nimport sys\nfrom collections.abc import Sequence\nfrom pathlib import Path\n\n\ndef _prog_name() -> str:\n    return Path(sys.argv[0]).name or \"kimi\"\n\n\ndef main(argv: Sequence[str] | None = None) -> int | str | None:\n    args = list(sys.argv[1:] if argv is None else argv)\n\n    if len(args) == 1 and args[0] in {\"--version\", \"-V\"}:\n        from kimi_cli.constant import get_version\n\n        print(f\"kimi, version {get_version()}\")\n        return 0\n\n    from kimi_cli.cli import cli\n\n    try:\n        return cli(args=args, prog_name=_prog_name())\n    except SystemExit as exc:\n        return exc.code\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "src/kimi_cli/acp/AGENTS.md",
    "content": "# ACP Integration Notes (kimi-cli)\n\n## Protocol summary (ACP overview)\n- ACP is JSON-RPC 2.0 with request/response methods plus one-way notifications.\n- Typical flow: `initialize` -> optional `authenticate` -> `session/new` or `session/load`\n  -> `session/prompt`\n  with `session/update` notifications and optional `session/cancel`.\n- Clients provide `session/request_permission` and optional terminal/filesystem methods.\n- All ACP file paths must be absolute; line numbers are 1-based.\n\n## Entry points and server modes\n- **Single-session server**: `KimiCLI.run_acp()` uses `ACP` -> `ACPServerSingleSession`.\n  - Code: `src/kimi_cli/app.py`, `src/kimi_cli/ui/acp/__init__.py`.\n  - Used when running CLI with `--acp` UI mode.\n- **Multi-session server**: `acp_main()` runs `ACPServer` with `use_unstable_protocol=True`.\n  - Code: `src/kimi_cli/acp/__init__.py`, `src/kimi_cli/acp/server.py`.\n  - Exposed via the `kimi acp` command in `src/kimi_cli/cli/__init__.py`.\n\n## Capabilities advertised\n- `prompt_capabilities`: `embedded_context=False`, `image=True`, `audio=False`.\n- `mcp_capabilities`: `http=True`, `sse=False`.\n- Single-session: `load_session=False`, no session list capabilities.\n- Multi-session: `load_session=True`, `session_capabilities.list` supported.\n- `auth_methods=[]` (no authentication methods advertised).\n\n## Session lifecycle (implemented behavior)\n- `session/new`\n  - Multi-session: creates a persisted `Session`, builds `KimiCLI`, stores `ACPSession`.\n  - Single-session: wraps the existing `Soul` into a `Wire` loop and creates `ACPSession`.\n  - Both send `AvailableCommandsUpdate` for slash commands on session creation.\n  - MCP servers passed by ACP are converted via `acp_mcp_servers_to_mcp_config`.\n- `session/load`\n  - Multi-session only: loads by `Session.find`, then builds `KimiCLI` and `ACPSession`.\n  - No history replay yet (TODO).\n  - Single-session: not implemented.\n- `session/list`\n  - Multi-session only: lists sessions via `Session.list`, no pagination.\n  - Single-session: not implemented.\n- `session/prompt`\n  - Uses `ACPSession.prompt()` to stream updates and produce a `stop_reason`.\n  - Stop reasons: `end_turn`, `max_turn_requests`, `cancelled`.\n- `session/cancel`\n  - Sets the per-turn cancel event to stop the prompt.\n\n## Streaming updates and content mapping\n- Text chunks -> `AgentMessageChunk`.\n- Think chunks -> `AgentThoughtChunk`.\n- Tool calls:\n  - Start -> `ToolCallStart` with JSON args as text content.\n  - Streaming args -> `ToolCallProgress` with updated title/args.\n  - Results -> `ToolCallProgress` with `completed` or `failed`.\n  - Tool call IDs are prefixed with turn ID to avoid collisions across turns.\n- Plan updates:\n  - `TodoDisplayBlock` is converted into `AgentPlanUpdate`.\n- Available commands:\n  - `AvailableCommandsUpdate` is sent right after session creation.\n\n## Prompt/content conversion\n- Incoming prompt blocks:\n  - Supported: `TextContentBlock`, `ImageContentBlock` (converted to data URL).\n  - Unsupported types are logged and ignored.\n- Tool result display blocks:\n  - `DiffDisplayBlock` -> `FileEditToolCallContent`.\n  - `HideOutputDisplayBlock` suppresses tool output in ACP (used by terminal tool).\n\n## Tool integration and permission flow\n- ACP sessions use `ACPKaos` to route filesystem reads/writes through ACP clients.\n- If the client advertises `terminal` capability, the `Shell` tool is replaced by an\n  ACP-backed `Terminal` tool.\n  - Uses ACP `terminal/create`, waits for exit, streams `TerminalToolCallContent`,\n    then releases the terminal handle.\n- Approval requests in the core tool system are bridged to ACP\n  `session/request_permission` with allow-once/allow-always/reject options.\n\n## Current gaps / not implemented\n- `authenticate` method (not used by current Zed ACP client).\n- `session/set_mode` and `session/set_model` (no multi-mode/model switching in kimi-cli).\n- `ext_method` / `ext_notification` for custom ACP extensions are stubbed.\n- Single-session server does not implement `session/load` or `session/list`.\n\n## Filesystem (ACP client-backed)\n- When the client advertises `fs.readTextFile` / `fs.writeTextFile`, `ACPKaos` routes\n  reads and writes through ACP `fs/*` methods.\n- `ReadFile` uses `KaosPath.read_lines`, which `ACPKaos` implements via ACP reads.\n- `ReadMediaFile` uses `KaosPath.read_bytes` to load image/video payloads through ACP reads.\n- `WriteFile` uses `KaosPath.read_text/write_text/append_text` and still generates diffs\n  and approvals in the tool layer.\n\n## Zed-specific notes (as of current integration)\n- Zed does not currently call `authenticate`.\n- Zed’s external agent server session management is not yet available, so\n  `session/load` is not exercised in practice.\n"
  },
  {
    "path": "src/kimi_cli/acp/__init__.py",
    "content": "def acp_main() -> None:\n    \"\"\"Entry point for the multi-session ACP server.\"\"\"\n    import asyncio\n\n    import acp\n\n    from kimi_cli.acp.server import ACPServer\n    from kimi_cli.app import enable_logging\n    from kimi_cli.utils.logging import logger\n\n    enable_logging()\n    logger.info(\"Starting ACP server on stdio\")\n    asyncio.run(acp.run_agent(ACPServer(), use_unstable_protocol=True))\n"
  },
  {
    "path": "src/kimi_cli/acp/convert.py",
    "content": "from __future__ import annotations\n\nimport acp\n\nfrom kimi_cli.acp.types import ACPContentBlock\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire.types import (\n    ContentPart,\n    DiffDisplayBlock,\n    DisplayBlock,\n    ImageURLPart,\n    TextPart,\n    ToolReturnValue,\n)\n\n\ndef acp_blocks_to_content_parts(prompt: list[ACPContentBlock]) -> list[ContentPart]:\n    content: list[ContentPart] = []\n    for block in prompt:\n        match block:\n            case acp.schema.TextContentBlock():\n                content.append(TextPart(text=block.text))\n            case acp.schema.ImageContentBlock():\n                content.append(\n                    ImageURLPart(\n                        image_url=ImageURLPart.ImageURL(\n                            url=f\"data:{block.mime_type};base64,{block.data}\"\n                        )\n                    )\n                )\n            case acp.schema.EmbeddedResourceContentBlock():\n                resource = block.resource\n                if isinstance(resource, acp.schema.TextResourceContents):\n                    uri = resource.uri\n                    text = resource.text\n                    content.append(TextPart(text=f\"<resource uri={uri!r}>\\n{text}\\n</resource>\"))\n                else:\n                    logger.warning(\n                        \"Unsupported embedded resource type: {type}\",\n                        type=type(resource).__name__,\n                    )\n            case acp.schema.ResourceContentBlock():\n                # ResourceContentBlock is a link reference without inline content;\n                # include the URI so the model is at least aware of the reference.\n                content.append(\n                    TextPart(text=f\"<resource_link uri={block.uri!r} name={block.name!r} />\")\n                )\n            case _:\n                logger.warning(\"Unsupported prompt content block: {block}\", block=block)\n    return content\n\n\ndef display_block_to_acp_content(\n    block: DisplayBlock,\n) -> acp.schema.FileEditToolCallContent | None:\n    if isinstance(block, DiffDisplayBlock):\n        return acp.schema.FileEditToolCallContent(\n            type=\"diff\",\n            path=block.path,\n            old_text=block.old_text,\n            new_text=block.new_text,\n        )\n\n    return None\n\n\ndef tool_result_to_acp_content(\n    tool_ret: ToolReturnValue,\n) -> list[\n    acp.schema.ContentToolCallContent\n    | acp.schema.FileEditToolCallContent\n    | acp.schema.TerminalToolCallContent\n]:\n    from kimi_cli.acp.tools import HideOutputDisplayBlock\n\n    def _to_acp_content(\n        part: ContentPart,\n    ) -> (\n        acp.schema.ContentToolCallContent\n        | acp.schema.FileEditToolCallContent\n        | acp.schema.TerminalToolCallContent\n    ):\n        if isinstance(part, TextPart):\n            return acp.schema.ContentToolCallContent(\n                type=\"content\", content=acp.schema.TextContentBlock(type=\"text\", text=part.text)\n            )\n        logger.warning(\"Unsupported content part in tool result: {part}\", part=part)\n        return acp.schema.ContentToolCallContent(\n            type=\"content\",\n            content=acp.schema.TextContentBlock(type=\"text\", text=f\"[{part.__class__.__name__}]\"),\n        )\n\n    def _to_text_block(text: str) -> acp.schema.ContentToolCallContent:\n        return acp.schema.ContentToolCallContent(\n            type=\"content\", content=acp.schema.TextContentBlock(type=\"text\", text=text)\n        )\n\n    contents: list[\n        acp.schema.ContentToolCallContent\n        | acp.schema.FileEditToolCallContent\n        | acp.schema.TerminalToolCallContent\n    ] = []\n\n    for block in tool_ret.display:\n        if isinstance(block, HideOutputDisplayBlock):\n            # return early to indicate no output should be shown\n            return []\n\n        content = display_block_to_acp_content(block)\n        if content is not None:\n            contents.append(content)\n    # TODO: better concatenation of `display` blocks and `output`?\n\n    output = tool_ret.output\n    if isinstance(output, str):\n        if output:\n            contents.append(_to_text_block(output))\n    else:\n        # NOTE: At the moment, ToolReturnValue.output is either a string or a\n        # list of ContentPart. We avoid an unnecessary isinstance() check here\n        # to keep pyright happy while still handling list outputs.\n        contents.extend(_to_acp_content(part) for part in output)\n\n    if not contents and tool_ret.message:\n        # Fallback to the `message` for LLM if there's no other content\n        contents.append(_to_text_block(tool_ret.message))\n\n    return contents\n"
  },
  {
    "path": "src/kimi_cli/acp/kaos.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom collections.abc import AsyncGenerator, Iterable, Mapping\nfrom contextlib import suppress\nfrom typing import Literal\n\nimport acp\nfrom kaos import AsyncReadable, AsyncWritable, Kaos, KaosProcess, StatResult, StrOrKaosPath\nfrom kaos.local import local_kaos\nfrom kaos.path import KaosPath\n\n_DEFAULT_TERMINAL_OUTPUT_LIMIT = 50_000\n_DEFAULT_POLL_INTERVAL = 0.2\n_TRUNCATION_NOTICE = \"[acp output truncated]\\n\"\n\n\nclass _NullWritable:\n    def can_write_eof(self) -> bool:\n        return False\n\n    def close(self) -> None:\n        return None\n\n    async def drain(self) -> None:\n        return None\n\n    def is_closing(self) -> bool:\n        return False\n\n    async def wait_closed(self) -> None:\n        return None\n\n    def write(self, data: bytes) -> None:\n        return None\n\n    def writelines(self, data: Iterable[bytes], /) -> None:\n        return None\n\n    def write_eof(self) -> None:\n        return None\n\n\nclass ACPProcess:\n    \"\"\"KAOS process adapter for ACP terminal execution.\"\"\"\n\n    def __init__(\n        self,\n        terminal: acp.TerminalHandle,\n        *,\n        poll_interval: float = _DEFAULT_POLL_INTERVAL,\n    ) -> None:\n        self._terminal = terminal\n        self._poll_interval = poll_interval\n        self._stdin = _NullWritable()\n        self._stdout = asyncio.StreamReader()\n        self._stderr = asyncio.StreamReader()\n        self.stdin: AsyncWritable = self._stdin\n        self.stdout: AsyncReadable = self._stdout\n        # ACP does not expose stderr separately; keep stderr empty.\n        self.stderr: AsyncReadable = self._stderr\n        self._returncode: int | None = None\n        self._last_output = \"\"\n        self._truncation_noted = False\n        self._exit_future: asyncio.Future[int] = asyncio.get_running_loop().create_future()\n        self._poll_task = asyncio.create_task(self._poll_output())\n\n    @property\n    def pid(self) -> int:\n        return -1\n\n    @property\n    def returncode(self) -> int | None:\n        return self._returncode\n\n    async def wait(self) -> int:\n        return await self._exit_future\n\n    async def kill(self) -> None:\n        await self._terminal.kill()\n\n    def _feed_output(self, output_response: acp.schema.TerminalOutputResponse) -> None:\n        output = output_response.output\n        reset = output_response.truncated or (\n            self._last_output and not output.startswith(self._last_output)\n        )\n        if reset and self._last_output and not self._truncation_noted:\n            self._stdout.feed_data(_TRUNCATION_NOTICE.encode(\"utf-8\"))\n            self._truncation_noted = True\n\n        delta = output if reset else output[len(self._last_output) :]\n        if delta:\n            self._stdout.feed_data(delta.encode(\"utf-8\", \"replace\"))\n        self._last_output = output\n\n    @staticmethod\n    def _normalize_exit_code(exit_code: int | None) -> int:\n        return 1 if exit_code is None else exit_code\n\n    async def _poll_output(self) -> None:\n        exit_task = asyncio.create_task(self._terminal.wait_for_exit())\n        exit_code: int | None = None\n        try:\n            while True:\n                if exit_task.done():\n                    exit_response = exit_task.result()\n                    exit_code = exit_response.exit_code\n                    break\n\n                output_response = await self._terminal.current_output()\n                self._feed_output(output_response)\n                if output_response.exit_status:\n                    exit_code = output_response.exit_status.exit_code\n                    try:\n                        exit_response = await exit_task\n                        exit_code = exit_response.exit_code or exit_code\n                    except Exception:\n                        pass\n                    break\n\n                await asyncio.sleep(self._poll_interval)\n\n            final_output = await self._terminal.current_output()\n            self._feed_output(final_output)\n        except Exception as exc:\n            error_note = f\"[acp terminal error] {exc}\\n\"\n            self._stdout.feed_data(error_note.encode(\"utf-8\", \"replace\"))\n            if exit_code is None:\n                exit_code = 1\n        finally:\n            if not exit_task.done():\n                exit_task.cancel()\n                with suppress(Exception):\n                    await exit_task\n            self._returncode = self._normalize_exit_code(exit_code)\n            self._stdout.feed_eof()\n            self._stderr.feed_eof()\n            if not self._exit_future.done():\n                self._exit_future.set_result(self._returncode)\n            with suppress(Exception):\n                await self._terminal.release()\n\n\nclass ACPKaos:\n    \"\"\"KAOS backend that routes supported operations through ACP.\"\"\"\n\n    name: str = \"acp\"\n\n    def __init__(\n        self,\n        client: acp.Client,\n        session_id: str,\n        client_capabilities: acp.schema.ClientCapabilities | None,\n        fallback: Kaos | None = None,\n        *,\n        output_byte_limit: int | None = _DEFAULT_TERMINAL_OUTPUT_LIMIT,\n        poll_interval: float = _DEFAULT_POLL_INTERVAL,\n    ) -> None:\n        self._client = client\n        self._session_id = session_id\n        self._fallback = fallback or local_kaos\n        fs = client_capabilities.fs if client_capabilities else None\n        self._supports_read = bool(fs and fs.read_text_file)\n        self._supports_write = bool(fs and fs.write_text_file)\n        self._supports_terminal = bool(client_capabilities and client_capabilities.terminal)\n        self._output_byte_limit = output_byte_limit\n        self._poll_interval = poll_interval\n\n    def pathclass(self):\n        return self._fallback.pathclass()\n\n    def normpath(self, path: StrOrKaosPath) -> KaosPath:\n        return self._fallback.normpath(path)\n\n    def gethome(self) -> KaosPath:\n        return self._fallback.gethome()\n\n    def getcwd(self) -> KaosPath:\n        return self._fallback.getcwd()\n\n    async def chdir(self, path: StrOrKaosPath) -> None:\n        await self._fallback.chdir(path)\n\n    async def stat(self, path: StrOrKaosPath, *, follow_symlinks: bool = True) -> StatResult:\n        return await self._fallback.stat(path, follow_symlinks=follow_symlinks)\n\n    def iterdir(self, path: StrOrKaosPath) -> AsyncGenerator[KaosPath]:\n        return self._fallback.iterdir(path)\n\n    def glob(\n        self, path: StrOrKaosPath, pattern: str, *, case_sensitive: bool = True\n    ) -> AsyncGenerator[KaosPath]:\n        return self._fallback.glob(path, pattern, case_sensitive=case_sensitive)\n\n    async def readbytes(self, path: StrOrKaosPath, n: int | None = None) -> bytes:\n        return await self._fallback.readbytes(path, n=n)\n\n    async def readtext(\n        self,\n        path: StrOrKaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> str:\n        abs_path = self._abs_path(path)\n        if not self._supports_read:\n            return await self._fallback.readtext(abs_path, encoding=encoding, errors=errors)\n        response = await self._client.read_text_file(path=abs_path, session_id=self._session_id)\n        return response.content\n\n    async def readlines(\n        self,\n        path: StrOrKaosPath,\n        *,\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> AsyncGenerator[str]:\n        text = await self.readtext(path, encoding=encoding, errors=errors)\n        for line in text.splitlines(keepends=True):\n            yield line\n\n    async def writebytes(self, path: StrOrKaosPath, data: bytes) -> int:\n        return await self._fallback.writebytes(path, data)\n\n    async def writetext(\n        self,\n        path: StrOrKaosPath,\n        data: str,\n        *,\n        mode: Literal[\"w\", \"a\"] = \"w\",\n        encoding: str = \"utf-8\",\n        errors: Literal[\"strict\", \"ignore\", \"replace\"] = \"strict\",\n    ) -> int:\n        abs_path = self._abs_path(path)\n        if mode == \"a\":\n            if self._supports_read and self._supports_write:\n                existing = await self.readtext(abs_path, encoding=encoding, errors=errors)\n                await self._client.write_text_file(\n                    path=abs_path,\n                    content=existing + data,\n                    session_id=self._session_id,\n                )\n                return len(data)\n            return await self._fallback.writetext(\n                abs_path, data, mode=\"a\", encoding=encoding, errors=errors\n            )\n\n        if not self._supports_write:\n            return await self._fallback.writetext(\n                abs_path, data, mode=mode, encoding=encoding, errors=errors\n            )\n\n        await self._client.write_text_file(\n            path=abs_path,\n            content=data,\n            session_id=self._session_id,\n        )\n        return len(data)\n\n    async def mkdir(\n        self, path: StrOrKaosPath, parents: bool = False, exist_ok: bool = False\n    ) -> None:\n        await self._fallback.mkdir(path, parents=parents, exist_ok=exist_ok)\n\n    async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> KaosProcess:\n        return await self._fallback.exec(*args, env=env)\n\n    def _abs_path(self, path: StrOrKaosPath) -> str:\n        kaos_path = path if isinstance(path, KaosPath) else KaosPath(path)\n        return str(kaos_path.canonical())\n"
  },
  {
    "path": "src/kimi_cli/acp/mcp.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any\n\nimport acp.schema\nfrom fastmcp.mcp_config import MCPConfig\nfrom pydantic import ValidationError\n\nfrom kimi_cli.acp.types import MCPServer\nfrom kimi_cli.exception import MCPConfigError\n\n\ndef acp_mcp_servers_to_mcp_config(mcp_servers: list[MCPServer]) -> MCPConfig:\n    if not mcp_servers:\n        return MCPConfig()\n\n    try:\n        return MCPConfig.model_validate(\n            {\"mcpServers\": {server.name: _convert_acp_mcp_server(server) for server in mcp_servers}}\n        )\n    except ValidationError as exc:\n        raise MCPConfigError(f\"Invalid MCP config from ACP client: {exc}\") from exc\n\n\ndef _convert_acp_mcp_server(server: MCPServer) -> dict[str, Any]:\n    \"\"\"Convert an ACP MCP server to a dictionary representation.\"\"\"\n    match server:\n        case acp.schema.HttpMcpServer():\n            return {\n                \"url\": server.url,\n                \"transport\": \"http\",\n                \"headers\": {header.name: header.value for header in server.headers},\n            }\n        case acp.schema.SseMcpServer():\n            return {\n                \"url\": server.url,\n                \"transport\": \"sse\",\n                \"headers\": {header.name: header.value for header in server.headers},\n            }\n        case acp.schema.McpServerStdio():\n            return {\n                \"command\": server.command,\n                \"args\": server.args,\n                \"env\": {item.name: item.value for item in server.env},\n                \"transport\": \"stdio\",\n            }\n"
  },
  {
    "path": "src/kimi_cli/acp/server.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport sys\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any, NamedTuple\n\nimport acp\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.acp.kaos import ACPKaos\nfrom kimi_cli.acp.mcp import acp_mcp_servers_to_mcp_config\nfrom kimi_cli.acp.session import ACPSession\nfrom kimi_cli.acp.tools import replace_tools\nfrom kimi_cli.acp.types import ACPContentBlock, MCPServer\nfrom kimi_cli.acp.version import ACPVersionSpec, negotiate_version\nfrom kimi_cli.app import KimiCLI\nfrom kimi_cli.auth.oauth import KIMI_CODE_OAUTH_KEY, load_tokens\nfrom kimi_cli.config import LLMModel, OAuthRef, load_config, save_config\nfrom kimi_cli.constant import NAME, VERSION\nfrom kimi_cli.llm import create_llm, derive_model_capabilities\nfrom kimi_cli.session import Session\nfrom kimi_cli.soul.slash import registry as soul_slash_registry\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.utils.logging import logger\n\n\nclass ACPServer:\n    def __init__(self) -> None:\n        self.client_capabilities: acp.schema.ClientCapabilities | None = None\n        self.conn: acp.Client | None = None\n        self.sessions: dict[str, tuple[ACPSession, _ModelIDConv]] = {}\n        self.negotiated_version: ACPVersionSpec | None = None\n        self._auth_methods: list[acp.schema.AuthMethod] = []\n\n    def on_connect(self, conn: acp.Client) -> None:\n        logger.info(\"ACP client connected\")\n        self.conn = conn\n\n    async def initialize(\n        self,\n        protocol_version: int,\n        client_capabilities: acp.schema.ClientCapabilities | None = None,\n        client_info: acp.schema.Implementation | None = None,\n        **kwargs: Any,\n    ) -> acp.InitializeResponse:\n        self.negotiated_version = negotiate_version(protocol_version)\n        logger.info(\n            \"ACP server initialized with client protocol version: {version}, \"\n            \"negotiated version: {negotiated}, \"\n            \"client capabilities: {capabilities}, client info: {info}\",\n            version=protocol_version,\n            negotiated=self.negotiated_version,\n            capabilities=client_capabilities,\n            info=client_info,\n        )\n        self.client_capabilities = client_capabilities\n\n        # get command and args of current process for terminal-auth\n        command = sys.argv[0]\n        if command.endswith(\"kimi\"):\n            args = []\n        else:\n            idx = sys.argv.index(\"kimi\")\n            args = sys.argv[1 : idx + 1]\n\n        # Build terminal auth data for error response\n        terminal_args = args + [\"login\"]\n\n        # Build and cache auth methods for reuse in AUTH_REQUIRED errors\n        self._auth_methods = [\n            acp.schema.AuthMethod(\n                id=\"login\",\n                name=\"Login with Kimi account\",\n                description=(\n                    \"Run `kimi login` command in the terminal, \"\n                    \"then follow the instructions to finish login.\"\n                ),\n                # Store auth data in field_meta for building AUTH_REQUIRED error\n                field_meta={\n                    \"terminal-auth\": {\n                        \"command\": command,\n                        \"args\": terminal_args,\n                        \"label\": \"Kimi Code Login\",\n                        \"env\": {},\n                        \"type\": \"terminal\",\n                    }\n                },\n            ),\n        ]\n\n        return acp.InitializeResponse(\n            protocol_version=self.negotiated_version.protocol_version,\n            agent_capabilities=acp.schema.AgentCapabilities(\n                load_session=True,\n                prompt_capabilities=acp.schema.PromptCapabilities(\n                    embedded_context=True, image=True, audio=False\n                ),\n                mcp_capabilities=acp.schema.McpCapabilities(http=True, sse=False),\n                session_capabilities=acp.schema.SessionCapabilities(\n                    list=acp.schema.SessionListCapabilities(),\n                    resume=acp.schema.SessionResumeCapabilities(),\n                ),\n            ),\n            auth_methods=self._auth_methods,\n            agent_info=acp.schema.Implementation(name=NAME, version=VERSION),\n        )\n\n    def _check_auth(self) -> None:\n        \"\"\"Check if Kimi Code authentication is complete. Raise AUTH_REQUIRED if not.\"\"\"\n        ref = OAuthRef(storage=\"file\", key=KIMI_CODE_OAUTH_KEY)\n        token = load_tokens(ref)\n\n        if token is None or not token.access_token:\n            # Build AUTH_REQUIRED error data for clients\n            auth_methods_data: list[dict[str, Any]] = []\n            for m in self._auth_methods:\n                if m.field_meta and \"terminal-auth\" in m.field_meta:\n                    terminal_auth = m.field_meta[\"terminal-auth\"]\n                    auth_methods_data.append(\n                        {\n                            \"id\": m.id,\n                            \"name\": m.name,\n                            \"description\": m.description,\n                            \"type\": terminal_auth.get(\"type\", \"terminal\"),\n                            \"args\": terminal_auth.get(\"args\", []),\n                            \"env\": terminal_auth.get(\"env\", {}),\n                        }\n                    )\n\n            logger.warning(\"Authentication required, no valid token found\")\n            raise acp.RequestError.auth_required({\"authMethods\": auth_methods_data})\n\n    async def new_session(\n        self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> acp.NewSessionResponse:\n        logger.info(\"Creating new session for working directory: {cwd}\", cwd=cwd)\n        assert self.conn is not None, \"ACP client not connected\"\n        assert self.client_capabilities is not None, \"ACP connection not initialized\"\n\n        # Check authentication before creating session\n        self._check_auth()\n\n        session = await Session.create(KaosPath.unsafe_from_local_path(Path(cwd)))\n\n        mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers or [])\n        cli_instance = await KimiCLI.create(\n            session,\n            mcp_configs=[mcp_config],\n        )\n        config = cli_instance.soul.runtime.config\n        acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities)\n        acp_session = ACPSession(session.id, cli_instance, self.conn, kaos=acp_kaos)\n        model_id_conv = _ModelIDConv(config.default_model, config.default_thinking)\n        self.sessions[session.id] = (acp_session, model_id_conv)\n\n        if isinstance(cli_instance.soul.agent.toolset, KimiToolset):\n            replace_tools(\n                self.client_capabilities,\n                self.conn,\n                session.id,\n                cli_instance.soul.agent.toolset,\n                cli_instance.soul.runtime,\n            )\n\n        available_commands = [\n            acp.schema.AvailableCommand(name=cmd.name, description=cmd.description)\n            for cmd in soul_slash_registry.list_commands()\n        ]\n        asyncio.create_task(\n            self.conn.session_update(\n                session_id=session.id,\n                update=acp.schema.AvailableCommandsUpdate(\n                    session_update=\"available_commands_update\",\n                    available_commands=available_commands,\n                ),\n            )\n        )\n        return acp.NewSessionResponse(\n            session_id=session.id,\n            modes=acp.schema.SessionModeState(\n                available_modes=[\n                    acp.schema.SessionMode(\n                        id=\"default\",\n                        name=\"Default\",\n                        description=\"The default mode.\",\n                    ),\n                ],\n                current_mode_id=\"default\",\n            ),\n            models=acp.schema.SessionModelState(\n                available_models=_expand_llm_models(config.models),\n                current_model_id=model_id_conv.to_acp_model_id(),\n            ),\n        )\n\n    async def _setup_session(\n        self,\n        cwd: str,\n        session_id: str,\n        mcp_servers: list[MCPServer] | None = None,\n    ) -> tuple[ACPSession, _ModelIDConv]:\n        \"\"\"Load or resume a session. Shared by load_session and resume_session.\"\"\"\n        assert self.conn is not None, \"ACP client not connected\"\n        assert self.client_capabilities is not None, \"ACP connection not initialized\"\n\n        work_dir = KaosPath.unsafe_from_local_path(Path(cwd))\n        session = await Session.find(work_dir, session_id)\n        if session is None:\n            logger.error(\n                \"Session not found: {id} for working directory: {cwd}\", id=session_id, cwd=cwd\n            )\n            raise acp.RequestError.invalid_params({\"session_id\": \"Session not found\"})\n\n        mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers or [])\n        cli_instance = await KimiCLI.create(\n            session,\n            mcp_configs=[mcp_config],\n        )\n        config = cli_instance.soul.runtime.config\n        acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities)\n        acp_session = ACPSession(session.id, cli_instance, self.conn, kaos=acp_kaos)\n        model_id_conv = _ModelIDConv(config.default_model, config.default_thinking)\n        self.sessions[session.id] = (acp_session, model_id_conv)\n\n        if isinstance(cli_instance.soul.agent.toolset, KimiToolset):\n            replace_tools(\n                self.client_capabilities,\n                self.conn,\n                session.id,\n                cli_instance.soul.agent.toolset,\n                cli_instance.soul.runtime,\n            )\n\n        return acp_session, model_id_conv\n\n    async def load_session(\n        self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> None:\n        logger.info(\"Loading session: {id} for working directory: {cwd}\", id=session_id, cwd=cwd)\n\n        if session_id in self.sessions:\n            logger.warning(\"Session already loaded: {id}\", id=session_id)\n            return\n\n        # Check authentication before loading session\n        self._check_auth()\n\n        await self._setup_session(cwd, session_id, mcp_servers)\n        # TODO: replay session history?\n\n    async def resume_session(\n        self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> acp.schema.ResumeSessionResponse:\n        logger.info(\"Resuming session: {id} for working directory: {cwd}\", id=session_id, cwd=cwd)\n\n        if session_id not in self.sessions:\n            await self._setup_session(cwd, session_id, mcp_servers)\n\n        acp_session, model_id_conv = self.sessions[session_id]\n        config = acp_session.cli.soul.runtime.config\n        return acp.schema.ResumeSessionResponse(\n            modes=acp.schema.SessionModeState(\n                available_modes=[\n                    acp.schema.SessionMode(\n                        id=\"default\",\n                        name=\"Default\",\n                        description=\"The default mode.\",\n                    ),\n                ],\n                current_mode_id=\"default\",\n            ),\n            models=acp.schema.SessionModelState(\n                available_models=_expand_llm_models(config.models),\n                current_model_id=model_id_conv.to_acp_model_id(),\n            ),\n        )\n\n    async def fork_session(\n        self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> acp.schema.ForkSessionResponse:\n        raise NotImplementedError\n\n    async def list_sessions(\n        self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any\n    ) -> acp.schema.ListSessionsResponse:\n        logger.info(\"Listing sessions for working directory: {cwd}\", cwd=cwd)\n        if cwd is None:\n            return acp.schema.ListSessionsResponse(sessions=[], next_cursor=None)\n        work_dir = KaosPath.unsafe_from_local_path(Path(cwd))\n        sessions = await Session.list(work_dir)\n        return acp.schema.ListSessionsResponse(\n            sessions=[\n                acp.schema.SessionInfo(\n                    cwd=cwd,\n                    session_id=s.id,\n                    title=s.title,\n                    updated_at=datetime.fromtimestamp(s.updated_at).astimezone().isoformat(),\n                )\n                for s in sessions\n            ],\n            next_cursor=None,\n        )\n\n    async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> None:\n        assert mode_id == \"default\", \"Only default mode is supported\"\n\n    async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> None:\n        logger.info(\n            \"Setting session model to {model_id} for session: {id}\",\n            model_id=model_id,\n            id=session_id,\n        )\n        if session_id not in self.sessions:\n            logger.error(\"Session not found: {id}\", id=session_id)\n            raise acp.RequestError.invalid_params({\"session_id\": \"Session not found\"})\n\n        acp_session, current_model_id = self.sessions[session_id]\n        cli_instance = acp_session.cli\n        model_id_conv = _ModelIDConv.from_acp_model_id(model_id)\n        if model_id_conv == current_model_id:\n            return\n\n        config = cli_instance.soul.runtime.config\n        new_model = config.models.get(model_id_conv.model_key)\n        if new_model is None:\n            logger.error(\"Model not found: {model_key}\", model_key=model_id_conv.model_key)\n            raise acp.RequestError.invalid_params({\"model_id\": \"Model not found\"})\n        new_provider = config.providers.get(new_model.provider)\n        if new_provider is None:\n            logger.error(\n                \"Provider not found: {provider} for model: {model_key}\",\n                provider=new_model.provider,\n                model_key=model_id_conv.model_key,\n            )\n            raise acp.RequestError.invalid_params({\"model_id\": \"Model's provider not found\"})\n\n        new_llm = create_llm(\n            new_provider,\n            new_model,\n            session_id=acp_session.id,\n            thinking=model_id_conv.thinking,\n            oauth=cli_instance.soul.runtime.oauth,\n        )\n        cli_instance.soul.runtime.llm = new_llm\n\n        config.default_model = model_id_conv.model_key\n        config.default_thinking = model_id_conv.thinking\n        assert config.is_from_default_location, \"`kimi acp` must use the default config location\"\n        config_for_save = load_config()\n        config_for_save.default_model = model_id_conv.model_key\n        config_for_save.default_thinking = model_id_conv.thinking\n        save_config(config_for_save)\n\n    async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateResponse | None:\n        \"\"\"\n        For Terminal Auth, this method is typically not called directly\n        (user completes auth in terminal). Implement for completeness.\n        \"\"\"\n        if method_id == \"login\":\n            ref = OAuthRef(storage=\"file\", key=KIMI_CODE_OAUTH_KEY)\n            token = load_tokens(ref)\n\n            if token and token.access_token:\n                logger.info(\"Authentication successful for method: {id}\", id=method_id)\n                return acp.AuthenticateResponse()\n            else:\n                logger.warning(\"Authentication not complete for method: {id}\", id=method_id)\n                raise acp.RequestError.auth_required(\n                    {\n                        \"message\": \"Please complete login in terminal first\",\n                        \"authMethods\": self._auth_methods,\n                    }\n                )\n\n        logger.error(\"Unknown auth method: {method_id}\", method_id=method_id)\n        raise acp.RequestError.invalid_params({\"method_id\": \"Unknown auth method\"})\n\n    async def prompt(\n        self, prompt: list[ACPContentBlock], session_id: str, **kwargs: Any\n    ) -> acp.PromptResponse:\n        logger.info(\"Received prompt request for session: {id}\", id=session_id)\n        if session_id not in self.sessions:\n            logger.error(\"Session not found: {id}\", id=session_id)\n            raise acp.RequestError.invalid_params({\"session_id\": \"Session not found\"})\n        acp_session, *_ = self.sessions[session_id]\n        return await acp_session.prompt(prompt)\n\n    async def cancel(self, session_id: str, **kwargs: Any) -> None:\n        logger.info(\"Received cancel request for session: {id}\", id=session_id)\n        if session_id not in self.sessions:\n            logger.error(\"Session not found: {id}\", id=session_id)\n            raise acp.RequestError.invalid_params({\"session_id\": \"Session not found\"})\n        acp_session, *_ = self.sessions[session_id]\n        await acp_session.cancel()\n\n    async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:\n        raise NotImplementedError\n\n    async def ext_notification(self, method: str, params: dict[str, Any]) -> None:\n        raise NotImplementedError\n\n\nclass _ModelIDConv(NamedTuple):\n    model_key: str\n    thinking: bool\n\n    @classmethod\n    def from_acp_model_id(cls, model_id: str) -> _ModelIDConv:\n        if model_id.endswith(\",thinking\"):\n            return _ModelIDConv(model_id[: -len(\",thinking\")], True)\n        return _ModelIDConv(model_id, False)\n\n    def to_acp_model_id(self) -> str:\n        if self.thinking:\n            return f\"{self.model_key},thinking\"\n        return self.model_key\n\n\ndef _expand_llm_models(models: dict[str, LLMModel]) -> list[acp.schema.ModelInfo]:\n    expanded_models: list[acp.schema.ModelInfo] = []\n    for model_key, model in models.items():\n        capabilities = derive_model_capabilities(model)\n        if \"thinking\" in model.model or \"reason\" in model.model:\n            # always-thinking models\n            expanded_models.append(\n                acp.schema.ModelInfo(\n                    model_id=_ModelIDConv(model_key, True).to_acp_model_id(),\n                    name=f\"{model.model}\",\n                )\n            )\n        else:\n            expanded_models.append(\n                acp.schema.ModelInfo(\n                    model_id=model_key,\n                    name=model.model,\n                )\n            )\n            if \"thinking\" in capabilities:\n                # add thinking variant\n                expanded_models.append(\n                    acp.schema.ModelInfo(\n                        model_id=_ModelIDConv(model_key, True).to_acp_model_id(),\n                        name=f\"{model.model} (thinking)\",\n                    )\n                )\n    return expanded_models\n"
  },
  {
    "path": "src/kimi_cli/acp/session.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport uuid\nfrom contextvars import ContextVar\n\nimport acp\nimport streamingjson  # type: ignore[reportMissingTypeStubs]\nfrom kaos import Kaos, reset_current_kaos, set_current_kaos\nfrom kosong.chat_provider import ChatProviderError\n\nfrom kimi_cli.acp.convert import (\n    acp_blocks_to_content_parts,\n    display_block_to_acp_content,\n    tool_result_to_acp_content,\n)\nfrom kimi_cli.acp.types import ACPContentBlock\nfrom kimi_cli.app import KimiCLI\nfrom kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled\nfrom kimi_cli.tools import extract_key_argument\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire.types import (\n    ApprovalRequest,\n    ApprovalResponse,\n    CompactionBegin,\n    CompactionEnd,\n    ContentPart,\n    MCPLoadingBegin,\n    MCPLoadingEnd,\n    Notification,\n    QuestionRequest,\n    StatusUpdate,\n    SteerInput,\n    StepBegin,\n    StepInterrupted,\n    SubagentEvent,\n    TextPart,\n    ThinkPart,\n    TodoDisplayBlock,\n    ToolCall,\n    ToolCallPart,\n    ToolCallRequest,\n    ToolResult,\n    TurnBegin,\n    TurnEnd,\n)\n\n_current_turn_id = ContextVar[str | None](\"current_turn_id\", default=None)\n_terminal_tool_call_ids = ContextVar[set[str] | None](\"terminal_tool_call_ids\", default=None)\n\n\ndef get_current_acp_tool_call_id_or_none() -> str | None:\n    \"\"\"See `_ToolCallState.acp_tool_call_id`.\"\"\"\n    from kimi_cli.soul.toolset import get_current_tool_call_or_none\n\n    turn_id = _current_turn_id.get()\n    if turn_id is None:\n        return None\n    tool_call = get_current_tool_call_or_none()\n    if tool_call is None:\n        return None\n    return f\"{turn_id}/{tool_call.id}\"\n\n\ndef register_terminal_tool_call_id(tool_call_id: str) -> None:\n    calls = _terminal_tool_call_ids.get()\n    if calls is not None:\n        calls.add(tool_call_id)\n\n\ndef should_hide_terminal_output(tool_call_id: str) -> bool:\n    calls = _terminal_tool_call_ids.get()\n    return calls is not None and tool_call_id in calls\n\n\nclass _ToolCallState:\n    \"\"\"Manages the state of a single tool call for streaming updates.\"\"\"\n\n    def __init__(self, tool_call: ToolCall):\n        self.tool_call = tool_call\n        self.args = tool_call.function.arguments or \"\"\n        self.lexer = streamingjson.Lexer()\n        if tool_call.function.arguments is not None:\n            self.lexer.append_string(tool_call.function.arguments)\n\n    @property\n    def acp_tool_call_id(self) -> str:\n        # When the user rejected or cancelled a tool call, the step result may not\n        # be appended to the context. In this case, future step may emit tool call\n        # with the same tool call ID (on the LLM side). To avoid confusion of the\n        # ACP client, we ensure the uniqueness by prefixing with the turn ID.\n        turn_id = _current_turn_id.get()\n        assert turn_id is not None\n        return f\"{turn_id}/{self.tool_call.id}\"\n\n    def append_args_part(self, args_part: str) -> None:\n        \"\"\"Append a new arguments part to the accumulated args and lexer.\"\"\"\n        self.args += args_part\n        self.lexer.append_string(args_part)\n\n    def get_title(self) -> str:\n        \"\"\"Get the current title with subtitle if available.\"\"\"\n        tool_name = self.tool_call.function.name\n        subtitle = extract_key_argument(self.lexer, tool_name)\n        if subtitle:\n            return f\"{tool_name}: {subtitle}\"\n        return tool_name\n\n\nclass _TurnState:\n    def __init__(self):\n        self.id = str(uuid.uuid4())\n        \"\"\"Unique ID for the turn.\"\"\"\n        self.tool_calls: dict[str, _ToolCallState] = {}\n        \"\"\"Map of tool call ID (LLM-side ID) to tool call state.\"\"\"\n        self.last_tool_call: _ToolCallState | None = None\n        self.cancel_event = asyncio.Event()\n\n\nclass ACPSession:\n    def __init__(\n        self,\n        id: str,\n        cli: KimiCLI,\n        acp_conn: acp.Client,\n        kaos: Kaos | None = None,\n    ) -> None:\n        self._id = id\n        self._cli = cli\n        self._conn = acp_conn\n        self._kaos = kaos\n        self._turn_state: _TurnState | None = None\n\n    @property\n    def id(self) -> str:\n        \"\"\"The ID of the ACP session.\"\"\"\n        return self._id\n\n    @property\n    def cli(self) -> KimiCLI:\n        \"\"\"The Kimi Code CLI instance bound to this ACP session.\"\"\"\n        return self._cli\n\n    async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse:\n        user_input = acp_blocks_to_content_parts(prompt)\n        self._turn_state = _TurnState()\n        token = _current_turn_id.set(self._turn_state.id)\n        kaos_token = set_current_kaos(self._kaos) if self._kaos is not None else None\n        terminal_tool_calls_token = _terminal_tool_call_ids.set(set())\n        try:\n            async for msg in self._cli.run(user_input, self._turn_state.cancel_event):\n                match msg:\n                    case TurnBegin():\n                        pass\n                    case SteerInput():\n                        pass\n                    case TurnEnd():\n                        pass\n                    case StepBegin():\n                        pass\n                    case StepInterrupted():\n                        break\n                    case CompactionBegin():\n                        pass\n                    case CompactionEnd():\n                        pass\n                    case MCPLoadingBegin():\n                        pass\n                    case MCPLoadingEnd():\n                        pass\n                    case StatusUpdate():\n                        pass\n                    case Notification():\n                        await self._send_notification(msg)\n                    case ThinkPart(think=think):\n                        await self._send_thinking(think)\n                    case TextPart(text=text):\n                        await self._send_text(text)\n                    case ContentPart():\n                        logger.warning(\"Unsupported content part: {part}\", part=msg)\n                        await self._send_text(f\"[{msg.__class__.__name__}]\")\n                    case ToolCall():\n                        await self._send_tool_call(msg)\n                    case ToolCallPart():\n                        await self._send_tool_call_part(msg)\n                    case ToolResult():\n                        await self._send_tool_result(msg)\n                    case ApprovalResponse():\n                        pass\n                    case SubagentEvent():\n                        pass\n                    case ApprovalRequest():\n                        await self._handle_approval_request(msg)\n                    case ToolCallRequest():\n                        logger.warning(\"Unexpected ToolCallRequest in ACP session: {msg}\", msg=msg)\n                    case QuestionRequest():\n                        logger.warning(\n                            \"QuestionRequest is unsupported in ACP session; resolving empty answer.\"\n                        )\n                        msg.resolve({})\n        except LLMNotSet as e:\n            logger.exception(\"LLM not set:\")\n            raise acp.RequestError.auth_required() from e\n        except LLMNotSupported as e:\n            logger.exception(\"LLM not supported:\")\n            raise acp.RequestError.internal_error({\"error\": str(e)}) from e\n        except ChatProviderError as e:\n            logger.exception(\"LLM provider error:\")\n            raise acp.RequestError.internal_error({\"error\": str(e)}) from e\n        except MaxStepsReached as e:\n            logger.warning(\"Max steps reached: {n_steps}\", n_steps=e.n_steps)\n            return acp.PromptResponse(stop_reason=\"max_turn_requests\")\n        except RunCancelled:\n            logger.info(\"Prompt cancelled by user\")\n            return acp.PromptResponse(stop_reason=\"cancelled\")\n        except Exception as e:\n            logger.exception(\"Unexpected error during prompt:\")\n            raise acp.RequestError.internal_error({\"error\": str(e)}) from e\n        finally:\n            self._turn_state = None\n            if kaos_token is not None:\n                reset_current_kaos(kaos_token)\n            _terminal_tool_call_ids.reset(terminal_tool_calls_token)\n            _current_turn_id.reset(token)\n        return acp.PromptResponse(stop_reason=\"end_turn\")\n\n    async def cancel(self) -> None:\n        if self._turn_state is None:\n            logger.warning(\"Cancel requested but no prompt is running\")\n            return\n\n        self._turn_state.cancel_event.set()\n\n    async def _send_thinking(self, think: str):\n        \"\"\"Send thinking content to client.\"\"\"\n        if not self._id or not self._conn:\n            return\n\n        await self._conn.session_update(\n            self._id,\n            acp.schema.AgentThoughtChunk(\n                content=acp.schema.TextContentBlock(type=\"text\", text=think),\n                session_update=\"agent_thought_chunk\",\n            ),\n        )\n\n    async def _send_text(self, text: str):\n        \"\"\"Send text chunk to client.\"\"\"\n        if not self._id or not self._conn:\n            return\n\n        await self._conn.session_update(\n            session_id=self._id,\n            update=acp.schema.AgentMessageChunk(\n                content=acp.schema.TextContentBlock(type=\"text\", text=text),\n                session_update=\"agent_message_chunk\",\n            ),\n        )\n\n    async def _send_notification(self, notification: Notification):\n        \"\"\"Send a system notification to the client as a text chunk.\"\"\"\n        body = notification.body.strip()\n        text = f\"[Notification] {notification.title}\"\n        if body:\n            text = f\"{text}\\n{body}\"\n        await self._send_text(text)\n\n    async def _send_tool_call(self, tool_call: ToolCall):\n        \"\"\"Send tool call to client.\"\"\"\n        assert self._turn_state is not None\n        if not self._id or not self._conn:\n            return\n\n        # Create and store tool call state\n        state = _ToolCallState(tool_call)\n        self._turn_state.tool_calls[tool_call.id] = state\n        self._turn_state.last_tool_call = state\n\n        await self._conn.session_update(\n            session_id=self._id,\n            update=acp.schema.ToolCallStart(\n                session_update=\"tool_call\",\n                tool_call_id=state.acp_tool_call_id,\n                title=state.get_title(),\n                status=\"in_progress\",\n                content=[\n                    acp.schema.ContentToolCallContent(\n                        type=\"content\",\n                        content=acp.schema.TextContentBlock(type=\"text\", text=state.args),\n                    )\n                ],\n            ),\n        )\n        logger.debug(\"Sent tool call: {name}\", name=tool_call.function.name)\n\n    async def _send_tool_call_part(self, part: ToolCallPart):\n        \"\"\"Send tool call part (streaming arguments).\"\"\"\n        assert self._turn_state is not None\n        if (\n            not self._id\n            or not self._conn\n            or not part.arguments_part\n            or self._turn_state.last_tool_call is None\n        ):\n            return\n\n        # Append new arguments part to the last tool call\n        self._turn_state.last_tool_call.append_args_part(part.arguments_part)\n\n        # Update the tool call with new content and title\n        update = acp.schema.ToolCallProgress(\n            session_update=\"tool_call_update\",\n            tool_call_id=self._turn_state.last_tool_call.acp_tool_call_id,\n            title=self._turn_state.last_tool_call.get_title(),\n            status=\"in_progress\",\n            content=[\n                acp.schema.ContentToolCallContent(\n                    type=\"content\",\n                    content=acp.schema.TextContentBlock(\n                        type=\"text\", text=self._turn_state.last_tool_call.args\n                    ),\n                )\n            ],\n        )\n\n        await self._conn.session_update(session_id=self._id, update=update)\n        logger.debug(\"Sent tool call update: {delta}\", delta=part.arguments_part[:50])\n\n    async def _send_tool_result(self, result: ToolResult):\n        \"\"\"Send tool result to client.\"\"\"\n        assert self._turn_state is not None\n        if not self._id or not self._conn:\n            return\n\n        tool_ret = result.return_value\n\n        state = self._turn_state.tool_calls.pop(result.tool_call_id, None)\n        if state is None:\n            logger.warning(\"Tool call not found: {id}\", id=result.tool_call_id)\n            return\n\n        update = acp.schema.ToolCallProgress(\n            session_update=\"tool_call_update\",\n            tool_call_id=state.acp_tool_call_id,\n            status=\"failed\" if tool_ret.is_error else \"completed\",\n        )\n\n        contents = (\n            []\n            if should_hide_terminal_output(state.acp_tool_call_id)\n            else tool_result_to_acp_content(tool_ret)\n        )\n        if contents:\n            update.content = contents\n\n        await self._conn.session_update(session_id=self._id, update=update)\n        logger.debug(\"Sent tool result: {id}\", id=result.tool_call_id)\n\n        for block in tool_ret.display:\n            if isinstance(block, TodoDisplayBlock):\n                await self._send_plan_update(block)\n\n    async def _handle_approval_request(self, request: ApprovalRequest):\n        \"\"\"Handle approval request by sending permission request to client.\"\"\"\n        assert self._turn_state is not None\n        if not self._id or not self._conn:\n            logger.warning(\"No session ID, auto-rejecting approval request\")\n            request.resolve(\"reject\")\n            return\n\n        state = self._turn_state.tool_calls.get(request.tool_call_id, None)\n        if state is None:\n            logger.warning(\"Tool call not found: {id}\", id=request.tool_call_id)\n            request.resolve(\"reject\")\n            return\n\n        try:\n            content: list[\n                acp.schema.ContentToolCallContent\n                | acp.schema.FileEditToolCallContent\n                | acp.schema.TerminalToolCallContent\n            ] = []\n            if request.display:\n                for block in request.display:\n                    diff_content = display_block_to_acp_content(block)\n                    if diff_content is not None:\n                        content.append(diff_content)\n            if not content:\n                content.append(\n                    acp.schema.ContentToolCallContent(\n                        type=\"content\",\n                        content=acp.schema.TextContentBlock(\n                            type=\"text\",\n                            text=f\"Requesting approval to perform: {request.description}\",\n                        ),\n                    )\n                )\n\n            # Send permission request and wait for response\n            logger.debug(\"Requesting permission for action: {action}\", action=request.action)\n            response = await self._conn.request_permission(\n                [\n                    acp.schema.PermissionOption(\n                        option_id=\"approve\",\n                        name=\"Approve once\",\n                        kind=\"allow_once\",\n                    ),\n                    acp.schema.PermissionOption(\n                        option_id=\"approve_for_session\",\n                        name=\"Approve for this session\",\n                        kind=\"allow_always\",\n                    ),\n                    acp.schema.PermissionOption(\n                        option_id=\"reject\",\n                        name=\"Reject\",\n                        kind=\"reject_once\",\n                    ),\n                ],\n                self._id,\n                acp.schema.ToolCallUpdate(\n                    tool_call_id=state.acp_tool_call_id,\n                    title=state.get_title(),\n                    content=content,\n                ),\n            )\n            logger.debug(\"Received permission response: {response}\", response=response)\n\n            # Process the outcome\n            if isinstance(response.outcome, acp.schema.AllowedOutcome):\n                # selected\n                option_id = response.outcome.option_id\n                if option_id == \"approve\":\n                    logger.debug(\"Permission granted for: {action}\", action=request.action)\n                    request.resolve(\"approve\")\n                elif option_id == \"approve_for_session\":\n                    logger.debug(\"Permission granted for session: {action}\", action=request.action)\n                    request.resolve(\"approve_for_session\")\n                else:\n                    logger.debug(\"Permission denied for: {action}\", action=request.action)\n                    request.resolve(\"reject\")\n            else:\n                # cancelled\n                logger.debug(\"Permission request cancelled for: {action}\", action=request.action)\n                request.resolve(\"reject\")\n        except Exception:\n            logger.exception(\"Error handling approval request:\")\n            # On error, reject the request\n            request.resolve(\"reject\")\n\n    async def _send_plan_update(self, block: TodoDisplayBlock) -> None:\n        \"\"\"Send todo list updates as ACP agent plan updates.\"\"\"\n\n        status_map: dict[str, acp.schema.PlanEntryStatus] = {\n            \"pending\": \"pending\",\n            \"in progress\": \"in_progress\",\n            \"in_progress\": \"in_progress\",\n            \"done\": \"completed\",\n            \"completed\": \"completed\",\n        }\n        entries: list[acp.schema.PlanEntry] = [\n            acp.schema.PlanEntry(\n                content=todo.title,\n                priority=\"medium\",\n                status=status_map.get(todo.status.lower(), \"pending\"),\n            )\n            for todo in block.items\n            if todo.title\n        ]\n\n        if not entries:\n            logger.warning(\"No valid todo items to send in plan update: {todos}\", todos=block.items)\n            return\n\n        await self._conn.session_update(\n            session_id=self._id,\n            update=acp.schema.AgentPlanUpdate(session_update=\"plan\", entries=entries),\n        )\n"
  },
  {
    "path": "src/kimi_cli/acp/tools.py",
    "content": "import asyncio\nfrom contextlib import suppress\n\nimport acp\nfrom kaos import get_current_kaos\nfrom kaos.local import local_kaos\nfrom kosong.tooling import CallableTool2, ToolReturnValue\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.tools.shell import Params as ShellParams\nfrom kimi_cli.tools.shell import Shell\nfrom kimi_cli.tools.utils import ToolRejectedError, ToolResultBuilder\nfrom kimi_cli.wire.types import DisplayBlock\n\n\ndef replace_tools(\n    client_capabilities: acp.schema.ClientCapabilities,\n    acp_conn: acp.Client,\n    acp_session_id: str,\n    toolset: KimiToolset,\n    runtime: Runtime,\n) -> None:\n    current_kaos = get_current_kaos().name\n    if current_kaos not in (local_kaos.name, \"acp\"):\n        # Only replace tools when running locally or under ACPKaos.\n        return\n\n    if client_capabilities.terminal and (shell_tool := toolset.find(Shell)):\n        # Replace the Shell tool with the ACP Terminal tool if supported.\n        toolset.add(\n            Terminal(\n                shell_tool,\n                acp_conn,\n                acp_session_id,\n                runtime.approval,\n            )\n        )\n\n\nclass HideOutputDisplayBlock(DisplayBlock):\n    \"\"\"A special DisplayBlock that indicates output should be hidden in ACP clients.\"\"\"\n\n    type: str = \"acp/hide_output\"\n\n\nclass Terminal(CallableTool2[ShellParams]):\n    def __init__(\n        self,\n        shell_tool: Shell,\n        acp_conn: acp.Client,\n        acp_session_id: str,\n        approval: Approval,\n    ) -> None:\n        # Use the `name`, `description`, and `params` from the existing Shell tool,\n        # so that when this is added to the toolset, it replaces the original Shell tool.\n        super().__init__(shell_tool.name, shell_tool.description, shell_tool.params)\n        self._acp_conn = acp_conn\n        self._acp_session_id = acp_session_id\n        self._approval = approval\n\n    async def __call__(self, params: ShellParams) -> ToolReturnValue:\n        from kimi_cli.acp.session import get_current_acp_tool_call_id_or_none\n\n        builder = ToolResultBuilder()\n        # Hide tool output because we use `TerminalToolCallContent` which already streams output\n        # directly to the user.\n        builder.display(HideOutputDisplayBlock())\n\n        if not params.command:\n            return builder.error(\"Command cannot be empty.\", brief=\"Empty command\")\n\n        if not await self._approval.request(\n            self.name,\n            \"run shell command\",\n            f\"Run command `{params.command}`\",\n        ):\n            return ToolRejectedError()\n\n        timeout_seconds = float(params.timeout)\n        timeout_label = f\"{timeout_seconds:g}s\"\n        terminal: acp.TerminalHandle | None = None\n        exit_status: (\n            acp.schema.WaitForTerminalExitResponse | acp.schema.TerminalExitStatus | None\n        ) = None\n        timed_out = False\n\n        try:\n            term = await self._acp_conn.create_terminal(\n                command=params.command,\n                session_id=self._acp_session_id,\n                output_byte_limit=builder.max_chars,\n            )\n            # FIXME: update ACP sdk for the fix\n            assert isinstance(term, acp.TerminalHandle), (\n                \"Expected TerminalHandle from create_terminal\"\n            )\n            terminal = term\n\n            acp_tool_call_id = get_current_acp_tool_call_id_or_none()\n            assert acp_tool_call_id, \"Expected to have an ACP tool call ID in context\"\n            await self._acp_conn.session_update(\n                session_id=self._acp_session_id,\n                update=acp.schema.ToolCallProgress(\n                    session_update=\"tool_call_update\",\n                    tool_call_id=acp_tool_call_id,\n                    status=\"in_progress\",\n                    content=[\n                        acp.schema.TerminalToolCallContent(\n                            type=\"terminal\",\n                            terminal_id=terminal.id,\n                        )\n                    ],\n                ),\n            )\n\n            try:\n                async with asyncio.timeout(timeout_seconds):\n                    exit_status = await terminal.wait_for_exit()\n            except TimeoutError:\n                timed_out = True\n                await terminal.kill()\n\n            output_response = await terminal.current_output()\n            builder.write(output_response.output)\n            if output_response.exit_status:\n                exit_status = output_response.exit_status\n\n            exit_code = exit_status.exit_code if exit_status else None\n            exit_signal = exit_status.signal if exit_status else None\n\n            truncated_note = (\n                \" Output was truncated by the client output limit.\"\n                if output_response.truncated\n                else \"\"\n            )\n\n            if timed_out:\n                return builder.error(\n                    f\"Command killed by timeout ({timeout_label}){truncated_note}\",\n                    brief=f\"Killed by timeout ({timeout_label})\",\n                )\n            if exit_signal:\n                return builder.error(\n                    f\"Command terminated by signal: {exit_signal}.{truncated_note}\",\n                    brief=f\"Signal: {exit_signal}\",\n                )\n            if exit_code not in (None, 0):\n                return builder.error(\n                    f\"Command failed with exit code: {exit_code}.{truncated_note}\",\n                    brief=f\"Failed with exit code: {exit_code}\",\n                )\n            return builder.ok(f\"Command executed successfully.{truncated_note}\")\n        finally:\n            if terminal is not None:\n                with suppress(Exception):\n                    await terminal.release()\n"
  },
  {
    "path": "src/kimi_cli/acp/types.py",
    "content": "from __future__ import annotations\n\nimport acp\n\nMCPServer = acp.schema.HttpMcpServer | acp.schema.SseMcpServer | acp.schema.McpServerStdio\n\nACPContentBlock = (\n    acp.schema.TextContentBlock\n    | acp.schema.ImageContentBlock\n    | acp.schema.AudioContentBlock\n    | acp.schema.ResourceContentBlock\n    | acp.schema.EmbeddedResourceContentBlock\n)\n"
  },
  {
    "path": "src/kimi_cli/acp/version.py",
    "content": "from __future__ import annotations\n\nfrom dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass ACPVersionSpec:\n    \"\"\"Describes one supported ACP protocol version.\"\"\"\n\n    protocol_version: int  # negotiation integer (currently 1)\n    spec_tag: str  # ACP spec tag (e.g. \"v0.10.8\")\n    sdk_version: str  # corresponding SDK version (e.g. \"0.8.0\")\n\n\nCURRENT_VERSION = ACPVersionSpec(\n    protocol_version=1,\n    spec_tag=\"v0.10.8\",\n    sdk_version=\"0.8.0\",\n)\n\nSUPPORTED_VERSIONS: dict[int, ACPVersionSpec] = {\n    1: CURRENT_VERSION,\n}\n\nMIN_PROTOCOL_VERSION = 1\n\n\ndef negotiate_version(client_protocol_version: int) -> ACPVersionSpec:\n    \"\"\"Negotiate the protocol version with the client.\n\n    Returns the highest server-supported version that does not exceed the\n    client's requested version.  If the client version is lower than\n    ``MIN_PROTOCOL_VERSION`` the server still returns its own current\n    version so the client can decide whether to disconnect.\n    \"\"\"\n    if client_protocol_version < MIN_PROTOCOL_VERSION:\n        return CURRENT_VERSION\n\n    # Find the highest supported version <= client version\n    best: ACPVersionSpec | None = None\n    for ver, spec in SUPPORTED_VERSIONS.items():\n        if ver <= client_protocol_version and (best is None or ver > best.protocol_version):\n            best = spec\n\n    return best if best is not None else CURRENT_VERSION\n"
  },
  {
    "path": "src/kimi_cli/agents/default/agent.yaml",
    "content": "version: 1\nagent:\n  name: \"\"\n  system_prompt_path: ./system.md\n  system_prompt_args:\n    ROLE_ADDITIONAL: \"\"\n  tools:\n    - \"kimi_cli.tools.multiagent:Task\"\n    # - \"kimi_cli.tools.multiagent:CreateSubagent\"\n    # - \"kimi_cli.tools.dmail:SendDMail\"\n    # - \"kimi_cli.tools.think:Think\"\n    - \"kimi_cli.tools.ask_user:AskUserQuestion\"\n    - \"kimi_cli.tools.todo:SetTodoList\"\n    - \"kimi_cli.tools.shell:Shell\"\n    - \"kimi_cli.tools.background:TaskList\"\n    - \"kimi_cli.tools.background:TaskOutput\"\n    - \"kimi_cli.tools.background:TaskStop\"\n    - \"kimi_cli.tools.file:ReadFile\"\n    - \"kimi_cli.tools.file:ReadMediaFile\"\n    - \"kimi_cli.tools.file:Glob\"\n    - \"kimi_cli.tools.file:Grep\"\n    - \"kimi_cli.tools.file:WriteFile\"\n    - \"kimi_cli.tools.file:StrReplaceFile\"\n    - \"kimi_cli.tools.web:SearchWeb\"\n    - \"kimi_cli.tools.web:FetchURL\"\n    - \"kimi_cli.tools.plan:ExitPlanMode\"\n    - \"kimi_cli.tools.plan.enter:EnterPlanMode\"\n  subagents:\n    coder:\n      path: ./sub.yaml\n      description: \"Good at general software engineering tasks.\"\n"
  },
  {
    "path": "src/kimi_cli/agents/default/sub.yaml",
    "content": "version: 1\nagent:\n  extend: ./agent.yaml\n  system_prompt_args:\n    ROLE_ADDITIONAL: |\n      You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You need to provide a comprehensive summary on what you have done and learned in your final message. If you wrote or modified any files, you must mention them in the summary.\n  exclude_tools:\n    - \"kimi_cli.tools.multiagent:Task\"\n    - \"kimi_cli.tools.multiagent:CreateSubagent\"\n    - \"kimi_cli.tools.dmail:SendDMail\"\n    - \"kimi_cli.tools.todo:SetTodoList\"\n    - \"kimi_cli.tools.plan:ExitPlanMode\"\n    - \"kimi_cli.tools.plan.enter:EnterPlanMode\"\n  subagents: # make sure no subagents are provided\n"
  },
  {
    "path": "src/kimi_cli/agents/default/system.md",
    "content": "You are Kimi Code CLI, an interactive general AI agent running on a user's computer.\n\nYour primary goal is to answer questions and/or finish tasks safely and efficiently, adhering strictly to the following system instructions and the user's requirements, leveraging the available tools flexibly.\n\n${ROLE_ADDITIONAL}\n\n# Prompt and Tool Use\n\nThe user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly.\n\nWhen handling the user's request, you may call available tools to accomplish the task. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools.\n\nYou have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance.\n\nThe results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information.\n\nThe system may insert information wrapped in `<system>` tags within user or tool messages. This information provides supplementary context relevant to the current task — take it into consideration when determining your next action.\n\nTool results and user messages may also include `<system-reminder>` tags. Unlike `<system>` tags, these are **authoritative system directives** that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode).\n\nIf the `Shell`, `TaskList`, `TaskOutput`, and `TaskStop` tools are available and you are the root agent, you can use Background Bash for long-running shell commands. Launch it via `Shell` with `run_in_background=true` and a short `description`. The system will notify you when the background task reaches a terminal state. Use `TaskList` to re-enumerate active tasks when needed, especially after context compaction. Use `TaskOutput` to inspect progress or wait for completion, and use `TaskStop` only when you need to cancel the task. For human users in the interactive shell, the only task-management slash command is `/task`. Do not tell users to run `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented slash subcommands. If you are a subagent or these tools are not available, do not assume you can create or control background tasks.\n\nWhen responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise.\n\n# General Guidelines for Coding\n\nWhen building something from scratch, you should:\n\n- Understand the user's requirements.\n- Ask the user for clarification if there is anything unclear.\n- Design the architecture and make a plan for the implementation.\n- Write the code in a modular and maintainable way.\n\nWhen working on an existing codebase, you should:\n\n- Understand the codebase and the user's requirements. Identify the ultimate goal and the most important criteria to achieve the goal.\n- For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes.\n- For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests.\n- For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes.\n- Make MINIMAL changes to achieve the goal. This is very important to your performance.\n- Follow the coding style of existing code in the project.\n\nDO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations.\n\n# General Guidelines for Research and Data Processing\n\nThe user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must:\n\n- Understand the user's requirements thoroughly, ask for clarification before you start if needed.\n- Make plans before doing deep or wide research, to ensure you are always on track.\n- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy.\n- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment.\n- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected.\n- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation.\n\n# Working Environment\n\n## Operating System\n\nThe operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory.\n\n## Date and Time\n\nThe current date and time in ISO format is `${KIMI_NOW}`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Shell tool with proper command.\n\n## Working Directory\n\nThe current working directory is `${KIMI_WORK_DIR}`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters.\n\nThe directory listing of current working directory is:\n\n```\n${KIMI_WORK_DIR_LS}\n```\n\nUse this as your basic understanding of the project structure.\n{% if KIMI_ADDITIONAL_DIRS_INFO %}\n\n## Additional Directories\n\nThe following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.\n\n${KIMI_ADDITIONAL_DIRS_INFO}\n{% endif %}\n\n# Project Information\n\nMarkdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root.\n\n> Why `AGENTS.md`?\n>\n> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors.\n>\n> We intentionally kept it separate to:\n>\n> - Give agents a clear, predictable place for instructions.\n> - Keep `README`s concise and focused on human contributors.\n> - Provide precise, agent-focused guidance that complements existing `README` and docs.\n\nThe project level `${KIMI_WORK_DIR}/AGENTS.md`:\n\n`````````\n${KIMI_AGENTS_MD}\n`````````\n\nIf the above `AGENTS.md` is empty or insufficient, you may check `README`/`README.md` files or `AGENTS.md` files in subdirectories for more information about specific parts of the project.\n\nIf you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.\n\n# Skills\n\nSkills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material.\n\n## What are skills?\n\nSkills are modular extensions that provide:\n\n- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis)\n- Workflow patterns: Best practices for common tasks\n- Tool integrations: Pre-configured tool chains for specific operations\n- Reference material: Documentation, templates, and examples\n\n## Available skills\n\n${KIMI_SKILLS}\n\n## How to use skills\n\nIdentify the skills that are likely to be useful for the tasks you are currently working on, read the `SKILL.md` file for detailed instructions, guidelines, scripts and more.\n\nOnly read skill details when needed to conserve the context window.\n\n# Ultimate Reminders\n\nAt any time, you should be HELPFUL and POLITE, CONCISE and ACCURATE, PATIENT and THOROUGH.\n\n- Never diverge from the requirements and the goals of the task you work on. Stay on track.\n- Never give the user more than what they want.\n- Try your best to avoid any hallucination. Do fact checking before providing any factual information.\n- Think twice before you act.\n- Do not give up too early.\n- ALWAYS, keep it stupidly simple. Do not overcomplicate things.\n"
  },
  {
    "path": "src/kimi_cli/agents/okabe/agent.yaml",
    "content": "version: 1\nagent:\n  extend: default\n  tools:\n    - \"kimi_cli.tools.multiagent:Task\"\n    # - \"kimi_cli.tools.multiagent:CreateSubagent\"\n    - \"kimi_cli.tools.dmail:SendDMail\"\n    # - \"kimi_cli.tools.think:Think\"\n    - \"kimi_cli.tools.todo:SetTodoList\"\n    - \"kimi_cli.tools.shell:Shell\"\n    - \"kimi_cli.tools.file:ReadFile\"\n    - \"kimi_cli.tools.file:ReadMediaFile\"\n    - \"kimi_cli.tools.file:Glob\"\n    - \"kimi_cli.tools.file:Grep\"\n    - \"kimi_cli.tools.file:WriteFile\"\n    - \"kimi_cli.tools.file:StrReplaceFile\"\n    - \"kimi_cli.tools.web:SearchWeb\"\n    - \"kimi_cli.tools.web:FetchURL\"\n"
  },
  {
    "path": "src/kimi_cli/agentspec.py",
    "content": "from __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Any, NamedTuple\n\nimport yaml\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.exception import AgentSpecError\n\nDEFAULT_AGENT_SPEC_VERSION = \"1\"\nSUPPORTED_AGENT_SPEC_VERSIONS = (DEFAULT_AGENT_SPEC_VERSION,)\n\n\ndef get_agents_dir() -> Path:\n    return Path(__file__).parent / \"agents\"\n\n\nDEFAULT_AGENT_FILE = get_agents_dir() / \"default\" / \"agent.yaml\"\nOKABE_AGENT_FILE = get_agents_dir() / \"okabe\" / \"agent.yaml\"\n\n\nclass Inherit(NamedTuple):\n    \"\"\"Marker class for inheritance in agent spec.\"\"\"\n\n\ninherit = Inherit()\n\n\nclass AgentSpec(BaseModel):\n    \"\"\"Agent specification.\"\"\"\n\n    extend: str | None = Field(default=None, description=\"Agent file to extend\")\n    name: str | Inherit = Field(default=inherit, description=\"Agent name\")  # required\n    system_prompt_path: Path | Inherit = Field(\n        default=inherit, description=\"System prompt path\"\n    )  # required\n    system_prompt_args: dict[str, str] = Field(\n        default_factory=dict, description=\"System prompt arguments\"\n    )\n    tools: list[str] | None | Inherit = Field(default=inherit, description=\"Tools\")  # required\n    exclude_tools: list[str] | None | Inherit = Field(\n        default=inherit, description=\"Tools to exclude\"\n    )\n    subagents: dict[str, SubagentSpec] | None | Inherit = Field(\n        default=inherit, description=\"Subagents\"\n    )\n\n\nclass SubagentSpec(BaseModel):\n    \"\"\"Subagent specification.\"\"\"\n\n    path: Path = Field(description=\"Subagent file path\")\n    description: str = Field(description=\"Subagent description\")\n\n\n@dataclass(frozen=True, slots=True, kw_only=True)\nclass ResolvedAgentSpec:\n    \"\"\"Resolved agent specification.\"\"\"\n\n    name: str\n    system_prompt_path: Path\n    system_prompt_args: dict[str, str]\n    tools: list[str]\n    exclude_tools: list[str]\n    subagents: dict[str, SubagentSpec]\n\n\ndef load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:\n    \"\"\"\n    Load agent specification from file.\n\n    Raises:\n        FileNotFoundError: If the agent spec file is not found.\n        AgentSpecError: If the agent spec is not valid.\n    \"\"\"\n    agent_spec = _load_agent_spec(agent_file)\n    assert agent_spec.extend is None, \"agent extension should be recursively resolved\"\n    if isinstance(agent_spec.name, Inherit):\n        raise AgentSpecError(\"Agent name is required\")\n    if isinstance(agent_spec.system_prompt_path, Inherit):\n        raise AgentSpecError(\"System prompt path is required\")\n    if isinstance(agent_spec.tools, Inherit):\n        raise AgentSpecError(\"Tools are required\")\n    if isinstance(agent_spec.exclude_tools, Inherit):\n        agent_spec.exclude_tools = []\n    if isinstance(agent_spec.subagents, Inherit):\n        agent_spec.subagents = {}\n    return ResolvedAgentSpec(\n        name=agent_spec.name,\n        system_prompt_path=agent_spec.system_prompt_path,\n        system_prompt_args=agent_spec.system_prompt_args,\n        tools=agent_spec.tools or [],\n        exclude_tools=agent_spec.exclude_tools or [],\n        subagents=agent_spec.subagents or {},\n    )\n\n\ndef _load_agent_spec(agent_file: Path) -> AgentSpec:\n    if not agent_file.exists():\n        raise AgentSpecError(f\"Agent spec file not found: {agent_file}\")\n    if not agent_file.is_file():\n        raise AgentSpecError(f\"Agent spec path is not a file: {agent_file}\")\n    try:\n        with open(agent_file, encoding=\"utf-8\") as f:\n            data: dict[str, Any] = yaml.safe_load(f)\n    except yaml.YAMLError as e:\n        raise AgentSpecError(f\"Invalid YAML in agent spec file: {e}\") from e\n\n    version = str(data.get(\"version\", DEFAULT_AGENT_SPEC_VERSION))\n    if version not in SUPPORTED_AGENT_SPEC_VERSIONS:\n        raise AgentSpecError(f\"Unsupported agent spec version: {version}\")\n\n    agent_spec = AgentSpec(**data.get(\"agent\", {}))\n    if isinstance(agent_spec.system_prompt_path, Path):\n        agent_spec.system_prompt_path = (\n            agent_file.parent / agent_spec.system_prompt_path\n        ).absolute()\n    if isinstance(agent_spec.subagents, dict):\n        for v in agent_spec.subagents.values():\n            v.path = (agent_file.parent / v.path).absolute()\n    if agent_spec.extend:\n        if agent_spec.extend == \"default\":\n            base_agent_file = DEFAULT_AGENT_FILE\n        else:\n            base_agent_file = (agent_file.parent / agent_spec.extend).absolute()\n        base_agent_spec = _load_agent_spec(base_agent_file)\n        if not isinstance(agent_spec.name, Inherit):\n            base_agent_spec.name = agent_spec.name\n        if not isinstance(agent_spec.system_prompt_path, Inherit):\n            base_agent_spec.system_prompt_path = agent_spec.system_prompt_path\n        for k, v in agent_spec.system_prompt_args.items():\n            # system prompt args should be merged instead of overwritten\n            base_agent_spec.system_prompt_args[k] = v\n        if not isinstance(agent_spec.tools, Inherit):\n            base_agent_spec.tools = agent_spec.tools\n        if not isinstance(agent_spec.exclude_tools, Inherit):\n            base_agent_spec.exclude_tools = agent_spec.exclude_tools\n        if not isinstance(agent_spec.subagents, Inherit):\n            base_agent_spec.subagents = agent_spec.subagents\n        agent_spec = base_agent_spec\n    return agent_spec\n"
  },
  {
    "path": "src/kimi_cli/app.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport dataclasses\nimport warnings\nfrom collections.abc import AsyncGenerator, Callable\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nimport kaos\nfrom kaos.path import KaosPath\nfrom pydantic import SecretStr\n\nfrom kimi_cli.agentspec import DEFAULT_AGENT_FILE\nfrom kimi_cli.auth.oauth import OAuthManager\nfrom kimi_cli.cli import InputFormat, OutputFormat\nfrom kimi_cli.config import Config, LLMModel, LLMProvider, load_config\nfrom kimi_cli.llm import augment_provider_with_env_vars, create_llm, model_display_name\nfrom kimi_cli.session import Session\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.soul import run_soul\nfrom kimi_cli.soul.agent import Runtime, load_agent\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.utils.logging import logger, redirect_stderr_to_logger\nfrom kimi_cli.utils.path import shorten_home\nfrom kimi_cli.wire import Wire, WireUISide\nfrom kimi_cli.wire.types import ContentPart, WireMessage\n\nif TYPE_CHECKING:\n    from fastmcp.mcp_config import MCPConfig\n\n\ndef enable_logging(debug: bool = False, *, redirect_stderr: bool = True) -> None:\n    # NOTE: stderr redirection is implemented by swapping the process-level fd=2 (dup2).\n    # That can hide Click/Typer error output during CLI startup, so some entrypoints delay\n    # installing it until after critical initialization succeeds.\n    logger.remove()  # Remove default stderr handler\n    logger.enable(\"kimi_cli\")\n    if debug:\n        logger.enable(\"kosong\")\n    logger.add(\n        get_share_dir() / \"logs\" / \"kimi.log\",\n        # FIXME: configure level for different modules\n        level=\"TRACE\" if debug else \"INFO\",\n        rotation=\"06:00\",\n        retention=\"10 days\",\n    )\n    if redirect_stderr:\n        redirect_stderr_to_logger()\n\n\nclass KimiCLI:\n    @staticmethod\n    async def create(\n        session: Session,\n        *,\n        # Basic configuration\n        config: Config | Path | None = None,\n        model_name: str | None = None,\n        thinking: bool | None = None,\n        # Run mode\n        yolo: bool = False,\n        # Extensions\n        agent_file: Path | None = None,\n        mcp_configs: list[MCPConfig] | list[dict[str, Any]] | None = None,\n        skills_dir: KaosPath | None = None,\n        # Loop control\n        max_steps_per_turn: int | None = None,\n        max_retries_per_step: int | None = None,\n        max_ralph_iterations: int | None = None,\n        startup_progress: Callable[[str], None] | None = None,\n        defer_mcp_loading: bool = False,\n    ) -> KimiCLI:\n        \"\"\"\n        Create a KimiCLI instance.\n\n        Args:\n            session (Session): A session created by `Session.create` or `Session.continue_`.\n            config (Config | Path | None, optional): Configuration to use, or path to config file.\n                Defaults to None.\n            model_name (str | None, optional): Name of the model to use. Defaults to None.\n            thinking (bool | None, optional): Whether to enable thinking mode. Defaults to None.\n            yolo (bool, optional): Approve all actions without confirmation. Defaults to False.\n            agent_file (Path | None, optional): Path to the agent file. Defaults to None.\n            mcp_configs (list[MCPConfig | dict[str, Any]] | None, optional): MCP configs to load\n                MCP tools from. Defaults to None.\n            skills_dir (KaosPath | None, optional): Override skills directory discovery. Defaults\n                to None.\n            max_steps_per_turn (int | None, optional): Maximum number of steps in one turn.\n                Defaults to None.\n            max_retries_per_step (int | None, optional): Maximum number of retries in one step.\n                Defaults to None.\n            max_ralph_iterations (int | None, optional): Extra iterations after the first turn in\n                Ralph mode. Defaults to None.\n            startup_progress (Callable[[str], None] | None, optional): Progress callback used by\n                interactive startup UI. Defaults to None.\n            defer_mcp_loading (bool, optional): Defer MCP startup until the interactive shell is\n                ready. Defaults to False.\n\n        Raises:\n            FileNotFoundError: When the agent file is not found.\n            ConfigError(KimiCLIException, ValueError): When the configuration is invalid.\n            AgentSpecError(KimiCLIException, ValueError): When the agent specification is invalid.\n            SystemPromptTemplateError(KimiCLIException, ValueError): When the system prompt\n                template is invalid.\n            InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded.\n            MCPConfigError(KimiCLIException, ValueError): When any MCP configuration is invalid.\n            MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be\n                connected.\n        \"\"\"\n        if startup_progress is not None:\n            startup_progress(\"Loading configuration...\")\n\n        config = config if isinstance(config, Config) else load_config(config)\n        if max_steps_per_turn is not None:\n            config.loop_control.max_steps_per_turn = max_steps_per_turn\n        if max_retries_per_step is not None:\n            config.loop_control.max_retries_per_step = max_retries_per_step\n        if max_ralph_iterations is not None:\n            config.loop_control.max_ralph_iterations = max_ralph_iterations\n        logger.info(\"Loaded config: {config}\", config=config)\n\n        oauth = OAuthManager(config)\n\n        model: LLMModel | None = None\n        provider: LLMProvider | None = None\n\n        # try to use config file\n        if not model_name and config.default_model:\n            # no --model specified && default model is set in config\n            model = config.models[config.default_model]\n            provider = config.providers[model.provider]\n        if model_name and model_name in config.models:\n            # --model specified && model is set in config\n            model = config.models[model_name]\n            provider = config.providers[model.provider]\n\n        if not model:\n            model = LLMModel(provider=\"\", model=\"\", max_context_size=100_000)\n            provider = LLMProvider(type=\"kimi\", base_url=\"\", api_key=SecretStr(\"\"))\n\n        # try overwrite with environment variables\n        assert provider is not None\n        assert model is not None\n        env_overrides = augment_provider_with_env_vars(provider, model)\n\n        # determine thinking mode\n        thinking = config.default_thinking if thinking is None else thinking\n\n        # determine yolo mode\n        yolo = yolo if yolo else config.default_yolo\n\n        llm = create_llm(\n            provider,\n            model,\n            thinking=thinking,\n            session_id=session.id,\n            oauth=oauth,\n        )\n        if llm is not None:\n            logger.info(\"Using LLM provider: {provider}\", provider=provider)\n            logger.info(\"Using LLM model: {model}\", model=model)\n            logger.info(\"Thinking mode: {thinking}\", thinking=thinking)\n\n        if startup_progress is not None:\n            startup_progress(\"Scanning workspace...\")\n\n        runtime = await Runtime.create(config, oauth, llm, session, yolo, skills_dir)\n        runtime.notifications.recover()\n        runtime.background_tasks.reconcile()\n\n        # Refresh plugin configs with fresh credentials (e.g. OAuth tokens)\n        try:\n            from kimi_cli.plugin.manager import (\n                collect_host_values,\n                get_plugins_dir,\n                refresh_plugin_configs,\n            )\n\n            host_values = collect_host_values(config, oauth)\n            if host_values.get(\"api_key\"):\n                refresh_plugin_configs(get_plugins_dir(), host_values)\n        except Exception:\n            logger.debug(\"Failed to refresh plugin configs, skipping\")\n\n        if agent_file is None:\n            agent_file = DEFAULT_AGENT_FILE\n        if startup_progress is not None:\n            startup_progress(\"Loading agent...\")\n\n        agent = await load_agent(\n            agent_file,\n            runtime,\n            mcp_configs=mcp_configs or [],\n            start_mcp_loading=not defer_mcp_loading,\n        )\n\n        if startup_progress is not None:\n            startup_progress(\"Restoring conversation...\")\n        context = Context(session.context_file)\n        await context.restore()\n\n        if context.system_prompt is not None:\n            agent = dataclasses.replace(agent, system_prompt=context.system_prompt)\n        else:\n            await context.write_system_prompt(agent.system_prompt)\n\n        soul = KimiSoul(agent, context=context)\n        return KimiCLI(soul, runtime, env_overrides)\n\n    def __init__(\n        self,\n        _soul: KimiSoul,\n        _runtime: Runtime,\n        _env_overrides: dict[str, str],\n    ) -> None:\n        self._soul = _soul\n        self._runtime = _runtime\n        self._env_overrides = _env_overrides\n\n    @property\n    def soul(self) -> KimiSoul:\n        \"\"\"Get the KimiSoul instance.\"\"\"\n        return self._soul\n\n    @property\n    def session(self) -> Session:\n        \"\"\"Get the Session instance.\"\"\"\n        return self._runtime.session\n\n    def shutdown_background_tasks(self) -> None:\n        \"\"\"Kill active background tasks on exit, unless keep_alive_on_exit is configured.\"\"\"\n        if self._runtime.config.background.keep_alive_on_exit:\n            return\n        killed = self._runtime.background_tasks.kill_all_active(reason=\"CLI session ended\")\n        if killed:\n            logger.info(\"Stopped {n} background task(s) on exit: {ids}\", n=len(killed), ids=killed)\n\n    @contextlib.asynccontextmanager\n    async def _env(self) -> AsyncGenerator[None]:\n        original_cwd = KaosPath.cwd()\n        await kaos.chdir(self._runtime.session.work_dir)\n        try:\n            # to ignore possible warnings from dateparser\n            warnings.filterwarnings(\"ignore\", category=DeprecationWarning)\n            async with self._runtime.oauth.refreshing(self._runtime):\n                yield\n        finally:\n            await kaos.chdir(original_cwd)\n\n    async def run(\n        self,\n        user_input: str | list[ContentPart],\n        cancel_event: asyncio.Event,\n        merge_wire_messages: bool = False,\n    ) -> AsyncGenerator[WireMessage]:\n        \"\"\"\n        Run the Kimi Code CLI instance without any UI and yield Wire messages directly.\n\n        Args:\n            user_input (str | list[ContentPart]): The user input to the agent.\n            cancel_event (asyncio.Event): An event to cancel the run.\n            merge_wire_messages (bool): Whether to merge Wire messages as much as possible.\n\n        Yields:\n            WireMessage: The Wire messages from the `KimiSoul`.\n\n        Raises:\n            LLMNotSet: When the LLM is not set.\n            LLMNotSupported: When the LLM does not have required capabilities.\n            ChatProviderError: When the LLM provider returns an error.\n            MaxStepsReached: When the maximum number of steps is reached.\n            RunCancelled: When the run is cancelled by the cancel event.\n        \"\"\"\n        async with self._env():\n            wire_future = asyncio.Future[WireUISide]()\n            stop_ui_loop = asyncio.Event()\n\n            async def _ui_loop_fn(wire: Wire) -> None:\n                wire_future.set_result(wire.ui_side(merge=merge_wire_messages))\n                await stop_ui_loop.wait()\n\n            soul_task = asyncio.create_task(\n                run_soul(\n                    self.soul,\n                    user_input,\n                    _ui_loop_fn,\n                    cancel_event,\n                    runtime=self._runtime,\n                )\n            )\n\n            try:\n                wire_ui = await wire_future\n                while True:\n                    msg = await wire_ui.receive()\n                    yield msg\n            except QueueShutDown:\n                pass\n            finally:\n                # stop consuming Wire messages\n                stop_ui_loop.set()\n                # wait for the soul task to finish, or raise\n                await soul_task\n\n    async def run_shell(self, command: str | None = None) -> bool:\n        \"\"\"Run the Kimi Code CLI instance with shell UI.\"\"\"\n        from kimi_cli.ui.shell import Shell, WelcomeInfoItem\n\n        welcome_info = [\n            WelcomeInfoItem(\n                name=\"Directory\", value=str(shorten_home(self._runtime.session.work_dir))\n            ),\n            WelcomeInfoItem(name=\"Session\", value=self._runtime.session.id),\n        ]\n        if base_url := self._env_overrides.get(\"KIMI_BASE_URL\"):\n            welcome_info.append(\n                WelcomeInfoItem(\n                    name=\"API URL\",\n                    value=f\"{base_url} (from KIMI_BASE_URL)\",\n                    level=WelcomeInfoItem.Level.WARN,\n                )\n            )\n        if self._env_overrides.get(\"KIMI_API_KEY\"):\n            welcome_info.append(\n                WelcomeInfoItem(\n                    name=\"API Key\",\n                    value=\"****** (from KIMI_API_KEY)\",\n                    level=WelcomeInfoItem.Level.WARN,\n                )\n            )\n        if not self._runtime.llm:\n            welcome_info.append(\n                WelcomeInfoItem(\n                    name=\"Model\",\n                    value=\"not set, send /login to login\",\n                    level=WelcomeInfoItem.Level.WARN,\n                )\n            )\n        elif \"KIMI_MODEL_NAME\" in self._env_overrides:\n            welcome_info.append(\n                WelcomeInfoItem(\n                    name=\"Model\",\n                    value=f\"{self._soul.model_name} (from KIMI_MODEL_NAME)\",\n                    level=WelcomeInfoItem.Level.WARN,\n                )\n            )\n        else:\n            welcome_info.append(\n                WelcomeInfoItem(\n                    name=\"Model\",\n                    value=model_display_name(self._soul.model_name),\n                    level=WelcomeInfoItem.Level.INFO,\n                )\n            )\n            if self._soul.model_name not in (\n                \"kimi-for-coding\",\n                \"kimi-code\",\n                \"kimi-k2.5\",\n                \"kimi-k2-5\",\n            ):\n                welcome_info.append(\n                    WelcomeInfoItem(\n                        name=\"Tip\",\n                        value=\"send /login to use our latest kimi-k2.5 model\",\n                        level=WelcomeInfoItem.Level.WARN,\n                    )\n                )\n        welcome_info.append(\n            WelcomeInfoItem(\n                name=\"\\nTip\",\n                value=(\n                    \"Kimi Code Web UI, a GUI version of Kimi Code, is now in technical preview.\"\n                    \"\\n\"\n                    \"     Type /web to switch, or next time run `kimi web` directly.\"\n                ),\n                level=WelcomeInfoItem.Level.INFO,\n            )\n        )\n        async with self._env():\n            shell = Shell(self._soul, welcome_info=welcome_info)\n            return await shell.run(command)\n\n    async def run_print(\n        self,\n        input_format: InputFormat,\n        output_format: OutputFormat,\n        command: str | None = None,\n        *,\n        final_only: bool = False,\n    ) -> bool:\n        \"\"\"Run the Kimi Code CLI instance with print UI.\"\"\"\n        from kimi_cli.ui.print import Print\n\n        async with self._env():\n            print_ = Print(\n                self._soul,\n                input_format,\n                output_format,\n                self._runtime.session.context_file,\n                final_only=final_only,\n            )\n            return await print_.run(command)\n\n    async def run_acp(self) -> None:\n        \"\"\"Run the Kimi Code CLI instance as ACP server.\"\"\"\n        from kimi_cli.ui.acp import ACP\n\n        async with self._env():\n            acp = ACP(self._soul)\n            await acp.run()\n\n    async def run_wire_stdio(self) -> None:\n        \"\"\"Run the Kimi Code CLI instance as Wire server over stdio.\"\"\"\n        from kimi_cli.wire.server import WireServer\n\n        async with self._env():\n            server = WireServer(self._soul)\n            await server.serve()\n"
  },
  {
    "path": "src/kimi_cli/auth/__init__.py",
    "content": "from __future__ import annotations\n\nKIMI_CODE_PLATFORM_ID = \"kimi-code\"\n\n__all__ = [\"KIMI_CODE_PLATFORM_ID\"]\n"
  },
  {
    "path": "src/kimi_cli/auth/oauth.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport os\nimport platform\nimport socket\nimport sys\nimport time\nimport uuid\nimport webbrowser\nfrom collections.abc import AsyncIterator\nfrom contextlib import asynccontextmanager, suppress\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, Literal, cast\n\nimport aiohttp\nimport keyring\nfrom pydantic import SecretStr\n\nfrom kimi_cli.auth import KIMI_CODE_PLATFORM_ID\nfrom kimi_cli.auth.platforms import (\n    ModelInfo,\n    get_platform_by_id,\n    list_models,\n    managed_model_key,\n    managed_provider_key,\n)\nfrom kimi_cli.config import (\n    Config,\n    LLMModel,\n    LLMProvider,\n    MoonshotFetchConfig,\n    MoonshotSearchConfig,\n    OAuthRef,\n    save_config,\n)\nfrom kimi_cli.constant import VERSION\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.utils.aiohttp import new_client_session\nfrom kimi_cli.utils.logging import logger\n\nif TYPE_CHECKING:\n    from kimi_cli.soul.agent import Runtime\n\n\nKIMI_CODE_CLIENT_ID = \"17e5f671-d194-4dfb-9706-5516cb48c098\"\nKIMI_CODE_OAUTH_KEY = \"oauth/kimi-code\"\nDEFAULT_OAUTH_HOST = \"https://auth.kimi.com\"\nKEYRING_SERVICE = \"kimi-code\"\nREFRESH_INTERVAL_SECONDS = 60\nREFRESH_THRESHOLD_SECONDS = 300\n\n\nclass OAuthError(RuntimeError):\n    \"\"\"OAuth flow error.\"\"\"\n\n\nclass OAuthUnauthorized(OAuthError):\n    \"\"\"OAuth credentials rejected.\"\"\"\n\n\nclass OAuthDeviceExpired(OAuthError):\n    \"\"\"Device authorization expired.\"\"\"\n\n\nOAuthEventKind = Literal[\"info\", \"error\", \"waiting\", \"verification_url\", \"success\"]\n\n\n@dataclass(slots=True, frozen=True)\nclass OAuthEvent:\n    type: OAuthEventKind\n    message: str\n    data: dict[str, Any] | None = None\n\n    def __str__(self) -> str:\n        return self.message\n\n    @property\n    def json(self) -> str:\n        payload: dict[str, Any] = {\"type\": self.type, \"message\": self.message}\n        if self.data is not None:\n            payload[\"data\"] = self.data\n        return json.dumps(payload, ensure_ascii=False)\n\n\n@dataclass(slots=True)\nclass OAuthToken:\n    access_token: str\n    refresh_token: str\n    expires_at: float\n    scope: str\n    token_type: str\n\n    @classmethod\n    def from_response(cls, payload: dict[str, Any]) -> OAuthToken:\n        expires_in = float(payload[\"expires_in\"])\n        return cls(\n            access_token=str(payload[\"access_token\"]),\n            refresh_token=str(payload[\"refresh_token\"]),\n            expires_at=time.time() + expires_in,\n            scope=str(payload[\"scope\"]),\n            token_type=str(payload[\"token_type\"]),\n        )\n\n    def to_dict(self) -> dict[str, Any]:\n        return {\n            \"access_token\": self.access_token,\n            \"refresh_token\": self.refresh_token,\n            \"expires_at\": self.expires_at,\n            \"scope\": self.scope,\n            \"token_type\": self.token_type,\n        }\n\n    @classmethod\n    def from_dict(cls, payload: dict[str, Any]) -> OAuthToken:\n        expires_at_value = payload.get(\"expires_at\")\n        return cls(\n            access_token=str(payload.get(\"access_token\") or \"\"),\n            refresh_token=str(payload.get(\"refresh_token\") or \"\"),\n            expires_at=float(expires_at_value) if expires_at_value is not None else 0.0,\n            scope=str(payload.get(\"scope\") or \"\"),\n            token_type=str(payload.get(\"token_type\") or \"\"),\n        )\n\n\n@dataclass(slots=True)\nclass DeviceAuthorization:\n    user_code: str\n    device_code: str\n    verification_uri: str\n    verification_uri_complete: str\n    expires_in: int | None\n    interval: int\n\n\ndef _oauth_host() -> str:\n    return os.getenv(\"KIMI_CODE_OAUTH_HOST\") or os.getenv(\"KIMI_OAUTH_HOST\") or DEFAULT_OAUTH_HOST\n\n\ndef _device_id_path() -> Path:\n    return get_share_dir() / \"device_id\"\n\n\ndef _ensure_private_file(path: Path) -> None:\n    with suppress(OSError):\n        os.chmod(path, 0o600)\n\n\ndef _device_model() -> str:\n    system = platform.system()\n    arch = platform.machine() or \"\"\n    if system == \"Darwin\":\n        version = platform.mac_ver()[0] or platform.release()\n        if version and arch:\n            return f\"macOS {version} {arch}\"\n        if version:\n            return f\"macOS {version}\"\n        return f\"macOS {arch}\".strip()\n    if system == \"Windows\":\n        release = platform.release()\n        if release == \"10\":\n            try:\n                build = sys.getwindowsversion().build  # type: ignore[attr-defined]\n            except Exception:\n                build = None\n            if build and build >= 22000:\n                release = \"11\"\n        if release and arch:\n            return f\"Windows {release} {arch}\"\n        if release:\n            return f\"Windows {release}\"\n        return f\"Windows {arch}\".strip()\n    if system:\n        version = platform.release()\n        if version and arch:\n            return f\"{system} {version} {arch}\"\n        if version:\n            return f\"{system} {version}\"\n        return f\"{system} {arch}\".strip()\n    return \"Unknown\"\n\n\ndef get_device_id() -> str:\n    path = _device_id_path()\n    if path.exists():\n        return path.read_text(encoding=\"utf-8\").strip()\n    device_id = uuid.uuid4().hex\n    path.write_text(device_id, encoding=\"utf-8\")\n    _ensure_private_file(path)\n    return device_id\n\n\ndef _ascii_header_value(value: str, *, fallback: str = \"unknown\") -> str:\n    try:\n        value.encode(\"ascii\")\n        return value.strip()\n    except UnicodeEncodeError:\n        sanitized = value.encode(\"ascii\", errors=\"ignore\").decode(\"ascii\").strip()\n        return sanitized or fallback\n\n\ndef _common_headers() -> dict[str, str]:\n    device_name = platform.node() or socket.gethostname()\n    device_model = _device_model()\n    headers = {\n        \"X-Msh-Platform\": \"kimi_cli\",\n        \"X-Msh-Version\": VERSION,\n        \"X-Msh-Device-Name\": device_name,\n        \"X-Msh-Device-Model\": device_model,\n        \"X-Msh-Os-Version\": platform.version(),\n        \"X-Msh-Device-Id\": get_device_id(),\n    }\n    return {key: _ascii_header_value(value) for key, value in headers.items()}\n\n\ndef _credentials_dir() -> Path:\n    path = get_share_dir() / \"credentials\"\n    path.mkdir(parents=True, exist_ok=True)\n    return path\n\n\ndef _credentials_path(key: str) -> Path:\n    name = key.removeprefix(\"oauth/\").split(\"/\")[-1] or key\n    return _credentials_dir() / f\"{name}.json\"\n\n\ndef _load_from_keyring(key: str) -> OAuthToken | None:\n    try:\n        raw = keyring.get_password(KEYRING_SERVICE, key)\n    except Exception as exc:\n        logger.warning(\"Failed to read token from keyring: {error}\", error=exc)\n        return None\n    if not raw:\n        return None\n    try:\n        payload = json.loads(raw)\n    except json.JSONDecodeError:\n        return None\n    if not isinstance(payload, dict):\n        return None\n    payload = cast(dict[str, Any], payload)\n    return OAuthToken.from_dict(payload)\n\n\ndef _delete_from_keyring(key: str) -> None:\n    try:\n        keyring.delete_password(KEYRING_SERVICE, key)\n    except Exception:\n        return\n\n\ndef _load_from_file(key: str) -> OAuthToken | None:\n    path = _credentials_path(key)\n    if not path.exists():\n        return None\n    try:\n        payload = json.loads(path.read_text(encoding=\"utf-8\"))\n    except json.JSONDecodeError:\n        return None\n    if not isinstance(payload, dict):\n        return None\n    payload = cast(dict[str, Any], payload)\n    return OAuthToken.from_dict(payload)\n\n\ndef _save_to_file(key: str, token: OAuthToken) -> None:\n    path = _credentials_path(key)\n    path.write_text(json.dumps(token.to_dict(), ensure_ascii=False), encoding=\"utf-8\")\n    _ensure_private_file(path)\n\n\ndef _delete_from_file(key: str) -> None:\n    path = _credentials_path(key)\n    if path.exists():\n        path.unlink()\n\n\ndef load_tokens(ref: OAuthRef) -> OAuthToken | None:\n    file_token = _load_from_file(ref.key)\n    if file_token is not None:\n        return file_token\n    if ref.storage != \"keyring\":\n        return None\n    token = _load_from_keyring(ref.key)\n    if token is None:\n        return None\n    try:\n        _save_to_file(ref.key, token)\n    except OSError as exc:\n        logger.warning(\"Failed to migrate token from keyring to file: {error}\", error=exc)\n    else:\n        with suppress(Exception):\n            _delete_from_keyring(ref.key)\n    return token\n\n\ndef save_tokens(ref: OAuthRef, token: OAuthToken) -> OAuthRef:\n    if ref.storage == \"keyring\":\n        logger.warning(\"Keyring storage is deprecated; saving OAuth tokens to file.\")\n        ref = OAuthRef(storage=\"file\", key=ref.key)\n    _save_to_file(ref.key, token)\n    return ref\n\n\ndef delete_tokens(ref: OAuthRef) -> None:\n    if ref.storage == \"keyring\":\n        _delete_from_keyring(ref.key)\n    _delete_from_file(ref.key)\n\n\nasync def request_device_authorization() -> DeviceAuthorization:\n    async with (\n        new_client_session() as session,\n        session.post(\n            f\"{_oauth_host().rstrip('/')}/api/oauth/device_authorization\",\n            data={\"client_id\": KIMI_CODE_CLIENT_ID},\n            headers=_common_headers(),\n        ) as response,\n    ):\n        data = await response.json(content_type=None)\n        status = response.status\n    if status != 200:\n        raise OAuthError(f\"Device authorization failed: {data}\")\n    return DeviceAuthorization(\n        user_code=str(data[\"user_code\"]),\n        device_code=str(data[\"device_code\"]),\n        verification_uri=str(data.get(\"verification_uri\") or \"\"),\n        verification_uri_complete=str(data[\"verification_uri_complete\"]),\n        expires_in=int(data.get(\"expires_in\") or 0) or None,\n        interval=int(data.get(\"interval\") or 5),\n    )\n\n\nasync def _request_device_token(auth: DeviceAuthorization) -> tuple[int, dict[str, Any]]:\n    try:\n        async with (\n            new_client_session() as session,\n            session.post(\n                f\"{_oauth_host().rstrip('/')}/api/oauth/token\",\n                data={\n                    \"client_id\": KIMI_CODE_CLIENT_ID,\n                    \"device_code\": auth.device_code,\n                    \"grant_type\": \"urn:ietf:params:oauth:grant-type:device_code\",\n                },\n                headers=_common_headers(),\n            ) as response,\n        ):\n            data_any: Any = await response.json(content_type=None)\n            status = response.status\n    except aiohttp.ClientError as exc:\n        raise OAuthError(\"Token polling request failed.\") from exc\n    if not isinstance(data_any, dict):\n        raise OAuthError(\"Unexpected token polling response.\")\n    data = cast(dict[str, Any], data_any)\n    if status >= 500:\n        raise OAuthError(f\"Token polling server error: {status}.\")\n    return status, data\n\n\nasync def refresh_token(refresh_token: str) -> OAuthToken:\n    async with (\n        new_client_session() as session,\n        session.post(\n            f\"{_oauth_host().rstrip('/')}/api/oauth/token\",\n            data={\n                \"client_id\": KIMI_CODE_CLIENT_ID,\n                \"grant_type\": \"refresh_token\",\n                \"refresh_token\": refresh_token,\n            },\n            headers=_common_headers(),\n        ) as response,\n    ):\n        data = await response.json(content_type=None)\n        status = response.status\n    if status in (401, 403):\n        raise OAuthUnauthorized(data.get(\"error_description\") or \"Token refresh unauthorized.\")\n    if status != 200:\n        raise OAuthError(data.get(\"error_description\") or \"Token refresh failed.\")\n    return OAuthToken.from_response(data)\n\n\ndef _select_default_model_and_thinking(models: list[ModelInfo]) -> tuple[ModelInfo, bool] | None:\n    if not models:\n        return None\n    selected_model = models[0]\n    capabilities = selected_model.capabilities\n    thinking = \"thinking\" in capabilities or \"always_thinking\" in capabilities\n    return selected_model, thinking\n\n\ndef _apply_kimi_code_config(\n    config: Config,\n    *,\n    models: list[ModelInfo],\n    selected_model: ModelInfo,\n    thinking: bool,\n    oauth_ref: OAuthRef,\n) -> None:\n    platform = get_platform_by_id(KIMI_CODE_PLATFORM_ID)\n    if platform is None:\n        raise OAuthError(\"Kimi Code platform not found.\")\n\n    provider_key = managed_provider_key(platform.id)\n    config.providers[provider_key] = LLMProvider(\n        type=\"kimi\",\n        base_url=platform.base_url,\n        api_key=SecretStr(\"\"),\n        oauth=oauth_ref,\n    )\n\n    for key, model in list(config.models.items()):\n        if model.provider == provider_key:\n            del config.models[key]\n\n    for model_info in models:\n        capabilities = model_info.capabilities or None\n        config.models[managed_model_key(platform.id, model_info.id)] = LLMModel(\n            provider=provider_key,\n            model=model_info.id,\n            max_context_size=model_info.context_length,\n            capabilities=capabilities,\n        )\n\n    config.default_model = managed_model_key(platform.id, selected_model.id)\n    config.default_thinking = thinking\n\n    if platform.search_url:\n        config.services.moonshot_search = MoonshotSearchConfig(\n            base_url=platform.search_url,\n            api_key=SecretStr(\"\"),\n            oauth=oauth_ref,\n        )\n\n    if platform.fetch_url:\n        config.services.moonshot_fetch = MoonshotFetchConfig(\n            base_url=platform.fetch_url,\n            api_key=SecretStr(\"\"),\n            oauth=oauth_ref,\n        )\n\n\nasync def login_kimi_code(\n    config: Config, *, open_browser: bool = True\n) -> AsyncIterator[OAuthEvent]:\n    if not config.is_from_default_location:\n        yield OAuthEvent(\n            \"error\",\n            \"Login requires the default config file; restart without --config/--config-file.\",\n        )\n        return\n\n    platform = get_platform_by_id(KIMI_CODE_PLATFORM_ID)\n    if platform is None:\n        yield OAuthEvent(\"error\", \"Kimi Code platform is unavailable.\")\n        return\n\n    auth: DeviceAuthorization\n    token: OAuthToken | None = None\n    while True:\n        try:\n            auth = await request_device_authorization()\n        except Exception as exc:\n            yield OAuthEvent(\"error\", f\"Login failed: {exc}\")\n            return\n\n        yield OAuthEvent(\n            \"info\",\n            \"Please visit the following URL to finish authorization.\",\n        )\n        yield OAuthEvent(\n            \"verification_url\",\n            f\"Verification URL: {auth.verification_uri_complete}\",\n            data={\n                \"verification_url\": auth.verification_uri_complete,\n                \"user_code\": auth.user_code,\n            },\n        )\n        if open_browser:\n            try:\n                webbrowser.open(auth.verification_uri_complete)\n            except Exception as exc:\n                logger.warning(\"Failed to open browser: {error}\", error=exc)\n\n        interval = max(auth.interval, 1)\n        printed_wait = False\n        try:\n            while True:\n                status, data = await _request_device_token(auth)\n                if status == 200 and \"access_token\" in data:\n                    token = OAuthToken.from_response(data)\n                    break\n                error_code = str(data.get(\"error\") or \"unknown_error\")\n                if error_code == \"expired_token\":\n                    raise OAuthDeviceExpired(\"Device code expired.\")\n                error_description = str(data.get(\"error_description\") or \"\")\n                if not printed_wait:\n                    yield OAuthEvent(\n                        \"waiting\",\n                        f\"Waiting for user authorization...: {error_description.strip()}\",\n                        data={\n                            \"error\": error_code,\n                            \"error_description\": error_description,\n                        },\n                    )\n                    printed_wait = True\n                await asyncio.sleep(interval)\n        except OAuthDeviceExpired:\n            yield OAuthEvent(\"info\", \"Device code expired, restarting login...\")\n            continue\n        except Exception as exc:\n            yield OAuthEvent(\"error\", f\"Login failed: {exc}\")\n            return\n        break\n\n    assert token is not None\n\n    oauth_ref = OAuthRef(storage=\"file\", key=KIMI_CODE_OAUTH_KEY)\n    oauth_ref = save_tokens(oauth_ref, token)\n\n    try:\n        models = await list_models(platform, token.access_token)\n    except Exception as exc:\n        logger.error(\"Failed to get models: {error}\", error=exc)\n        yield OAuthEvent(\"error\", f\"Failed to get models: {exc}\")\n        return\n\n    if not models:\n        yield OAuthEvent(\"error\", \"No models available for the selected platform.\")\n        return\n\n    selection = _select_default_model_and_thinking(models)\n    if selection is None:\n        return\n    selected_model, thinking = selection\n\n    _apply_kimi_code_config(\n        config,\n        models=models,\n        selected_model=selected_model,\n        thinking=thinking,\n        oauth_ref=oauth_ref,\n    )\n    save_config(config)\n    yield OAuthEvent(\"success\", \"Logged in successfully.\")\n    return\n\n\nasync def logout_kimi_code(config: Config) -> AsyncIterator[OAuthEvent]:\n    if not config.is_from_default_location:\n        yield OAuthEvent(\n            \"error\",\n            \"Logout requires the default config file; restart without --config/--config-file.\",\n        )\n        return\n\n    delete_tokens(OAuthRef(storage=\"keyring\", key=KIMI_CODE_OAUTH_KEY))\n    delete_tokens(OAuthRef(storage=\"file\", key=KIMI_CODE_OAUTH_KEY))\n\n    provider_key = managed_provider_key(KIMI_CODE_PLATFORM_ID)\n    if provider_key in config.providers:\n        del config.providers[provider_key]\n\n    removed_default = False\n    for key, model in list(config.models.items()):\n        if model.provider != provider_key:\n            continue\n        del config.models[key]\n        if config.default_model == key:\n            removed_default = True\n\n    if removed_default:\n        config.default_model = \"\"\n\n    config.services.moonshot_search = None\n    config.services.moonshot_fetch = None\n\n    save_config(config)\n    yield OAuthEvent(\"success\", \"Logged out successfully.\")\n    return\n\n\nclass OAuthManager:\n    def __init__(self, config: Config) -> None:\n        self._config = config\n        # Cache access tokens only; refresh tokens are always read from persisted storage.\n        self._access_tokens: dict[str, str] = {}\n        self._refresh_lock = asyncio.Lock()\n        self._migrate_oauth_storage()\n        self._load_initial_tokens()\n\n    def _iter_oauth_refs(self) -> list[OAuthRef]:\n        refs: list[OAuthRef] = []\n        for provider in self._config.providers.values():\n            if provider.oauth:\n                refs.append(provider.oauth)\n        for service in (\n            self._config.services.moonshot_search,\n            self._config.services.moonshot_fetch,\n        ):\n            if service and service.oauth:\n                refs.append(service.oauth)\n        return refs\n\n    def _migrate_oauth_storage(self) -> None:\n        migrated_keys: set[str] = set()\n        changed = False\n\n        def _migrate_ref(ref: OAuthRef) -> OAuthRef:\n            nonlocal changed\n            if ref.storage != \"keyring\":\n                return ref\n            if ref.key not in migrated_keys:\n                load_tokens(ref)\n                migrated_keys.add(ref.key)\n            changed = True\n            return OAuthRef(storage=\"file\", key=ref.key)\n\n        for provider in self._config.providers.values():\n            if provider.oauth:\n                provider.oauth = _migrate_ref(provider.oauth)\n\n        for service in (\n            self._config.services.moonshot_search,\n            self._config.services.moonshot_fetch,\n        ):\n            if service and service.oauth:\n                service.oauth = _migrate_ref(service.oauth)\n\n        if changed and self._config.is_from_default_location:\n            save_config(self._config)\n\n    def _load_initial_tokens(self) -> None:\n        for ref in self._iter_oauth_refs():\n            token = load_tokens(ref)\n            if token:\n                self._cache_access_token(ref, token)\n\n    def _cache_access_token(self, ref: OAuthRef, token: OAuthToken) -> None:\n        if not token.access_token:\n            self._access_tokens.pop(ref.key, None)\n            return\n        self._access_tokens[ref.key] = token.access_token\n\n    def common_headers(self) -> dict[str, str]:\n        return _common_headers()\n\n    def resolve_api_key(self, api_key: SecretStr, oauth: OAuthRef | None) -> str:\n        if oauth:\n            token = self._access_tokens.get(oauth.key)\n            if token is None:\n                persisted = load_tokens(oauth)\n                if persisted:\n                    self._cache_access_token(oauth, persisted)\n                    token = self._access_tokens.get(oauth.key)\n            if token:\n                return token\n        return api_key.get_secret_value()\n\n    def _kimi_code_ref(self) -> OAuthRef | None:\n        provider_key = managed_provider_key(KIMI_CODE_PLATFORM_ID)\n        provider = self._config.providers.get(provider_key)\n        if provider and provider.oauth:\n            return provider.oauth\n        for service in (\n            self._config.services.moonshot_search,\n            self._config.services.moonshot_fetch,\n        ):\n            if service and service.oauth and service.oauth.key == KIMI_CODE_OAUTH_KEY:\n                return service.oauth\n        return None\n\n    async def ensure_fresh(self, runtime: Runtime) -> None:\n        ref = self._kimi_code_ref()\n        if ref is None:\n            return\n        token = load_tokens(ref)\n        if token is None:\n            return\n        self._cache_access_token(ref, token)\n        self._apply_access_token(runtime, token.access_token)\n        await self._refresh_tokens(ref, token, runtime)\n\n    @asynccontextmanager\n    async def refreshing(self, runtime: Runtime) -> AsyncIterator[None]:\n        stop_event = asyncio.Event()\n\n        async def _runner() -> None:\n            try:\n                while True:\n                    try:\n                        await asyncio.wait_for(\n                            stop_event.wait(),\n                            timeout=REFRESH_INTERVAL_SECONDS,\n                        )\n                        return\n                    except TimeoutError:\n                        pass\n                    try:\n                        await self.ensure_fresh(runtime)\n                    except Exception as exc:\n                        logger.warning(\n                            \"Failed to refresh OAuth token in background: {error}\",\n                            error=exc,\n                        )\n            except asyncio.CancelledError:\n                pass\n\n        await self.ensure_fresh(runtime)\n        refresh_task = asyncio.create_task(_runner())\n        try:\n            yield\n        finally:\n            stop_event.set()\n            refresh_task.cancel()\n            with suppress(asyncio.CancelledError):\n                await refresh_task\n\n    async def _refresh_tokens(\n        self,\n        ref: OAuthRef,\n        token: OAuthToken,\n        runtime: Runtime,\n    ) -> None:\n        # Always prefer persisted tokens before refresh to avoid stale cache\n        # when multiple sessions might have already rotated the refresh token.\n        persisted = load_tokens(ref)\n        if persisted:\n            self._cache_access_token(ref, persisted)\n        current_token = persisted or token\n        if not current_token.refresh_token:\n            return\n        async with self._refresh_lock:\n            # Re-check persisted token inside the lock to reduce races.\n            persisted = load_tokens(ref)\n            if persisted:\n                self._cache_access_token(ref, persisted)\n            current = persisted or current_token\n            now = time.time()\n            if (\n                current.expires_at\n                and current.expires_at > now\n                and current.expires_at - now >= REFRESH_THRESHOLD_SECONDS\n            ):\n                return\n            refresh_token_value = current.refresh_token\n            if not refresh_token_value:\n                return\n            try:\n                refreshed = await refresh_token(refresh_token_value)\n            except OAuthUnauthorized as exc:\n                # If another session refreshed and persisted a new token,\n                # do not delete it. Just sync memory and exit.\n                latest = load_tokens(ref)\n                if latest and latest.refresh_token != refresh_token_value:\n                    self._cache_access_token(ref, latest)\n                    self._apply_access_token(runtime, latest.access_token)\n                    return\n                logger.warning(\n                    \"OAuth credentials rejected, deleting stored tokens: {error}\",\n                    error=exc,\n                )\n                self._access_tokens.pop(ref.key, None)\n                delete_tokens(ref)\n                self._apply_access_token(runtime, \"\")\n                return\n            except Exception as exc:\n                logger.warning(\"Failed to refresh OAuth token: {error}\", error=exc)\n                return\n            save_tokens(ref, refreshed)\n            self._cache_access_token(ref, refreshed)\n            self._apply_access_token(runtime, refreshed.access_token)\n\n    def _apply_access_token(self, runtime: Runtime, access_token: str) -> None:\n        provider_key = managed_provider_key(KIMI_CODE_PLATFORM_ID)\n        if runtime.llm is None or runtime.llm.model_config is None:\n            return\n        if runtime.llm.model_config.provider != provider_key:\n            return\n        from kosong.chat_provider.kimi import Kimi\n\n        assert isinstance(runtime.llm.chat_provider, Kimi), \"Expected Kimi chat provider\"\n        runtime.llm.chat_provider.client.api_key = access_token\n\n\nif __name__ == \"__main__\":\n    from rich import print\n\n    print(_common_headers())\n"
  },
  {
    "path": "src/kimi_cli/auth/platforms.py",
    "content": "from __future__ import annotations\n\nimport os\nfrom typing import Any, NamedTuple, cast\n\nimport aiohttp\nfrom pydantic import BaseModel\n\nfrom kimi_cli.auth import KIMI_CODE_PLATFORM_ID\nfrom kimi_cli.config import Config, LLMModel, load_config, save_config\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.utils.aiohttp import new_client_session\nfrom kimi_cli.utils.logging import logger\n\n\nclass ModelInfo(BaseModel):\n    \"\"\"Model information returned from the API.\"\"\"\n\n    id: str\n    context_length: int\n    supports_reasoning: bool\n    supports_image_in: bool\n    supports_video_in: bool\n\n    @property\n    def capabilities(self) -> set[ModelCapability]:\n        \"\"\"Derive capabilities from model info.\"\"\"\n        caps: set[ModelCapability] = set()\n        if self.supports_reasoning:\n            caps.add(\"thinking\")\n        # Models with \"thinking\" in name are always-thinking\n        if \"thinking\" in self.id.lower():\n            caps.update((\"thinking\", \"always_thinking\"))\n        if self.supports_image_in:\n            caps.add(\"image_in\")\n        if self.supports_video_in:\n            caps.add(\"video_in\")\n        if \"kimi-k2.5\" in self.id.lower():\n            caps.update((\"thinking\", \"image_in\", \"video_in\"))\n        return caps\n\n\nclass Platform(NamedTuple):\n    id: str\n    name: str\n    base_url: str\n    search_url: str | None = None\n    fetch_url: str | None = None\n    allowed_prefixes: list[str] | None = None\n\n\ndef _kimi_code_base_url() -> str:\n    if base_url := os.getenv(\"KIMI_CODE_BASE_URL\"):\n        return base_url\n    return \"https://api.kimi.com/coding/v1\"\n\n\nPLATFORMS: list[Platform] = [\n    Platform(\n        id=KIMI_CODE_PLATFORM_ID,\n        name=\"Kimi Code\",\n        base_url=_kimi_code_base_url(),\n        search_url=f\"{_kimi_code_base_url()}/search\",\n        fetch_url=f\"{_kimi_code_base_url()}/fetch\",\n    ),\n    Platform(\n        id=\"moonshot-cn\",\n        name=\"Moonshot AI Open Platform (moonshot.cn)\",\n        base_url=\"https://api.moonshot.cn/v1\",\n        allowed_prefixes=[\"kimi-k\"],\n    ),\n    Platform(\n        id=\"moonshot-ai\",\n        name=\"Moonshot AI Open Platform (moonshot.ai)\",\n        base_url=\"https://api.moonshot.ai/v1\",\n        allowed_prefixes=[\"kimi-k\"],\n    ),\n]\n\n_PLATFORM_BY_ID = {platform.id: platform for platform in PLATFORMS}\n_PLATFORM_BY_NAME = {platform.name: platform for platform in PLATFORMS}\n\n\ndef get_platform_by_id(platform_id: str) -> Platform | None:\n    return _PLATFORM_BY_ID.get(platform_id)\n\n\ndef get_platform_by_name(name: str) -> Platform | None:\n    return _PLATFORM_BY_NAME.get(name)\n\n\nMANAGED_PROVIDER_PREFIX = \"managed:\"\n\n\ndef managed_provider_key(platform_id: str) -> str:\n    return f\"{MANAGED_PROVIDER_PREFIX}{platform_id}\"\n\n\ndef managed_model_key(platform_id: str, model_id: str) -> str:\n    return f\"{platform_id}/{model_id}\"\n\n\ndef parse_managed_provider_key(provider_key: str) -> str | None:\n    if not provider_key.startswith(MANAGED_PROVIDER_PREFIX):\n        return None\n    return provider_key.removeprefix(MANAGED_PROVIDER_PREFIX)\n\n\ndef is_managed_provider_key(provider_key: str) -> bool:\n    return provider_key.startswith(MANAGED_PROVIDER_PREFIX)\n\n\ndef get_platform_name_for_provider(provider_key: str) -> str | None:\n    platform_id = parse_managed_provider_key(provider_key)\n    if not platform_id:\n        return None\n    platform = get_platform_by_id(platform_id)\n    return platform.name if platform else None\n\n\nasync def refresh_managed_models(config: Config) -> bool:\n    if not config.is_from_default_location:\n        return False\n\n    managed_providers = {\n        key: provider for key, provider in config.providers.items() if is_managed_provider_key(key)\n    }\n    if not managed_providers:\n        return False\n\n    changed = False\n    updates: list[tuple[str, str, list[ModelInfo]]] = []\n    for provider_key, provider in managed_providers.items():\n        platform_id = parse_managed_provider_key(provider_key)\n        if not platform_id:\n            continue\n        platform = get_platform_by_id(platform_id)\n        if platform is None:\n            logger.warning(\"Managed platform not found: {platform}\", platform=platform_id)\n            continue\n\n        api_key = provider.api_key.get_secret_value()\n        if not api_key and provider.oauth:\n            from kimi_cli.auth.oauth import load_tokens\n\n            token = load_tokens(provider.oauth)\n            if token:\n                api_key = token.access_token\n        if not api_key:\n            logger.warning(\n                \"Missing API key for managed provider: {provider}\",\n                provider=provider_key,\n            )\n            continue\n        try:\n            models = await list_models(platform, api_key)\n        except Exception as exc:\n            logger.error(\n                \"Failed to refresh models for {platform}: {error}\",\n                platform=platform_id,\n                error=exc,\n            )\n            continue\n\n        updates.append((provider_key, platform_id, models))\n        if _apply_models(config, provider_key, platform_id, models):\n            changed = True\n\n    if changed:\n        config_for_save = load_config()\n        save_changed = False\n        for provider_key, platform_id, models in updates:\n            if _apply_models(config_for_save, provider_key, platform_id, models):\n                save_changed = True\n        if save_changed:\n            save_config(config_for_save)\n    return changed\n\n\nasync def list_models(platform: Platform, api_key: str) -> list[ModelInfo]:\n    async with new_client_session() as session:\n        models = await _list_models(\n            session,\n            base_url=platform.base_url,\n            api_key=api_key,\n        )\n    if platform.allowed_prefixes is None:\n        return models\n    prefixes = tuple(platform.allowed_prefixes)\n    return [model for model in models if model.id.startswith(prefixes)]\n\n\nasync def _list_models(\n    session: aiohttp.ClientSession,\n    *,\n    base_url: str,\n    api_key: str,\n) -> list[ModelInfo]:\n    models_url = f\"{base_url.rstrip('/')}/models\"\n    try:\n        async with session.get(\n            models_url,\n            headers={\"Authorization\": f\"Bearer {api_key}\"},\n            raise_for_status=True,\n        ) as response:\n            resp_json = await response.json()\n    except aiohttp.ClientError:\n        raise\n\n    data = resp_json.get(\"data\")\n    if not isinstance(data, list):\n        raise ValueError(f\"Unexpected models response for {base_url}\")\n\n    result: list[ModelInfo] = []\n    for item in cast(list[dict[str, Any]], data):\n        model_id = item.get(\"id\")\n        if not model_id:\n            continue\n        result.append(\n            ModelInfo(\n                id=str(model_id),\n                context_length=int(item.get(\"context_length\") or 0),\n                supports_reasoning=bool(item.get(\"supports_reasoning\")),\n                supports_image_in=bool(item.get(\"supports_image_in\")),\n                supports_video_in=bool(item.get(\"supports_video_in\")),\n            )\n        )\n    return result\n\n\ndef _apply_models(\n    config: Config,\n    provider_key: str,\n    platform_id: str,\n    models: list[ModelInfo],\n) -> bool:\n    changed = False\n    model_keys: list[str] = []\n\n    for model in models:\n        model_key = managed_model_key(platform_id, model.id)\n        model_keys.append(model_key)\n\n        existing = config.models.get(model_key)\n        capabilities = model.capabilities or None  # empty set -> None\n\n        if existing is None:\n            config.models[model_key] = LLMModel(\n                provider=provider_key,\n                model=model.id,\n                max_context_size=model.context_length,\n                capabilities=capabilities,\n            )\n            changed = True\n            continue\n\n        if existing.provider != provider_key:\n            existing.provider = provider_key\n            changed = True\n        if existing.model != model.id:\n            existing.model = model.id\n            changed = True\n        if existing.max_context_size != model.context_length:\n            existing.max_context_size = model.context_length\n            changed = True\n        if existing.capabilities != capabilities:\n            existing.capabilities = capabilities\n            changed = True\n\n    removed_default = False\n    model_keys_set = set(model_keys)\n    for key, model in list(config.models.items()):\n        if model.provider != provider_key:\n            continue\n        if key in model_keys_set:\n            continue\n        del config.models[key]\n        if config.default_model == key:\n            removed_default = True\n        changed = True\n\n    if removed_default:\n        if model_keys:\n            config.default_model = model_keys[0]\n        else:\n            config.default_model = next(iter(config.models), \"\")\n        changed = True\n\n    if config.default_model and config.default_model not in config.models:\n        config.default_model = next(iter(config.models), \"\")\n        changed = True\n\n    return changed\n"
  },
  {
    "path": "src/kimi_cli/background/__init__.py",
    "content": "from .ids import generate_task_id\nfrom .manager import BackgroundTaskManager\nfrom .models import (\n    TaskConsumerState,\n    TaskControl,\n    TaskKind,\n    TaskOutputChunk,\n    TaskRuntime,\n    TaskSpec,\n    TaskStatus,\n    TaskView,\n    is_terminal_status,\n)\nfrom .store import BackgroundTaskStore\nfrom .summary import build_active_task_snapshot, format_task, format_task_list, list_task_views\nfrom .worker import run_background_task_worker\n\n__all__ = [\n    \"BackgroundTaskManager\",\n    \"BackgroundTaskStore\",\n    \"TaskConsumerState\",\n    \"TaskControl\",\n    \"TaskKind\",\n    \"TaskOutputChunk\",\n    \"TaskRuntime\",\n    \"TaskSpec\",\n    \"TaskStatus\",\n    \"TaskView\",\n    \"build_active_task_snapshot\",\n    \"format_task\",\n    \"format_task_list\",\n    \"generate_task_id\",\n    \"is_terminal_status\",\n    \"list_task_views\",\n    \"run_background_task_worker\",\n]\n"
  },
  {
    "path": "src/kimi_cli/background/ids.py",
    "content": "from __future__ import annotations\n\nimport secrets\n\nfrom .models import TaskKind\n\n_TASK_ID_PREFIXES: dict[TaskKind, str] = {\n    \"bash\": \"b\",\n    \"agent\": \"a\",\n}\n_ALPHABET = \"0123456789abcdefghijklmnopqrstuvwxyz\"\n\n\ndef generate_task_id(kind: TaskKind) -> str:\n    prefix = _TASK_ID_PREFIXES[kind]\n    suffix = \"\".join(secrets.choice(_ALPHABET) for _ in range(8))\n    return f\"{prefix}{suffix}\"\n"
  },
  {
    "path": "src/kimi_cli/background/manager.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nimport signal\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\nfrom typing import Any\n\nfrom kaos.local import local_kaos\n\nfrom kimi_cli.config import BackgroundConfig\nfrom kimi_cli.notifications import NotificationEvent, NotificationManager\nfrom kimi_cli.session import Session\nfrom kimi_cli.utils.logging import logger\n\nfrom .ids import generate_task_id\nfrom .models import (\n    TaskOutputChunk,\n    TaskRuntime,\n    TaskSpec,\n    TaskStatus,\n    TaskView,\n    is_terminal_status,\n)\nfrom .store import BackgroundTaskStore\n\n\nclass BackgroundTaskManager:\n    def __init__(\n        self,\n        session: Session,\n        config: BackgroundConfig,\n        *,\n        notifications: NotificationManager,\n        owner_role: str = \"root\",\n    ) -> None:\n        self._session = session\n        self._config = config\n        self._notifications = notifications\n        self._owner_role = owner_role\n        self._store = BackgroundTaskStore(session.context_file.parent / \"tasks\")\n\n    @property\n    def store(self) -> BackgroundTaskStore:\n        return self._store\n\n    @property\n    def role(self) -> str:\n        return self._owner_role\n\n    def copy_for_role(self, role: str) -> BackgroundTaskManager:\n        return BackgroundTaskManager(\n            self._session,\n            self._config,\n            notifications=self._notifications,\n            owner_role=role,\n        )\n\n    def _ensure_root(self) -> None:\n        if self._owner_role != \"root\":\n            raise RuntimeError(\"Background tasks are only supported from the root agent.\")\n\n    def _ensure_local_backend(self) -> None:\n        if self._session.work_dir_meta.kaos != local_kaos.name:\n            raise RuntimeError(\"Background tasks are only supported on local sessions.\")\n\n    def _active_task_count(self) -> int:\n        return sum(\n            1 for view in self._store.list_views() if not is_terminal_status(view.runtime.status)\n        )\n\n    def _worker_command(self, task_dir: Path) -> list[str]:\n        if getattr(sys, \"frozen\", False):\n            return [\n                sys.executable,\n                \"__background-task-worker\",\n                \"--task-dir\",\n                str(task_dir),\n                \"--heartbeat-interval-ms\",\n                str(self._config.worker_heartbeat_interval_ms),\n                \"--control-poll-interval-ms\",\n                str(self._config.wait_poll_interval_ms),\n                \"--kill-grace-period-ms\",\n                str(self._config.kill_grace_period_ms),\n            ]\n        return [\n            sys.executable,\n            \"-m\",\n            \"kimi_cli.cli\",\n            \"__background-task-worker\",\n            \"--task-dir\",\n            str(task_dir),\n            \"--heartbeat-interval-ms\",\n            str(self._config.worker_heartbeat_interval_ms),\n            \"--control-poll-interval-ms\",\n            str(self._config.wait_poll_interval_ms),\n            \"--kill-grace-period-ms\",\n            str(self._config.kill_grace_period_ms),\n        ]\n\n    def _launch_worker(self, task_dir: Path) -> int:\n        kwargs: dict[str, Any] = {\n            \"stdin\": subprocess.DEVNULL,\n            \"stdout\": subprocess.DEVNULL,\n            \"stderr\": subprocess.DEVNULL,\n            \"cwd\": str(task_dir),\n        }\n        if os.name == \"nt\":\n            kwargs[\"creationflags\"] = getattr(subprocess, \"CREATE_NEW_PROCESS_GROUP\", 0)\n        else:\n            kwargs[\"start_new_session\"] = True\n\n        process = subprocess.Popen(self._worker_command(task_dir), **kwargs)\n        return process.pid\n\n    def create_bash_task(\n        self,\n        *,\n        command: str,\n        description: str,\n        timeout_s: int,\n        tool_call_id: str,\n        shell_name: str,\n        shell_path: str,\n        cwd: str,\n    ) -> TaskView:\n        self._ensure_root()\n        self._ensure_local_backend()\n\n        if self._active_task_count() >= self._config.max_running_tasks:\n            raise RuntimeError(\"Too many background tasks are already running.\")\n\n        task_id = generate_task_id(\"bash\")\n        spec = TaskSpec(\n            id=task_id,\n            kind=\"bash\",\n            session_id=self._session.id,\n            description=description,\n            tool_call_id=tool_call_id,\n            owner_role=\"root\",\n            command=command,\n            shell_name=shell_name,\n            shell_path=shell_path,\n            cwd=cwd,\n            timeout_s=timeout_s,\n        )\n        self._store.create_task(spec)\n\n        runtime = self._store.read_runtime(task_id)\n        task_dir = self._store.task_dir(task_id)\n        try:\n            worker_pid = self._launch_worker(task_dir)\n        except Exception as exc:\n            runtime.status = \"failed\"\n            runtime.failure_reason = f\"Failed to launch worker: {exc}\"\n            runtime.finished_at = time.time()\n            runtime.updated_at = runtime.finished_at\n            self._store.write_runtime(task_id, runtime)\n            raise\n\n        runtime = self._store.read_runtime(task_id)\n        if runtime.finished_at is None and (\n            runtime.status == \"created\"\n            or (runtime.status == \"starting\" and runtime.worker_pid is None)\n        ):\n            runtime.status = \"starting\"\n            runtime.worker_pid = worker_pid\n            runtime.updated_at = time.time()\n            self._store.write_runtime(task_id, runtime)\n        return self._store.merged_view(task_id)\n\n    def list_tasks(\n        self,\n        *,\n        status: TaskStatus | None = None,\n        limit: int | None = 20,\n    ) -> list[TaskView]:\n        tasks = self._store.list_views()\n        if status is not None:\n            tasks = [task for task in tasks if task.runtime.status == status]\n        if limit is None:\n            return tasks\n        return tasks[:limit]\n\n    def get_task(self, task_id: str) -> TaskView | None:\n        try:\n            return self._store.merged_view(task_id)\n        except (FileNotFoundError, ValueError):\n            return None\n\n    def read_output(\n        self,\n        task_id: str,\n        *,\n        offset: int = 0,\n        max_bytes: int | None = None,\n    ) -> TaskOutputChunk:\n        view = self._store.merged_view(task_id)\n        return self._store.read_output(\n            task_id,\n            offset,\n            max_bytes or self._config.read_max_bytes,\n            status=view.runtime.status,\n        )\n\n    def tail_output(\n        self,\n        task_id: str,\n        *,\n        max_bytes: int | None = None,\n        max_lines: int | None = None,\n    ) -> str:\n        self._store.merged_view(task_id)\n        return self._store.tail_output(\n            task_id,\n            max_bytes=max_bytes or self._config.read_max_bytes,\n            max_lines=max_lines or self._config.notification_tail_lines,\n        )\n\n    async def wait(self, task_id: str, *, timeout_s: int = 30) -> TaskView:\n        end_time = time.monotonic() + timeout_s\n        while True:\n            view = self._store.merged_view(task_id)\n            if is_terminal_status(view.runtime.status):\n                return view\n            if time.monotonic() >= end_time:\n                return view\n            await asyncio.sleep(self._config.wait_poll_interval_ms / 1000)\n\n    def _best_effort_kill(self, runtime: TaskRuntime) -> None:\n        try:\n            if os.name == \"nt\":\n                pid = runtime.child_pid or runtime.worker_pid\n                if pid is None:\n                    return\n                subprocess.run(\n                    [\"taskkill\", \"/PID\", str(pid), \"/T\", \"/F\"],\n                    stdout=subprocess.DEVNULL,\n                    stderr=subprocess.DEVNULL,\n                    check=False,\n                )\n                return\n\n            if runtime.child_pgid is not None:\n                os.killpg(runtime.child_pgid, signal.SIGTERM)\n                return\n            if runtime.child_pid is not None:\n                os.kill(runtime.child_pid, signal.SIGTERM)\n        except ProcessLookupError:\n            pass\n        except Exception:\n            logger.exception(\"Failed to send best-effort kill signal\")\n\n    def kill(self, task_id: str, *, reason: str = \"Killed by user\") -> TaskView:\n        self._ensure_root()\n        view = self._store.merged_view(task_id)\n        if is_terminal_status(view.runtime.status):\n            return view\n\n        control = view.control.model_copy(\n            update={\n                \"kill_requested_at\": time.time(),\n                \"kill_reason\": reason,\n                \"force\": False,\n            }\n        )\n        self._store.write_control(task_id, control)\n        self._best_effort_kill(view.runtime)\n        return self._store.merged_view(task_id)\n\n    def kill_all_active(self, *, reason: str = \"CLI session ended\") -> list[str]:\n        \"\"\"Kill all non-terminal background tasks. Used during CLI shutdown.\"\"\"\n        killed: list[str] = []\n        for view in self._store.list_views():\n            if is_terminal_status(view.runtime.status):\n                continue\n            try:\n                self.kill(view.spec.id, reason=reason)\n                killed.append(view.spec.id)\n            except Exception:\n                logger.exception(\n                    \"Failed to kill task {task_id} during shutdown\",\n                    task_id=view.spec.id,\n                )\n        return killed\n\n    def recover(self) -> None:\n        now = time.time()\n        stale_after = self._config.worker_stale_after_ms / 1000\n        for view in self._store.list_views():\n            if is_terminal_status(view.runtime.status):\n                continue\n            last_progress_at = (\n                view.runtime.heartbeat_at\n                or view.runtime.started_at\n                or view.runtime.updated_at\n                or view.spec.created_at\n            )\n            if now - last_progress_at <= stale_after:\n                continue\n\n            # Re-read runtime to narrow the race window with the worker process.\n            fresh_runtime = self._store.read_runtime(view.spec.id)\n            if is_terminal_status(fresh_runtime.status):\n                continue\n            fresh_progress = (\n                fresh_runtime.heartbeat_at\n                or fresh_runtime.started_at\n                or fresh_runtime.updated_at\n                or view.spec.created_at\n            )\n            if now - fresh_progress <= stale_after:\n                continue\n\n            runtime = fresh_runtime.model_copy()\n            runtime.finished_at = now\n            runtime.updated_at = now\n            if view.control.kill_requested_at is not None:\n                runtime.status = \"killed\"\n                runtime.interrupted = True\n                runtime.failure_reason = view.control.kill_reason or \"Killed during recovery\"\n            else:\n                runtime.status = \"lost\"\n                runtime.failure_reason = (\n                    \"Background worker never heartbeat after startup\"\n                    if fresh_runtime.heartbeat_at is None\n                    else \"Background worker heartbeat expired\"\n                )\n            self._store.write_runtime(view.spec.id, runtime)\n\n    def reconcile(self, *, limit: int | None = None) -> list[str]:\n        self.recover()\n        return self.publish_terminal_notifications(limit=limit)\n\n    def publish_terminal_notifications(self, *, limit: int | None = None) -> list[str]:\n        published: list[str] = []\n        for view in self._store.list_views():\n            if not is_terminal_status(view.runtime.status):\n                continue\n\n            status = view.runtime.status\n            terminal_reason = \"timed_out\" if view.runtime.timed_out else status\n            match terminal_reason:\n                case \"completed\":\n                    severity = \"success\"\n                    title = f\"Background task completed: {view.spec.description}\"\n                case \"timed_out\":\n                    severity = \"error\"\n                    title = f\"Background task timed out: {view.spec.description}\"\n                case \"failed\":\n                    severity = \"error\"\n                    title = f\"Background task failed: {view.spec.description}\"\n                case \"killed\":\n                    severity = \"warning\"\n                    title = f\"Background task stopped: {view.spec.description}\"\n                case \"lost\":\n                    severity = \"warning\"\n                    title = f\"Background task lost: {view.spec.description}\"\n                case _:\n                    severity = \"info\"\n                    title = f\"Background task updated: {view.spec.description}\"\n\n            body_lines = [\n                f\"Task ID: {view.spec.id}\",\n                f\"Status: {status}\",\n                f\"Description: {view.spec.description}\",\n            ]\n            if terminal_reason != status:\n                body_lines.append(f\"Terminal reason: {terminal_reason}\")\n            if view.runtime.exit_code is not None:\n                body_lines.append(f\"Exit code: {view.runtime.exit_code}\")\n            if view.runtime.failure_reason:\n                body_lines.append(f\"Failure reason: {view.runtime.failure_reason}\")\n\n            event = NotificationEvent(\n                id=self._notifications.new_id(),\n                category=\"task\",\n                type=f\"task.{terminal_reason}\",\n                source_kind=\"background_task\",\n                source_id=view.spec.id,\n                title=title,\n                body=\"\\n\".join(body_lines),\n                severity=severity,\n                payload={\n                    \"task_id\": view.spec.id,\n                    \"task_kind\": view.spec.kind,\n                    \"status\": status,\n                    \"description\": view.spec.description,\n                    \"exit_code\": view.runtime.exit_code,\n                    \"interrupted\": view.runtime.interrupted,\n                    \"timed_out\": view.runtime.timed_out,\n                    \"terminal_reason\": terminal_reason,\n                    \"failure_reason\": view.runtime.failure_reason,\n                },\n                dedupe_key=f\"background_task:{view.spec.id}:{terminal_reason}\",\n            )\n            notification = self._notifications.publish(event)\n            if notification.event.id == event.id:\n                published.append(notification.event.id)\n            if limit is not None and len(published) >= limit:\n                break\n        return published\n"
  },
  {
    "path": "src/kimi_cli/background/models.py",
    "content": "from __future__ import annotations\n\nimport time\nfrom typing import Literal\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\ntype TaskKind = Literal[\"bash\", \"agent\"]\ntype TaskStatus = Literal[\"created\", \"starting\", \"running\", \"completed\", \"failed\", \"killed\", \"lost\"]\ntype TaskOwnerRole = Literal[\"root\", \"fixed_subagent\", \"dynamic_subagent\"]\n\nTERMINAL_TASK_STATUSES: tuple[TaskStatus, ...] = (\"completed\", \"failed\", \"killed\", \"lost\")\n\n\ndef is_terminal_status(status: TaskStatus) -> bool:\n    return status in TERMINAL_TASK_STATUSES\n\n\nclass TaskSpec(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    version: int = 1\n    id: str\n    kind: TaskKind\n    session_id: str\n    description: str\n    tool_call_id: str\n    owner_role: TaskOwnerRole = \"root\"\n    created_at: float = Field(default_factory=time.time)\n\n    # Bash-specific fields for V1. Future task types can use kind_payload.\n    command: str | None = None\n    shell_name: str | None = None\n    shell_path: str | None = None\n    cwd: str | None = None\n    timeout_s: int | None = None\n    kind_payload: dict[str, str] | None = None\n\n\nclass TaskRuntime(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    status: TaskStatus = \"created\"\n    worker_pid: int | None = None\n    child_pid: int | None = None\n    child_pgid: int | None = None\n    started_at: float | None = None\n    heartbeat_at: float | None = None\n    updated_at: float = Field(default_factory=time.time)\n    finished_at: float | None = None\n    exit_code: int | None = None\n    interrupted: bool = False\n    timed_out: bool = False\n    failure_reason: str | None = None\n\n\nclass TaskControl(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    kill_requested_at: float | None = None\n    kill_reason: str | None = None\n    force: bool = False\n\n\nclass TaskConsumerState(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    last_seen_output_size: int = 0\n    last_viewed_at: float | None = None\n\n\nclass TaskView(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    spec: TaskSpec\n    runtime: TaskRuntime\n    control: TaskControl\n    consumer: TaskConsumerState\n\n\nclass TaskOutputChunk(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    task_id: str\n    offset: int\n    next_offset: int\n    text: str\n    eof: bool\n    status: TaskStatus\n"
  },
  {
    "path": "src/kimi_cli/background/store.py",
    "content": "from __future__ import annotations\n\nimport os\nimport re\nfrom pathlib import Path\n\nfrom kimi_cli.utils.io import atomic_json_write\n\nfrom .models import (\n    TaskConsumerState,\n    TaskControl,\n    TaskOutputChunk,\n    TaskRuntime,\n    TaskSpec,\n    TaskStatus,\n    TaskView,\n)\n\n_VALID_TASK_ID = re.compile(r\"^[a-z0-9]{2,20}$\")\n\n\ndef _validate_task_id(task_id: str) -> None:\n    if not _VALID_TASK_ID.match(task_id):\n        raise ValueError(f\"Invalid task_id: {task_id!r}\")\n\n\nclass BackgroundTaskStore:\n    SPEC_FILE = \"spec.json\"\n    RUNTIME_FILE = \"runtime.json\"\n    CONTROL_FILE = \"control.json\"\n    CONSUMER_FILE = \"consumer.json\"\n    OUTPUT_FILE = \"output.log\"\n\n    def __init__(self, root: Path):\n        self._root = root\n\n    @property\n    def root(self) -> Path:\n        return self._root\n\n    def _ensure_root(self) -> Path:\n        \"\"\"Return the root directory, creating it if it does not exist.\"\"\"\n        self._root.mkdir(parents=True, exist_ok=True)\n        return self._root\n\n    def task_dir(self, task_id: str) -> Path:\n        _validate_task_id(task_id)\n        path = self._ensure_root() / task_id\n        path.mkdir(parents=True, exist_ok=True)\n        return path\n\n    def task_path(self, task_id: str) -> Path:\n        _validate_task_id(task_id)\n        return self.root / task_id\n\n    def spec_path(self, task_id: str) -> Path:\n        return self.task_path(task_id) / self.SPEC_FILE\n\n    def runtime_path(self, task_id: str) -> Path:\n        return self.task_path(task_id) / self.RUNTIME_FILE\n\n    def control_path(self, task_id: str) -> Path:\n        return self.task_path(task_id) / self.CONTROL_FILE\n\n    def consumer_path(self, task_id: str) -> Path:\n        return self.task_path(task_id) / self.CONSUMER_FILE\n\n    def output_path(self, task_id: str) -> Path:\n        return self.task_path(task_id) / self.OUTPUT_FILE\n\n    def create_task(self, spec: TaskSpec) -> None:\n        task_dir = self.task_dir(spec.id)\n        atomic_json_write(spec.model_dump(mode=\"json\"), task_dir / self.SPEC_FILE)\n        atomic_json_write(TaskRuntime().model_dump(mode=\"json\"), task_dir / self.RUNTIME_FILE)\n        atomic_json_write(TaskControl().model_dump(mode=\"json\"), task_dir / self.CONTROL_FILE)\n        atomic_json_write(\n            TaskConsumerState().model_dump(mode=\"json\"),\n            task_dir / self.CONSUMER_FILE,\n        )\n        self.output_path(spec.id).touch(exist_ok=True)\n\n    def list_task_ids(self) -> list[str]:\n        if not self.root.exists():\n            return []\n        task_ids: list[str] = []\n        for path in sorted(self.root.iterdir()):\n            if not path.is_dir():\n                continue\n            if not (path / self.SPEC_FILE).exists():\n                continue\n            task_ids.append(path.name)\n        return task_ids\n\n    def write_spec(self, spec: TaskSpec) -> None:\n        atomic_json_write(spec.model_dump(mode=\"json\"), self.spec_path(spec.id))\n\n    def read_spec(self, task_id: str) -> TaskSpec:\n        return TaskSpec.model_validate_json(self.spec_path(task_id).read_text(encoding=\"utf-8\"))\n\n    def write_runtime(self, task_id: str, runtime: TaskRuntime) -> None:\n        atomic_json_write(runtime.model_dump(mode=\"json\"), self.runtime_path(task_id))\n\n    def read_runtime(self, task_id: str) -> TaskRuntime:\n        path = self.runtime_path(task_id)\n        if not path.exists():\n            return TaskRuntime()\n        return TaskRuntime.model_validate_json(path.read_text(encoding=\"utf-8\"))\n\n    def write_control(self, task_id: str, control: TaskControl) -> None:\n        atomic_json_write(control.model_dump(mode=\"json\"), self.control_path(task_id))\n\n    def read_control(self, task_id: str) -> TaskControl:\n        path = self.control_path(task_id)\n        if not path.exists():\n            return TaskControl()\n        return TaskControl.model_validate_json(path.read_text(encoding=\"utf-8\"))\n\n    def write_consumer(self, task_id: str, consumer: TaskConsumerState) -> None:\n        atomic_json_write(consumer.model_dump(mode=\"json\"), self.consumer_path(task_id))\n\n    def read_consumer(self, task_id: str) -> TaskConsumerState:\n        path = self.consumer_path(task_id)\n        if not path.exists():\n            return TaskConsumerState()\n        return TaskConsumerState.model_validate_json(path.read_text(encoding=\"utf-8\"))\n\n    def merged_view(self, task_id: str) -> TaskView:\n        return TaskView(\n            spec=self.read_spec(task_id),\n            runtime=self.read_runtime(task_id),\n            control=self.read_control(task_id),\n            consumer=self.read_consumer(task_id),\n        )\n\n    def list_views(self) -> list[TaskView]:\n        views = [self.merged_view(task_id) for task_id in self.list_task_ids()]\n        views.sort(\n            key=lambda view: view.runtime.updated_at or view.spec.created_at,\n            reverse=True,\n        )\n        return views\n\n    def read_output(\n        self,\n        task_id: str,\n        offset: int,\n        max_bytes: int,\n        *,\n        status: TaskStatus,\n    ) -> TaskOutputChunk:\n        path = self.output_path(task_id)\n        if not path.exists():\n            return TaskOutputChunk(\n                task_id=task_id,\n                offset=offset,\n                next_offset=offset,\n                text=\"\",\n                eof=True,\n                status=status,\n            )\n\n        with path.open(\"rb\") as f:\n            f.seek(0, os.SEEK_END)\n            total_size = f.tell()\n            bounded_offset = min(max(offset, 0), total_size)\n            f.seek(bounded_offset)\n            content = f.read(max_bytes)\n\n        next_offset = bounded_offset + len(content)\n        return TaskOutputChunk(\n            task_id=task_id,\n            offset=bounded_offset,\n            next_offset=next_offset,\n            text=content.decode(\"utf-8\", errors=\"replace\"),\n            eof=next_offset >= total_size,\n            status=status,\n        )\n\n    def tail_output(self, task_id: str, max_bytes: int, max_lines: int) -> str:\n        path = self.output_path(task_id)\n        if not path.exists():\n            return \"\"\n\n        with path.open(\"rb\") as f:\n            f.seek(0, os.SEEK_END)\n            total_size = f.tell()\n            start = max(0, total_size - max_bytes)\n            f.seek(start)\n            content = f.read()\n\n        text = content.decode(\"utf-8\", errors=\"replace\")\n        lines = text.splitlines()\n        if len(lines) > max_lines:\n            lines = lines[-max_lines:]\n        return \"\\n\".join(lines)\n"
  },
  {
    "path": "src/kimi_cli/background/summary.py",
    "content": "from __future__ import annotations\n\nfrom .manager import BackgroundTaskManager\nfrom .models import TaskView, is_terminal_status\n\n\ndef list_task_views(\n    manager: BackgroundTaskManager,\n    *,\n    active_only: bool = True,\n    limit: int = 20,\n) -> list[TaskView]:\n    views = manager.list_tasks(limit=None)\n    if active_only:\n        views = [view for view in views if not is_terminal_status(view.runtime.status)]\n    return views[:limit]\n\n\ndef format_task(view: TaskView, *, include_command: bool = False) -> str:\n    lines = [\n        f\"task_id: {view.spec.id}\",\n        f\"kind: {view.spec.kind}\",\n        f\"status: {view.runtime.status}\",\n        f\"description: {view.spec.description}\",\n    ]\n    if include_command and view.spec.command:\n        lines.append(f\"command: {view.spec.command}\")\n    if view.runtime.exit_code is not None:\n        lines.append(f\"exit_code: {view.runtime.exit_code}\")\n    if view.runtime.failure_reason:\n        lines.append(f\"reason: {view.runtime.failure_reason}\")\n    return \"\\n\".join(lines)\n\n\ndef format_task_list(\n    views: list[TaskView],\n    *,\n    active_only: bool = True,\n    include_command: bool = True,\n) -> str:\n    header = \"active_background_tasks\" if active_only else \"background_tasks\"\n    if not views:\n        return f\"{header}: 0\\n[no tasks]\"\n\n    lines = [f\"{header}: {len(views)}\", \"\"]\n    for index, view in enumerate(views, start=1):\n        lines.extend([f\"[{index}]\", format_task(view, include_command=include_command), \"\"])\n    return \"\\n\".join(lines).rstrip()\n\n\ndef build_active_task_snapshot(manager: BackgroundTaskManager, *, limit: int = 20) -> str | None:\n    views = list_task_views(manager, active_only=True, limit=limit)\n    if not views:\n        return None\n    return \"\\n\".join(\n        [\n            \"<active-background-tasks>\",\n            format_task_list(views, active_only=True, include_command=False),\n            \"</active-background-tasks>\",\n        ]\n    )\n"
  },
  {
    "path": "src/kimi_cli/background/worker.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport os\nimport signal\nimport subprocess\nimport time\nfrom pathlib import Path\nfrom typing import Any\n\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.subprocess_env import get_clean_env\n\nfrom .models import TaskControl\nfrom .store import BackgroundTaskStore\n\n\ndef terminate_process_tree_windows(pid: int, *, force: bool) -> None:\n    args = [\"taskkill\", \"/PID\", str(pid), \"/T\"]\n    if force:\n        args.append(\"/F\")\n    subprocess.run(\n        args,\n        stdout=subprocess.DEVNULL,\n        stderr=subprocess.DEVNULL,\n        check=False,\n    )\n\n\nasync def run_background_task_worker(\n    task_dir: Path,\n    *,\n    heartbeat_interval_ms: int = 5000,\n    control_poll_interval_ms: int = 500,\n    kill_grace_period_ms: int = 2000,\n) -> None:\n    task_dir = task_dir.expanduser().resolve()\n    task_id = task_dir.name\n    store = BackgroundTaskStore(task_dir.parent)\n    spec = store.read_spec(task_id)\n    runtime = store.read_runtime(task_id)\n\n    runtime.status = \"starting\"\n    runtime.worker_pid = os.getpid()\n    runtime.started_at = time.time()\n    runtime.heartbeat_at = runtime.started_at\n    runtime.updated_at = runtime.started_at\n    store.write_runtime(task_id, runtime)\n\n    control = store.read_control(task_id)\n    if control.kill_requested_at is not None:\n        runtime.status = \"killed\"\n        runtime.interrupted = True\n        runtime.finished_at = time.time()\n        runtime.updated_at = runtime.finished_at\n        runtime.failure_reason = control.kill_reason or \"Killed before command start\"\n        store.write_runtime(task_id, runtime)\n        return\n\n    if spec.command is None or spec.shell_path is None or spec.cwd is None:\n        runtime.status = \"failed\"\n        runtime.finished_at = time.time()\n        runtime.updated_at = runtime.finished_at\n        runtime.failure_reason = \"Task spec is incomplete for bash worker\"\n        store.write_runtime(task_id, runtime)\n        return\n\n    process: asyncio.subprocess.Process | None = None\n    control_task: asyncio.Task[None] | None = None\n    heartbeat_task: asyncio.Task[None] | None = None\n    stop_event = asyncio.Event()\n    kill_sent_at: float | None = None\n    timed_out = False\n    timeout_reason: str | None = None\n\n    async def _heartbeat_loop() -> None:\n        while not stop_event.is_set():\n            await asyncio.sleep(heartbeat_interval_ms / 1000)\n            current = store.read_runtime(task_id)\n            if current.finished_at is not None:\n                return\n            current.heartbeat_at = time.time()\n            current.updated_at = current.heartbeat_at\n            store.write_runtime(task_id, current)\n\n    async def _terminate_process(force: bool = False) -> None:\n        nonlocal kill_sent_at\n        if process is None or process.returncode is not None:\n            return\n        kill_sent_at = kill_sent_at or time.time()\n\n        try:\n            if os.name == \"nt\":\n                terminate_process_tree_windows(process.pid, force=force)\n                return\n\n            target_pgid = process.pid\n            if force:\n                os.killpg(target_pgid, signal.SIGKILL)\n            else:\n                os.killpg(target_pgid, signal.SIGTERM)\n        except ProcessLookupError:\n            pass\n\n    async def _control_loop() -> None:\n        nonlocal kill_sent_at\n        while not stop_event.is_set():\n            await asyncio.sleep(control_poll_interval_ms / 1000)\n            current_control: TaskControl = store.read_control(task_id)\n            if current_control.kill_requested_at is not None:\n                await _terminate_process(force=current_control.force)\n                if (\n                    kill_sent_at is not None\n                    and process is not None\n                    and process.returncode is None\n                    and time.time() - kill_sent_at >= kill_grace_period_ms / 1000\n                ):\n                    await _terminate_process(force=True)\n\n    try:\n        output_path = store.output_path(task_id)\n        with output_path.open(\"ab\") as output_file:\n            spawn_kwargs: dict[str, Any] = {\n                \"stdin\": subprocess.DEVNULL,\n                \"stdout\": output_file,\n                \"stderr\": output_file,\n                \"cwd\": spec.cwd,\n                \"env\": get_clean_env(),\n            }\n            if os.name == \"nt\":\n                spawn_kwargs[\"creationflags\"] = getattr(subprocess, \"CREATE_NEW_PROCESS_GROUP\", 0)\n            else:\n                spawn_kwargs[\"start_new_session\"] = True\n\n            args = (\n                (spec.shell_path, \"-command\", spec.command)\n                if spec.shell_name == \"Windows PowerShell\"\n                else (spec.shell_path, \"-c\", spec.command)\n            )\n            process = await asyncio.create_subprocess_exec(*args, **spawn_kwargs)\n\n            runtime = store.read_runtime(task_id)\n            runtime.status = \"running\"\n            runtime.child_pid = process.pid\n            runtime.child_pgid = process.pid if os.name != \"nt\" else None\n            runtime.updated_at = time.time()\n            runtime.heartbeat_at = runtime.updated_at\n            store.write_runtime(task_id, runtime)\n            last_known_runtime = runtime\n\n            heartbeat_task = asyncio.create_task(_heartbeat_loop())\n            control_task = asyncio.create_task(_control_loop())\n            if spec.timeout_s is None:\n                returncode = await process.wait()\n            else:\n                try:\n                    returncode = await asyncio.wait_for(process.wait(), timeout=spec.timeout_s)\n                except TimeoutError:\n                    timed_out = True\n                    timeout_reason = f\"Command timed out after {spec.timeout_s}s\"\n                    await _terminate_process(force=False)\n                    try:\n                        returncode = await asyncio.wait_for(\n                            process.wait(),\n                            timeout=kill_grace_period_ms / 1000,\n                        )\n                    except TimeoutError:\n                        await _terminate_process(force=True)\n                        returncode = await process.wait()\n    except Exception as exc:\n        logger.exception(\"Background task worker failed\")\n        runtime = store.read_runtime(task_id)\n        runtime.status = \"failed\"\n        runtime.finished_at = time.time()\n        runtime.updated_at = runtime.finished_at\n        runtime.failure_reason = str(exc)\n        store.write_runtime(task_id, runtime)\n        return\n    finally:\n        stop_event.set()\n        for task in (heartbeat_task, control_task):\n            if task is not None:\n                task.cancel()\n                with contextlib.suppress(asyncio.CancelledError):\n                    await task\n\n    runtime = last_known_runtime.model_copy()\n    control = store.read_control(task_id)\n    runtime.finished_at = time.time()\n    runtime.updated_at = runtime.finished_at\n    runtime.exit_code = returncode\n    runtime.heartbeat_at = runtime.finished_at\n    if timed_out:\n        runtime.status = \"failed\"\n        runtime.interrupted = True\n        runtime.timed_out = True\n        runtime.failure_reason = timeout_reason\n    elif control.kill_requested_at is not None:\n        runtime.status = \"killed\"\n        runtime.interrupted = True\n        runtime.failure_reason = control.kill_reason or \"Killed\"\n    elif returncode == 0:\n        runtime.status = \"completed\"\n        runtime.failure_reason = None\n    else:\n        runtime.status = \"failed\"\n        runtime.failure_reason = f\"Command failed with exit code {returncode}\"\n    store.write_runtime(task_id, runtime)\n"
  },
  {
    "path": "src/kimi_cli/cli/__init__.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Annotated, Literal\n\nimport typer\n\nfrom ._lazy_group import LazySubcommandGroup\n\n\nclass Reload(Exception):\n    \"\"\"Reload configuration.\"\"\"\n\n    def __init__(self, session_id: str | None = None):\n        super().__init__(\"reload\")\n        self.session_id = session_id\n\n\nclass SwitchToWeb(Exception):\n    \"\"\"Switch to web interface.\"\"\"\n\n    def __init__(self, session_id: str | None = None):\n        super().__init__(\"switch_to_web\")\n        self.session_id = session_id\n\n\ncli = typer.Typer(\n    cls=LazySubcommandGroup,\n    epilog=\"\"\"\\b\\\nDocumentation:        https://moonshotai.github.io/kimi-cli/\\n\nLLM friendly version: https://moonshotai.github.io/kimi-cli/llms.txt\"\"\",\n    add_completion=False,\n    context_settings={\"help_option_names\": [\"-h\", \"--help\"]},\n    help=\"Kimi, your next CLI agent.\",\n)\n\nUIMode = Literal[\"shell\", \"print\", \"acp\", \"wire\"]\nInputFormat = Literal[\"text\", \"stream-json\"]\nOutputFormat = Literal[\"text\", \"stream-json\"]\n\n\ndef _version_callback(value: bool) -> None:\n    if value:\n        from kimi_cli.constant import get_version\n\n        typer.echo(f\"kimi, version {get_version()}\")\n        raise typer.Exit()\n\n\n@cli.callback(invoke_without_command=True)\ndef kimi(\n    ctx: typer.Context,\n    # Meta\n    version: Annotated[\n        bool,\n        typer.Option(\n            \"--version\",\n            \"-V\",\n            help=\"Show version and exit.\",\n            callback=_version_callback,\n            is_eager=True,\n        ),\n    ] = False,\n    verbose: Annotated[\n        bool,\n        typer.Option(\n            \"--verbose\",\n            help=\"Print verbose information. Default: no.\",\n        ),\n    ] = False,\n    debug: Annotated[\n        bool,\n        typer.Option(\n            \"--debug\",\n            help=\"Log debug information. Default: no.\",\n        ),\n    ] = False,\n    # Basic configuration\n    local_work_dir: Annotated[\n        Path | None,\n        typer.Option(\n            \"--work-dir\",\n            \"-w\",\n            exists=True,\n            file_okay=False,\n            dir_okay=True,\n            readable=True,\n            writable=True,\n            help=\"Working directory for the agent. Default: current directory.\",\n        ),\n    ] = None,\n    local_add_dirs: Annotated[\n        list[Path] | None,\n        typer.Option(\n            \"--add-dir\",\n            exists=True,\n            file_okay=False,\n            dir_okay=True,\n            readable=True,\n            help=(\n                \"Add an additional directory to the workspace scope. \"\n                \"Can be specified multiple times.\"\n            ),\n        ),\n    ] = None,\n    session_id: Annotated[\n        str | None,\n        typer.Option(\n            \"--session\",\n            \"-S\",\n            help=\"Session ID to resume for the working directory. Default: new session.\",\n        ),\n    ] = None,\n    continue_: Annotated[\n        bool,\n        typer.Option(\n            \"--continue\",\n            \"-C\",\n            help=\"Continue the previous session for the working directory. Default: no.\",\n        ),\n    ] = False,\n    config_string: Annotated[\n        str | None,\n        typer.Option(\n            \"--config\",\n            help=\"Config TOML/JSON string to load. Default: none.\",\n        ),\n    ] = None,\n    config_file: Annotated[\n        Path | None,\n        typer.Option(\n            \"--config-file\",\n            exists=True,\n            file_okay=True,\n            dir_okay=False,\n            readable=True,\n            help=\"Config TOML/JSON file to load. Default: ~/.kimi/config.toml.\",\n        ),\n    ] = None,\n    model_name: Annotated[\n        str | None,\n        typer.Option(\n            \"--model\",\n            \"-m\",\n            help=\"LLM model to use. Default: default model set in config file.\",\n        ),\n    ] = None,\n    thinking: Annotated[\n        bool | None,\n        typer.Option(\n            \"--thinking/--no-thinking\",\n            help=\"Enable thinking mode. Default: default thinking mode set in config file.\",\n        ),\n    ] = None,\n    # Run mode\n    yolo: Annotated[\n        bool,\n        typer.Option(\n            \"--yolo\",\n            \"--yes\",\n            \"-y\",\n            \"--auto-approve\",\n            help=\"Automatically approve all actions. Default: no.\",\n        ),\n    ] = False,\n    prompt: Annotated[\n        str | None,\n        typer.Option(\n            \"--prompt\",\n            \"-p\",\n            \"--command\",\n            \"-c\",\n            help=\"User prompt to the agent. Default: prompt interactively.\",\n        ),\n    ] = None,\n    print_mode: Annotated[\n        bool,\n        typer.Option(\n            \"--print\",\n            help=(\n                \"Run in print mode (non-interactive). Note: print mode implicitly adds `--yolo`.\"\n            ),\n        ),\n    ] = False,\n    acp_mode: Annotated[\n        bool,\n        typer.Option(\n            \"--acp\",\n            help=\"(Deprecated, use `kimi acp` instead) Run as ACP server.\",\n        ),\n    ] = False,\n    wire_mode: Annotated[\n        bool,\n        typer.Option(\n            \"--wire\",\n            help=\"Run as Wire server (experimental).\",\n        ),\n    ] = False,\n    input_format: Annotated[\n        InputFormat | None,\n        typer.Option(\n            \"--input-format\",\n            help=(\n                \"Input format to use. Must be used with `--print` \"\n                \"and the input must be piped in via stdin. \"\n                \"Default: text.\"\n            ),\n        ),\n    ] = None,\n    output_format: Annotated[\n        OutputFormat | None,\n        typer.Option(\n            \"--output-format\",\n            help=\"Output format to use. Must be used with `--print`. Default: text.\",\n        ),\n    ] = None,\n    final_message_only: Annotated[\n        bool,\n        typer.Option(\n            \"--final-message-only\",\n            help=\"Only print the final assistant message (print UI).\",\n        ),\n    ] = False,\n    quiet: Annotated[\n        bool,\n        typer.Option(\n            \"--quiet\",\n            help=\"Alias for `--print --output-format text --final-message-only`.\",\n        ),\n    ] = False,\n    # Customization\n    agent: Annotated[\n        Literal[\"default\", \"okabe\"] | None,\n        typer.Option(\n            \"--agent\",\n            help=\"Builtin agent specification to use. Default: builtin default agent.\",\n        ),\n    ] = None,\n    agent_file: Annotated[\n        Path | None,\n        typer.Option(\n            \"--agent-file\",\n            exists=True,\n            file_okay=True,\n            dir_okay=False,\n            readable=True,\n            help=\"Custom agent specification file. Default: builtin default agent.\",\n        ),\n    ] = None,\n    mcp_config_file: Annotated[\n        list[Path] | None,\n        typer.Option(\n            \"--mcp-config-file\",\n            exists=True,\n            file_okay=True,\n            dir_okay=False,\n            readable=True,\n            help=(\n                \"MCP config file to load. Add this option multiple times to specify multiple MCP \"\n                \"configs. Default: none.\"\n            ),\n        ),\n    ] = None,\n    mcp_config: Annotated[\n        list[str] | None,\n        typer.Option(\n            \"--mcp-config\",\n            help=(\n                \"MCP config JSON to load. Add this option multiple times to specify multiple MCP \"\n                \"configs. Default: none.\"\n            ),\n        ),\n    ] = None,\n    local_skills_dir: Annotated[\n        Path | None,\n        typer.Option(\n            \"--skills-dir\",\n            exists=True,\n            file_okay=False,\n            dir_okay=True,\n            readable=True,\n            help=\"Path to the skills directory. Overrides discovery.\",\n        ),\n    ] = None,\n    # Loop control\n    max_steps_per_turn: Annotated[\n        int | None,\n        typer.Option(\n            \"--max-steps-per-turn\",\n            min=1,\n            help=\"Maximum number of steps in one turn. Default: from config.\",\n        ),\n    ] = None,\n    max_retries_per_step: Annotated[\n        int | None,\n        typer.Option(\n            \"--max-retries-per-step\",\n            min=1,\n            help=\"Maximum number of retries in one step. Default: from config.\",\n        ),\n    ] = None,\n    max_ralph_iterations: Annotated[\n        int | None,\n        typer.Option(\n            \"--max-ralph-iterations\",\n            min=-1,\n            help=(\n                \"Extra iterations after the first turn in Ralph mode. Use -1 for unlimited. \"\n                \"Default: from config.\"\n            ),\n        ),\n    ] = None,\n):\n    \"\"\"Kimi, your next CLI agent.\"\"\"\n    import asyncio\n    import json\n\n    from kimi_cli.utils.proctitle import init_process_name\n\n    init_process_name(\"Kimi Code\")\n\n    if ctx.invoked_subcommand is not None:\n        return  # skip rest if a subcommand is invoked\n\n    del version  # handled in the callback\n\n    from kaos.path import KaosPath\n\n    from kimi_cli.agentspec import DEFAULT_AGENT_FILE, OKABE_AGENT_FILE\n    from kimi_cli.app import KimiCLI, enable_logging\n    from kimi_cli.config import Config, load_config_from_string\n    from kimi_cli.exception import ConfigError\n    from kimi_cli.metadata import load_metadata, save_metadata\n    from kimi_cli.session import Session\n    from kimi_cli.ui.shell.startup import ShellStartupProgress\n    from kimi_cli.utils.logging import logger, open_original_stderr, redirect_stderr_to_logger\n\n    from .mcp import get_global_mcp_config_file\n\n    # Don't redirect stderr yet. Our stderr redirector replaces fd=2 with a pipe, which\n    # would swallow Click/Typer startup errors (e.g. config parsing / BadParameter).\n    # We re-enable stderr redirection after KimiCLI.create() succeeds.\n    enable_logging(debug, redirect_stderr=False)\n\n    def _emit_fatal_error(message: str) -> None:\n        # Prefer writing to the original stderr fd even if we later redirect fd=2.\n        # This ensures fatal errors are visible to the user.\n        with open_original_stderr() as stream:\n            if stream is not None:\n                stream.write((message.rstrip() + \"\\n\").encode(\"utf-8\", errors=\"replace\"))\n                stream.flush()\n                return\n        typer.echo(message, err=True)\n\n    if session_id is not None:\n        session_id = session_id.strip()\n        if not session_id:\n            raise typer.BadParameter(\"Session ID cannot be empty\", param_hint=\"--session\")\n\n    if quiet:\n        if acp_mode or wire_mode:\n            raise typer.BadParameter(\n                \"Quiet mode cannot be combined with ACP or Wire UI\",\n                param_hint=\"--quiet\",\n            )\n        if output_format not in (None, \"text\"):\n            raise typer.BadParameter(\n                \"Quiet mode implies `--output-format text`\",\n                param_hint=\"--quiet\",\n            )\n        print_mode = True\n        output_format = \"text\"\n        final_message_only = True\n\n    conflict_option_sets = [\n        {\n            \"--print\": print_mode,\n            \"--acp\": acp_mode,\n            \"--wire\": wire_mode,\n        },\n        {\n            \"--agent\": agent is not None,\n            \"--agent-file\": agent_file is not None,\n        },\n        {\n            \"--continue\": continue_,\n            \"--session\": session_id is not None,\n        },\n        {\n            \"--config\": config_string is not None,\n            \"--config-file\": config_file is not None,\n        },\n    ]\n    for option_set in conflict_option_sets:\n        active_options = [flag for flag, active in option_set.items() if active]\n        if len(active_options) > 1:\n            raise typer.BadParameter(\n                f\"Cannot combine {', '.join(active_options)}.\",\n                param_hint=active_options[0],\n            )\n\n    if agent is not None:\n        match agent:\n            case \"default\":\n                agent_file = DEFAULT_AGENT_FILE\n            case \"okabe\":\n                agent_file = OKABE_AGENT_FILE\n\n    ui: UIMode = \"shell\"\n    if print_mode:\n        ui = \"print\"\n    elif acp_mode:\n        ui = \"acp\"\n    elif wire_mode:\n        ui = \"wire\"\n\n    if prompt is not None:\n        prompt = prompt.strip()\n        if not prompt:\n            raise typer.BadParameter(\"Prompt cannot be empty\", param_hint=\"--prompt\")\n\n    if input_format is not None and ui != \"print\":\n        raise typer.BadParameter(\n            \"Input format is only supported for print UI\",\n            param_hint=\"--input-format\",\n        )\n    if output_format is not None and ui != \"print\":\n        raise typer.BadParameter(\n            \"Output format is only supported for print UI\",\n            param_hint=\"--output-format\",\n        )\n    if final_message_only and ui != \"print\":\n        raise typer.BadParameter(\n            \"Final-message-only output is only supported for print UI\",\n            param_hint=\"--final-message-only\",\n        )\n\n    config: Config | Path | None = None\n    if config_string is not None:\n        config_string = config_string.strip()\n        if not config_string:\n            raise typer.BadParameter(\"Config cannot be empty\", param_hint=\"--config\")\n        try:\n            config = load_config_from_string(config_string)\n        except ConfigError as e:\n            raise typer.BadParameter(str(e), param_hint=\"--config\") from e\n    elif config_file is not None:\n        config = config_file\n\n    file_configs = list(mcp_config_file or [])\n    raw_mcp_config = list(mcp_config or [])\n\n    # Use default MCP config file if no MCP config is provided\n    if not file_configs:\n        default_mcp_file = get_global_mcp_config_file()\n        if default_mcp_file.exists():\n            file_configs.append(default_mcp_file)\n\n    try:\n        mcp_configs = [json.loads(conf.read_text(encoding=\"utf-8\")) for conf in file_configs]\n    except json.JSONDecodeError as e:\n        raise typer.BadParameter(f\"Invalid JSON: {e}\", param_hint=\"--mcp-config-file\") from e\n\n    try:\n        mcp_configs += [json.loads(conf) for conf in raw_mcp_config]\n    except json.JSONDecodeError as e:\n        raise typer.BadParameter(f\"Invalid JSON: {e}\", param_hint=\"--mcp-config\") from e\n\n    skills_dir: KaosPath | None = None\n    if local_skills_dir is not None:\n        skills_dir = KaosPath.unsafe_from_local_path(local_skills_dir)\n\n    work_dir = KaosPath.unsafe_from_local_path(local_work_dir) if local_work_dir else KaosPath.cwd()\n\n    async def _run(session_id: str | None) -> tuple[Session, bool]:\n        \"\"\"\n        Create/load session and run the CLI instance.\n\n        Returns:\n            The session and whether the run succeeded.\n        \"\"\"\n        startup_progress = ShellStartupProgress(enabled=ui == \"shell\")\n        try:\n            startup_progress.update(\"Preparing session...\")\n\n            if session_id is not None:\n                session = await Session.find(work_dir, session_id)\n                if session is None:\n                    logger.info(\n                        \"Session {session_id} not found, creating new session\",\n                        session_id=session_id,\n                    )\n                    session = await Session.create(work_dir, session_id)\n                logger.info(\"Switching to session: {session_id}\", session_id=session.id)\n            elif continue_:\n                session = await Session.continue_(work_dir)\n                if session is None:\n                    raise typer.BadParameter(\n                        \"No previous session found for the working directory\",\n                        param_hint=\"--continue\",\n                    )\n                logger.info(\"Continuing previous session: {session_id}\", session_id=session.id)\n            else:\n                session = await Session.create(work_dir)\n                logger.info(\"Created new session: {session_id}\", session_id=session.id)\n\n            # Add CLI-provided additional directories to session state\n            if local_add_dirs:\n                from kimi_cli.utils.path import is_within_directory\n\n                canonical_work_dir = work_dir.canonical()\n                changed = False\n                for d in local_add_dirs:\n                    dir_path = KaosPath.unsafe_from_local_path(d).canonical()\n                    dir_str = str(dir_path)\n                    # Skip dirs within work_dir (already accessible)\n                    if is_within_directory(dir_path, canonical_work_dir):\n                        logger.info(\n                            \"Skipping --add-dir {dir}: already within working directory\",\n                            dir=dir_str,\n                        )\n                        continue\n                    if dir_str not in session.state.additional_dirs:\n                        session.state.additional_dirs.append(dir_str)\n                        changed = True\n                if changed:\n                    session.save_state()\n\n            instance = await KimiCLI.create(\n                session,\n                config=config,\n                model_name=model_name,\n                thinking=thinking,\n                yolo=yolo or (ui == \"print\"),  # print mode implies yolo\n                agent_file=agent_file,\n                mcp_configs=mcp_configs,\n                skills_dir=skills_dir,\n                max_steps_per_turn=max_steps_per_turn,\n                max_retries_per_step=max_retries_per_step,\n                max_ralph_iterations=max_ralph_iterations,\n                startup_progress=startup_progress.update if ui == \"shell\" else None,\n                defer_mcp_loading=ui == \"shell\" and prompt is None,\n            )\n            startup_progress.stop()\n\n            # Install stderr redirection only after initialization succeeded, so runtime\n            # stderr noise is captured into logs without hiding startup failures.\n            redirect_stderr_to_logger()\n            preserve_background_tasks = False\n            try:\n                match ui:\n                    case \"shell\":\n                        succeeded = await instance.run_shell(prompt)\n                    case \"print\":\n                        succeeded = await instance.run_print(\n                            input_format or \"text\",\n                            output_format or \"text\",\n                            prompt,\n                            final_only=final_message_only,\n                        )\n                    case \"acp\":\n                        if prompt is not None:\n                            logger.warning(\"ACP server ignores prompt argument\")\n                        await instance.run_acp()\n                        succeeded = True\n                    case \"wire\":\n                        if prompt is not None:\n                            logger.warning(\"Wire server ignores prompt argument\")\n                        await instance.run_wire_stdio()\n                        succeeded = True\n            except Reload as e:\n                preserve_background_tasks = True\n                if e.session_id is None:\n                    raise Reload(session_id=session.id) from e\n                raise\n            except SwitchToWeb:\n                preserve_background_tasks = True\n                raise\n            finally:\n                if not preserve_background_tasks:\n                    instance.shutdown_background_tasks()\n\n            return session, succeeded\n        finally:\n            startup_progress.stop()\n\n    async def _post_run(last_session: Session, succeeded: bool) -> None:\n        if not succeeded:\n            return\n\n        metadata = load_metadata()\n\n        # Update work_dir metadata with last session\n        work_dir_meta = metadata.get_work_dir_meta(last_session.work_dir)\n\n        if work_dir_meta is None:\n            logger.warning(\n                \"Work dir metadata missing when marking last session, recreating: {work_dir}\",\n                work_dir=last_session.work_dir,\n            )\n            work_dir_meta = metadata.new_work_dir_meta(last_session.work_dir)\n\n        if last_session.is_empty():\n            logger.info(\n                \"Session {session_id} has empty context, removing it\",\n                session_id=last_session.id,\n            )\n            await last_session.delete()\n            if work_dir_meta.last_session_id == last_session.id:\n                work_dir_meta.last_session_id = None\n        else:\n            work_dir_meta.last_session_id = last_session.id\n\n        save_metadata(metadata)\n\n    async def _reload_loop(session_id: str | None) -> bool:\n        \"\"\"\n        Returns:\n            True if should switch to web interface, False otherwise.\n        \"\"\"\n        while True:\n            try:\n                last_session, succeeded = await _run(session_id)\n                break\n            except Reload as e:\n                session_id = e.session_id\n                continue\n            except SwitchToWeb as e:\n                if e.session_id is not None:\n                    session = await Session.find(work_dir, e.session_id)\n                    if session is not None:\n                        await _post_run(session, True)\n                return True\n        await _post_run(last_session, succeeded)\n        return False\n\n    try:\n        switch_to_web = asyncio.run(_reload_loop(session_id))\n    except (typer.BadParameter, typer.Exit):\n        # Let Typer/Click format these errors (rich panel + correct exit code).\n        raise\n    except Exception as exc:\n        import click\n\n        if isinstance(exc, click.ClickException):\n            # ClickException includes the errors Typer knows how to render; don't\n            # wrap them, or we'd lose the standard error UI and exit codes.\n            raise\n        logger.exception(\"Fatal error when running CLI\")\n        if debug:\n            import traceback\n\n            # In debug mode, show full traceback for quick diagnosis.\n            _emit_fatal_error(traceback.format_exc())\n        else:\n            from kimi_cli.share import get_share_dir\n\n            log_path = get_share_dir() / \"logs\" / \"kimi.log\"\n            # In non-debug mode, print a concise error and point users to logs.\n            _emit_fatal_error(f\"{exc}\\nSee logs: {log_path}\")\n        raise typer.Exit(code=1) from exc\n    if switch_to_web:\n        from kimi_cli.utils.logging import restore_stderr\n\n        restore_stderr()\n\n        # Restore default SIGINT handler and terminal state after the shell's\n        # asyncio.run() to ensure Ctrl+C works in the uvicorn web server.\n        import signal\n\n        signal.signal(signal.SIGINT, signal.default_int_handler)\n\n        from kimi_cli.utils.term import ensure_tty_sane\n\n        ensure_tty_sane()\n\n        from kimi_cli.web.app import run_web_server\n\n        run_web_server(open_browser=True)\n\n\n@cli.command()\ndef login(\n    json: bool = typer.Option(\n        False,\n        \"--json\",\n        help=\"Emit OAuth events as JSON lines.\",\n    ),\n) -> None:\n    \"\"\"Login to your Kimi account.\"\"\"\n    import asyncio\n\n    from rich.console import Console\n    from rich.status import Status\n\n    from kimi_cli.auth.oauth import login_kimi_code\n    from kimi_cli.config import load_config\n\n    async def _run() -> bool:\n        if json:\n            ok = True\n            async for event in login_kimi_code(load_config()):\n                typer.echo(event.json)\n                if event.type == \"error\":\n                    ok = False\n            return ok\n\n        console = Console()\n        ok = True\n        status: Status | None = None\n        try:\n            async for event in login_kimi_code(load_config()):\n                if event.type == \"waiting\":\n                    if status is None:\n                        status = console.status(\"Waiting for user authorization...\")\n                        status.start()\n                    continue\n                if status is not None:\n                    status.stop()\n                    status = None\n                match event.type:\n                    case \"error\":\n                        style = \"red\"\n                    case \"success\":\n                        style = \"green\"\n                    case _:\n                        style = None\n                console.print(event.message, markup=False, style=style)\n                if event.type == \"error\":\n                    ok = False\n        finally:\n            if status is not None:\n                status.stop()\n        return ok\n\n    ok = asyncio.run(_run())\n    if not ok:\n        raise typer.Exit(code=1)\n\n\n@cli.command()\ndef logout(\n    json: bool = typer.Option(\n        False,\n        \"--json\",\n        help=\"Emit OAuth events as JSON lines.\",\n    ),\n) -> None:\n    \"\"\"Logout from your Kimi account.\"\"\"\n    import asyncio\n\n    from rich.console import Console\n\n    from kimi_cli.auth.oauth import logout_kimi_code\n    from kimi_cli.config import load_config\n\n    async def _run() -> bool:\n        ok = True\n        if json:\n            async for event in logout_kimi_code(load_config()):\n                typer.echo(event.json)\n                if event.type == \"error\":\n                    ok = False\n            return ok\n\n        console = Console()\n        async for event in logout_kimi_code(load_config()):\n            match event.type:\n                case \"error\":\n                    style = \"red\"\n                case \"success\":\n                    style = \"green\"\n                case _:\n                    style = None\n            console.print(event.message, markup=False, style=style)\n            if event.type == \"error\":\n                ok = False\n        return ok\n\n    ok = asyncio.run(_run())\n    if not ok:\n        raise typer.Exit(code=1)\n\n\n@cli.command(context_settings={\"allow_extra_args\": True, \"ignore_unknown_options\": True})\ndef term(\n    ctx: typer.Context,\n) -> None:\n    \"\"\"Run Toad TUI backed by Kimi Code CLI ACP server.\"\"\"\n    from .toad import run_term\n\n    run_term(ctx)\n\n\n@cli.command()\ndef acp():\n    \"\"\"Run Kimi Code CLI ACP server.\"\"\"\n    from kimi_cli.acp import acp_main\n\n    acp_main()\n\n\n@cli.command(name=\"__background-task-worker\", hidden=True)\ndef background_task_worker(\n    task_dir: Annotated[Path, typer.Option(\"--task-dir\")],\n    heartbeat_interval_ms: Annotated[int, typer.Option(\"--heartbeat-interval-ms\")] = 5000,\n    control_poll_interval_ms: Annotated[int, typer.Option(\"--control-poll-interval-ms\")] = 500,\n    kill_grace_period_ms: Annotated[int, typer.Option(\"--kill-grace-period-ms\")] = 2000,\n) -> None:\n    \"\"\"Run background task worker subprocess (internal).\"\"\"\n    import asyncio\n\n    from kimi_cli.background import run_background_task_worker\n    from kimi_cli.utils.proctitle import set_process_title\n\n    set_process_title(\"kimi-code-bg-worker\")\n\n    from kimi_cli.app import enable_logging\n\n    enable_logging(debug=False)\n    asyncio.run(\n        run_background_task_worker(\n            task_dir,\n            heartbeat_interval_ms=heartbeat_interval_ms,\n            control_poll_interval_ms=control_poll_interval_ms,\n            kill_grace_period_ms=kill_grace_period_ms,\n        )\n    )\n\n\n@cli.command(name=\"__web-worker\", hidden=True)\ndef web_worker(session_id: str) -> None:\n    \"\"\"Run web worker subprocess (internal).\"\"\"\n    import asyncio\n    from uuid import UUID\n\n    from kimi_cli.utils.proctitle import set_process_title\n\n    set_process_title(\"kimi-code-worker\")\n\n    from kimi_cli.app import enable_logging\n    from kimi_cli.web.runner.worker import run_worker\n\n    try:\n        parsed_session_id = UUID(session_id)\n    except ValueError as exc:\n        raise typer.BadParameter(f\"Invalid session ID: {session_id}\") from exc\n\n    enable_logging(debug=False)\n    asyncio.run(run_worker(parsed_session_id))\n\n\nif __name__ == \"__main__\":\n    import sys\n\n    if \"kimi_cli.cli\" not in sys.modules:\n        sys.modules[\"kimi_cli.cli\"] = sys.modules[__name__]\n\n    sys.exit(cli())\n"
  },
  {
    "path": "src/kimi_cli/cli/__main__.py",
    "content": "from __future__ import annotations\n\nimport sys\n\nfrom kimi_cli.cli import cli\n\nif __name__ == \"__main__\":\n    sys.exit(cli())\n"
  },
  {
    "path": "src/kimi_cli/cli/_lazy_group.py",
    "content": "# pyright: reportAttributeAccessIssue=false, reportMissingParameterType=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownParameterType=false, reportUnknownVariableType=false, reportUntypedBaseClass=false\nfrom __future__ import annotations\n\nfrom importlib import import_module\nfrom typing import Any, cast\n\nimport click\nimport typer\nfrom click.core import HelpFormatter\nfrom typer.main import get_command\n\n\nclass LazySubcommandGroup(typer.core.TyperGroup):\n    \"\"\"Load heavyweight subcommands only when they are actually invoked.\"\"\"\n\n    lazy_subcommands: dict[str, tuple[str, str, str]] = {\n        \"info\": (\"kimi_cli.cli.info\", \"cli\", \"Show version and protocol information.\"),\n        \"export\": (\"kimi_cli.cli.export\", \"cli\", \"Export session data.\"),\n        \"mcp\": (\"kimi_cli.cli.mcp\", \"cli\", \"Manage MCP server configurations.\"),\n        \"plugin\": (\"kimi_cli.cli.plugin\", \"cli\", \"Manage plugins.\"),\n        \"vis\": (\"kimi_cli.cli.vis\", \"cli\", \"Run Kimi Agent Tracing Visualizer.\"),\n        \"web\": (\"kimi_cli.cli.web\", \"cli\", \"Run Kimi Code CLI web interface.\"),\n    }\n    lazy_command_order: tuple[str, ...] = (\n        \"info\",\n        \"export\",\n        \"mcp\",\n        \"plugin\",\n        \"vis\",\n        \"web\",\n    )\n\n    def list_commands(self, ctx: click.Context) -> list[str]:\n        commands = list(super().list_commands(ctx))\n        for name in self.lazy_command_order:\n            if name not in commands:\n                commands.append(name)\n        return commands\n\n    def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:\n        command = super().get_command(ctx, cmd_name)\n        if command is not None:\n            return command\n\n        lazy_spec = self.lazy_subcommands.get(cmd_name)\n        if lazy_spec is None:\n            return None\n\n        module_name, attribute_name, _ = lazy_spec\n        command = get_command(getattr(import_module(module_name), attribute_name))\n        command.name = cmd_name\n        self.commands[cmd_name] = command\n        return command\n\n    def format_help(self, ctx: click.Context, formatter: HelpFormatter) -> None:\n        if not typer.core.HAS_RICH or self.rich_markup_mode is None:\n            return super().format_help(ctx, formatter)\n\n        from typer import rich_utils\n\n        rich_utils_any = cast(Any, rich_utils)\n        console = rich_utils_any._get_rich_console()\n        console.print(\n            rich_utils_any.Padding(\n                rich_utils_any.highlighter(self.get_usage(ctx)),\n                1,\n            ),\n            style=rich_utils_any.STYLE_USAGE_COMMAND,\n        )\n\n        if self.help:\n            console.print(\n                rich_utils_any.Padding(\n                    rich_utils_any.Align(\n                        rich_utils_any._get_help_text(\n                            obj=self,\n                            markup_mode=self.rich_markup_mode,\n                        ),\n                        pad=False,\n                    ),\n                    (0, 1, 1, 1),\n                )\n            )\n\n        panel_to_arguments: dict[str, list[click.Argument]] = {}\n        panel_to_options: dict[str, list[click.Option]] = {}\n        for param in self.get_params(ctx):\n            if getattr(param, \"hidden\", False):\n                continue\n            if isinstance(param, click.Argument):\n                panel_name = (\n                    getattr(param, rich_utils_any._RICH_HELP_PANEL_NAME, None)\n                    or rich_utils_any.ARGUMENTS_PANEL_TITLE\n                )\n                panel_to_arguments.setdefault(panel_name, []).append(param)\n            elif isinstance(param, click.Option):\n                panel_name = (\n                    getattr(param, rich_utils_any._RICH_HELP_PANEL_NAME, None)\n                    or rich_utils_any.OPTIONS_PANEL_TITLE\n                )\n                panel_to_options.setdefault(panel_name, []).append(param)\n\n        default_arguments = panel_to_arguments.get(rich_utils_any.ARGUMENTS_PANEL_TITLE, [])\n        rich_utils_any._print_options_panel(\n            name=rich_utils_any.ARGUMENTS_PANEL_TITLE,\n            params=default_arguments,\n            ctx=ctx,\n            markup_mode=self.rich_markup_mode,\n            console=console,\n        )\n        for panel_name, arguments in panel_to_arguments.items():\n            if panel_name == rich_utils_any.ARGUMENTS_PANEL_TITLE:\n                continue\n            rich_utils_any._print_options_panel(\n                name=panel_name,\n                params=arguments,\n                ctx=ctx,\n                markup_mode=self.rich_markup_mode,\n                console=console,\n            )\n\n        default_options = panel_to_options.get(rich_utils_any.OPTIONS_PANEL_TITLE, [])\n        rich_utils_any._print_options_panel(\n            name=rich_utils_any.OPTIONS_PANEL_TITLE,\n            params=default_options,\n            ctx=ctx,\n            markup_mode=self.rich_markup_mode,\n            console=console,\n        )\n        for panel_name, options in panel_to_options.items():\n            if panel_name == rich_utils_any.OPTIONS_PANEL_TITLE:\n                continue\n            rich_utils_any._print_options_panel(\n                name=panel_name,\n                params=options,\n                ctx=ctx,\n                markup_mode=self.rich_markup_mode,\n                console=console,\n            )\n\n        panel_to_commands: dict[str, list[click.Command]] = {}\n        for command_name in self.list_commands(ctx):\n            command = self.commands.get(command_name)\n            if command is None:\n                lazy_spec = self.lazy_subcommands.get(command_name)\n                if lazy_spec is None:\n                    continue\n                command = click.Command(command_name, help=lazy_spec[2])\n            if command.hidden:\n                continue\n            panel_name = (\n                getattr(command, rich_utils_any._RICH_HELP_PANEL_NAME, None)\n                or rich_utils_any.COMMANDS_PANEL_TITLE\n            )\n            panel_to_commands.setdefault(panel_name, []).append(command)\n\n        max_cmd_len = max(\n            (\n                len(command.name or \"\")\n                for commands in panel_to_commands.values()\n                for command in commands\n            ),\n            default=0,\n        )\n        default_commands = panel_to_commands.get(rich_utils_any.COMMANDS_PANEL_TITLE, [])\n        rich_utils_any._print_commands_panel(\n            name=rich_utils_any.COMMANDS_PANEL_TITLE,\n            commands=default_commands,\n            markup_mode=self.rich_markup_mode,\n            console=console,\n            cmd_len=max_cmd_len,\n        )\n        for panel_name, commands in panel_to_commands.items():\n            if panel_name == rich_utils_any.COMMANDS_PANEL_TITLE:\n                continue\n            rich_utils_any._print_commands_panel(\n                name=panel_name,\n                commands=commands,\n                markup_mode=self.rich_markup_mode,\n                console=console,\n                cmd_len=max_cmd_len,\n            )\n\n        if self.epilog:\n            lines = self.epilog.split(\"\\n\\n\")\n            epilogue = \"\\n\".join(x.replace(\"\\n\", \" \").strip() for x in lines)\n            epilogue_text = rich_utils_any._make_rich_text(\n                text=epilogue,\n                markup_mode=self.rich_markup_mode,\n            )\n            console.print(rich_utils_any.Padding(rich_utils_any.Align(epilogue_text, pad=False), 1))\n\n    def format_commands(self, ctx: click.Context, formatter: HelpFormatter) -> None:\n        entries: list[tuple[str, str | None]] = []\n        for subcommand in self.list_commands(ctx):\n            command = self.commands.get(subcommand)\n            if command is not None:\n                if command.hidden:\n                    continue\n                entries.append((subcommand, None))\n                continue\n\n            lazy_spec = self.lazy_subcommands.get(subcommand)\n            if lazy_spec is None:\n                continue\n            entries.append((subcommand, lazy_spec[2]))\n\n        if not entries:\n            return\n\n        limit = formatter.width - 6 - max(len(name) for name, _ in entries)\n        rows: list[tuple[str, str]] = []\n        for subcommand, short_help in entries:\n            command = self.commands.get(subcommand)\n            if command is not None:\n                rows.append((subcommand, command.get_short_help_str(limit)))\n                continue\n            rows.append((subcommand, short_help or \"\"))\n\n        if rows:\n            with formatter.section(\"Commands\"):\n                formatter.write_dl(rows)\n"
  },
  {
    "path": "src/kimi_cli/cli/export.py",
    "content": "\"\"\"Export command for packaging session data.\"\"\"\n\nfrom __future__ import annotations\n\nimport io\nimport zipfile\nfrom pathlib import Path\nfrom typing import Annotated\n\nimport typer\n\ncli = typer.Typer(help=\"Export session data.\")\n\n\ndef _find_session_by_id(session_id: str) -> Path | None:\n    \"\"\"Find a session directory by session ID across all work directories.\"\"\"\n    from kimi_cli.share import get_share_dir\n\n    sessions_root = get_share_dir() / \"sessions\"\n    if not sessions_root.exists():\n        return None\n\n    for work_dir_hash_dir in sessions_root.iterdir():\n        if not work_dir_hash_dir.is_dir():\n            continue\n        candidate = work_dir_hash_dir / session_id\n        if candidate.is_dir():\n            return candidate\n\n    return None\n\n\n@cli.callback(invoke_without_command=True)\ndef export(\n    session_id: Annotated[\n        str,\n        typer.Argument(help=\"Session ID to export.\"),\n    ],\n    output: Annotated[\n        Path | None,\n        typer.Option(\n            \"--output\",\n            \"-o\",\n            help=\"Output ZIP file path. Default: session-{id}.zip in current directory.\",\n        ),\n    ] = None,\n) -> None:\n    \"\"\"Export a session as a ZIP archive.\"\"\"\n    session_dir = _find_session_by_id(session_id)\n    if session_dir is None:\n        typer.echo(f\"Error: session '{session_id}' not found.\", err=True)\n        raise typer.Exit(code=1)\n\n    # Collect files\n    files = sorted(f for f in session_dir.iterdir() if f.is_file())\n    if not files:\n        typer.echo(f\"Error: session '{session_id}' has no files.\", err=True)\n        raise typer.Exit(code=1)\n\n    # Determine output path\n    if output is None:\n        output = Path.cwd() / f\"session-{session_id}.zip\"\n\n    # Create ZIP\n    buf = io.BytesIO()\n    with zipfile.ZipFile(buf, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        for file_path in files:\n            zf.write(file_path, arcname=file_path.name)\n    buf.seek(0)\n\n    output.parent.mkdir(parents=True, exist_ok=True)\n    output.write_bytes(buf.getvalue())\n\n    typer.echo(str(output))\n"
  },
  {
    "path": "src/kimi_cli/cli/info.py",
    "content": "from __future__ import annotations\n\nimport json\nimport platform\nfrom typing import Annotated, TypedDict\n\nimport typer\n\n\nclass InfoData(TypedDict):\n    kimi_cli_version: str\n    agent_spec_versions: list[str]\n    wire_protocol_version: str\n    python_version: str\n\n\ndef _collect_info() -> InfoData:\n    from kimi_cli.agentspec import SUPPORTED_AGENT_SPEC_VERSIONS\n    from kimi_cli.constant import get_version\n    from kimi_cli.wire.protocol import WIRE_PROTOCOL_VERSION\n\n    return {\n        \"kimi_cli_version\": get_version(),\n        \"agent_spec_versions\": [str(version) for version in SUPPORTED_AGENT_SPEC_VERSIONS],\n        \"wire_protocol_version\": WIRE_PROTOCOL_VERSION,\n        \"python_version\": platform.python_version(),\n    }\n\n\ndef _emit_info(json_output: bool) -> None:\n    info = _collect_info()\n    if json_output:\n        typer.echo(json.dumps(info, ensure_ascii=False))\n        return\n\n    agent_versions_text = \", \".join(str(version) for version in info[\"agent_spec_versions\"])\n\n    lines = [\n        f\"kimi-cli version: {info['kimi_cli_version']}\",\n        f\"agent spec versions: {agent_versions_text}\",\n        f\"wire protocol: {info['wire_protocol_version']}\",\n        f\"python version: {info['python_version']}\",\n    ]\n    for line in lines:\n        typer.echo(line)\n\n\ncli = typer.Typer(help=\"Show version and protocol information.\")\n\n\n@cli.callback(invoke_without_command=True)\ndef info(\n    json_output: Annotated[\n        bool,\n        typer.Option(\n            \"--json\",\n            help=\"Output information as JSON.\",\n        ),\n    ] = False,\n):\n    \"\"\"Show version and protocol information.\"\"\"\n    _emit_info(json_output)\n"
  },
  {
    "path": "src/kimi_cli/cli/mcp.py",
    "content": "import json\nfrom pathlib import Path\nfrom typing import Annotated, Any, Literal\n\nimport typer\n\ncli = typer.Typer(help=\"Manage MCP server configurations.\")\n\n\ndef get_global_mcp_config_file() -> Path:\n    \"\"\"Get the global MCP config file path.\"\"\"\n    from kimi_cli.share import get_share_dir\n\n    return get_share_dir() / \"mcp.json\"\n\n\ndef _load_mcp_config() -> dict[str, Any]:\n    \"\"\"Load MCP config from global mcp config file.\"\"\"\n    from fastmcp.mcp_config import MCPConfig\n    from pydantic import ValidationError\n\n    mcp_file = get_global_mcp_config_file()\n    if not mcp_file.exists():\n        return {\"mcpServers\": {}}\n    try:\n        config = json.loads(mcp_file.read_text(encoding=\"utf-8\"))\n    except json.JSONDecodeError as e:\n        raise typer.BadParameter(f\"Invalid JSON in MCP config file '{mcp_file}': {e}\") from e\n\n    try:\n        MCPConfig.model_validate(config)\n    except ValidationError as e:\n        raise typer.BadParameter(f\"Invalid MCP config in '{mcp_file}': {e}\") from e\n\n    return config\n\n\ndef _save_mcp_config(config: dict[str, Any]) -> None:\n    \"\"\"Save MCP config to default file.\"\"\"\n    mcp_file = get_global_mcp_config_file()\n    mcp_file.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n\n\ndef _get_mcp_server(name: str, *, require_remote: bool = False) -> dict[str, Any]:\n    \"\"\"Get MCP server config by name.\"\"\"\n    config = _load_mcp_config()\n    servers = config.get(\"mcpServers\", {})\n    if name not in servers:\n        typer.echo(f\"MCP server '{name}' not found.\", err=True)\n        raise typer.Exit(code=1)\n    server = servers[name]\n    if require_remote and \"url\" not in server:\n        typer.echo(f\"MCP server '{name}' is not a remote server.\", err=True)\n        raise typer.Exit(code=1)\n    return server\n\n\ndef _parse_key_value_pairs(\n    items: list[str], option_name: str, *, separator: str = \"=\", strip_whitespace: bool = False\n) -> dict[str, str]:\n    \"\"\"Parse key/value pairs from CLI options.\"\"\"\n    parsed: dict[str, str] = {}\n    for item in items:\n        if separator not in item:\n            typer.echo(\n                f\"Invalid {option_name} format: {item} (expected KEY{separator}VALUE).\",\n                err=True,\n            )\n            raise typer.Exit(code=1)\n        key, value = item.split(separator, 1)\n        if strip_whitespace:\n            key, value = key.strip(), value.strip()\n        if not key:\n            typer.echo(f\"Invalid {option_name} format: {item} (empty key).\", err=True)\n            raise typer.Exit(code=1)\n        parsed[key] = value\n    return parsed\n\n\nTransport = Literal[\"stdio\", \"http\"]\n\n\n@cli.command(\n    \"add\",\n    epilog=\"\"\"\n    Examples:\\n\n      \\n\n      # Add streamable HTTP server:\\n\n      kimi mcp add --transport http context7 https://mcp.context7.com/mcp --header \\\"CONTEXT7_API_KEY: ctx7sk-your-key\\\"\\n\n      \\n\n      # Add streamable HTTP server with OAuth authorization:\\n\n      kimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp\\n\n      \\n\n      # Add stdio server:\\n\n      kimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest\n    \"\"\".strip(),  # noqa: E501\n)\ndef mcp_add(\n    name: Annotated[\n        str,\n        typer.Argument(help=\"Name of the MCP server to add.\"),\n    ],\n    server_args: Annotated[\n        list[str] | None,\n        typer.Argument(\n            metavar=\"TARGET_OR_COMMAND...\",\n            help=\"For http: server URL. For stdio: command to run (prefix with `--`).\",\n        ),\n    ] = None,\n    transport: Annotated[\n        Transport,\n        typer.Option(\n            \"--transport\",\n            \"-t\",\n            help=\"Transport type for the MCP server. Default: stdio.\",\n        ),\n    ] = \"stdio\",\n    env: Annotated[\n        list[str] | None,\n        typer.Option(\n            \"--env\",\n            \"-e\",\n            help=\"Environment variables in KEY=VALUE format. Can be specified multiple times.\",\n        ),\n    ] = None,\n    header: Annotated[\n        list[str] | None,\n        typer.Option(\n            \"--header\",\n            \"-H\",\n            help=\"HTTP headers in KEY:VALUE format. Can be specified multiple times.\",\n        ),\n    ] = None,\n    auth: Annotated[\n        str | None,\n        typer.Option(\n            \"--auth\",\n            \"-a\",\n            help=\"Authorization type (e.g., 'oauth').\",\n        ),\n    ] = None,\n):\n    \"\"\"Add an MCP server.\"\"\"\n    config = _load_mcp_config()\n    server_args = server_args or []\n\n    if transport not in {\"stdio\", \"http\"}:\n        typer.echo(f\"Unsupported transport: {transport}.\", err=True)\n        raise typer.Exit(code=1)\n\n    if transport == \"stdio\":\n        if not server_args:\n            typer.echo(\n                \"For stdio transport, provide the command to start the MCP server after `--`.\",\n                err=True,\n            )\n            raise typer.Exit(code=1)\n        if header:\n            typer.echo(\"--header is only valid for http transport.\", err=True)\n            raise typer.Exit(code=1)\n        if auth:\n            typer.echo(\"--auth is only valid for http transport.\", err=True)\n            raise typer.Exit(code=1)\n        command, *command_args = server_args\n        server_config: dict[str, Any] = {\"command\": command, \"args\": command_args}\n        if env:\n            server_config[\"env\"] = _parse_key_value_pairs(env, \"env\")\n    else:\n        if env:\n            typer.echo(\"--env is only supported for stdio transport.\", err=True)\n            raise typer.Exit(code=1)\n        if not server_args:\n            typer.echo(\"URL is required for http transport.\", err=True)\n            raise typer.Exit(code=1)\n        if len(server_args) > 1:\n            typer.echo(\n                \"Multiple targets provided. Supply a single URL for http transport.\",\n                err=True,\n            )\n            raise typer.Exit(code=1)\n        server_config = {\"url\": server_args[0], \"transport\": \"http\"}\n        if header:\n            server_config[\"headers\"] = _parse_key_value_pairs(\n                header, \"header\", separator=\":\", strip_whitespace=True\n            )\n        if auth:\n            server_config[\"auth\"] = auth\n\n    if \"mcpServers\" not in config:\n        config[\"mcpServers\"] = {}\n    config[\"mcpServers\"][name] = server_config\n    _save_mcp_config(config)\n    typer.echo(f\"Added MCP server '{name}' to {get_global_mcp_config_file()}.\")\n\n\n@cli.command(\"remove\")\ndef mcp_remove(\n    name: Annotated[\n        str,\n        typer.Argument(help=\"Name of the MCP server to remove.\"),\n    ],\n):\n    \"\"\"Remove an MCP server.\"\"\"\n    _get_mcp_server(name)\n    config = _load_mcp_config()\n    del config[\"mcpServers\"][name]\n    _save_mcp_config(config)\n    typer.echo(f\"Removed MCP server '{name}' from {get_global_mcp_config_file()}.\")\n\n\ndef _has_oauth_tokens(server_url: str) -> bool:\n    \"\"\"Check if OAuth tokens exist for the server.\"\"\"\n    import asyncio\n\n    async def _check() -> bool:\n        try:\n            from fastmcp.client.auth.oauth import FileTokenStorage\n\n            storage = FileTokenStorage(server_url=server_url)\n            tokens = await storage.get_tokens()\n            return tokens is not None\n        except Exception:\n            return False\n\n    return asyncio.run(_check())\n\n\n@cli.command(\"list\")\ndef mcp_list():\n    \"\"\"List all MCP servers.\"\"\"\n    config_file = get_global_mcp_config_file()\n    config = _load_mcp_config()\n    servers: dict[str, Any] = config.get(\"mcpServers\", {})\n\n    typer.echo(f\"MCP config file: {config_file}\")\n    if not servers:\n        typer.echo(\"No MCP servers configured.\")\n        return\n\n    for name, server in servers.items():\n        if \"command\" in server:\n            cmd = server[\"command\"]\n            cmd_args = \" \".join(server.get(\"args\", []))\n            line = f\"{name} (stdio): {cmd} {cmd_args}\".rstrip()\n        elif \"url\" in server:\n            transport = server.get(\"transport\") or \"http\"\n            if transport == \"streamable-http\":\n                transport = \"http\"\n            line = f\"{name} ({transport}): {server['url']}\"\n            if server.get(\"auth\") == \"oauth\" and not _has_oauth_tokens(server[\"url\"]):\n                line += \" [authorization required - run: kimi mcp auth \" + name + \"]\"\n        else:\n            line = f\"{name}: {server}\"\n        typer.echo(f\"  {line}\")\n\n\n@cli.command(\"auth\")\ndef mcp_auth(\n    name: Annotated[\n        str,\n        typer.Argument(help=\"Name of the MCP server to authorize.\"),\n    ],\n):\n    \"\"\"Authorize with an OAuth-enabled MCP server.\"\"\"\n    import asyncio\n\n    server = _get_mcp_server(name, require_remote=True)\n    if server.get(\"auth\") != \"oauth\":\n        typer.echo(f\"MCP server '{name}' does not use OAuth. Add with --auth oauth.\", err=True)\n        raise typer.Exit(code=1)\n\n    async def _auth() -> None:\n        import fastmcp\n\n        typer.echo(f\"Authorizing with '{name}'...\")\n        typer.echo(\"A browser window will open for authorization.\")\n\n        client = fastmcp.Client({\"mcpServers\": {name: server}})\n        try:\n            async with client:\n                tools = await client.list_tools()\n                typer.echo(f\"Successfully authorized with '{name}'.\")\n                typer.echo(f\"Available tools: {len(tools)}\")\n        except Exception as e:\n            typer.echo(f\"Authorization failed: {type(e).__name__}: {e}\", err=True)\n            raise typer.Exit(code=1) from None\n\n    asyncio.run(_auth())\n\n\n@cli.command(\"reset-auth\")\ndef mcp_reset_auth(\n    name: Annotated[\n        str,\n        typer.Argument(help=\"Name of the MCP server to reset authorization.\"),\n    ],\n):\n    \"\"\"Reset OAuth authorization for an MCP server (clear cached tokens).\"\"\"\n    server = _get_mcp_server(name, require_remote=True)\n\n    try:\n        from fastmcp.client.auth.oauth import FileTokenStorage\n\n        storage = FileTokenStorage(server_url=server[\"url\"])\n        storage.clear()\n        typer.echo(f\"OAuth tokens cleared for '{name}'.\")\n    except ImportError:\n        typer.echo(\"OAuth support not available.\", err=True)\n        raise typer.Exit(code=1) from None\n    except Exception as e:\n        typer.echo(f\"Failed to clear tokens: {type(e).__name__}: {e}\", err=True)\n        raise typer.Exit(code=1) from None\n\n\n@cli.command(\"test\")\ndef mcp_test(\n    name: Annotated[\n        str,\n        typer.Argument(help=\"Name of the MCP server to test.\"),\n    ],\n):\n    \"\"\"Test connection to an MCP server and list available tools.\"\"\"\n    import asyncio\n\n    server = _get_mcp_server(name)\n\n    async def _test() -> None:\n        import fastmcp\n\n        typer.echo(f\"Testing connection to '{name}'...\")\n        client = fastmcp.Client({\"mcpServers\": {name: server}})\n\n        try:\n            async with client:\n                tools = await client.list_tools()\n                typer.echo(f\"✓ Connected to '{name}'\")\n                typer.echo(f\"  Available tools: {len(tools)}\")\n                if tools:\n                    typer.echo(\"  Tools:\")\n                    for tool in tools:\n                        desc = tool.description or \"\"\n                        if len(desc) > 50:\n                            desc = desc[:47] + \"...\"\n                        typer.echo(f\"    - {tool.name}: {desc}\")\n        except Exception as e:\n            typer.echo(f\"✗ Connection failed: {type(e).__name__}: {e}\", err=True)\n            raise typer.Exit(code=1) from None\n\n    asyncio.run(_test())\n"
  },
  {
    "path": "src/kimi_cli/cli/plugin.py",
    "content": "\"\"\"CLI commands for plugin management.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Annotated\n\nimport typer\n\nfrom kimi_cli.plugin import PluginError\n\ncli = typer.Typer(help=\"Manage plugins.\")\n\n\ndef _parse_git_url(target: str) -> tuple[str, str | None, str | None]:\n    \"\"\"Parse a git URL into (clone_url, subpath, branch).\n\n    Splits .git URLs at the .git boundary. For GitHub/GitLab short URLs,\n    treats the first two path segments as owner/repo and the rest as subpath.\n    Strips ``tree/{branch}/`` or ``-/tree/{branch}/`` prefixes from\n    browser-copied URLs and returns the branch name.\n    \"\"\"\n    # Path 1: URL contains .git followed by / or end-of-string\n    idx = target.find(\".git/\")\n    if idx == -1 and target.endswith(\".git\"):\n        return target, None, None\n    if idx != -1:\n        clone_url = target[: idx + 4]  # up to and including \".git\"\n        rest = target[idx + 5 :]  # after \".git/\"\n        subpath = rest.strip(\"/\") or None\n        return clone_url, subpath, None\n\n    # Path 2: GitHub/GitLab short URL (no .git)\n    from urllib.parse import urlparse\n\n    parsed = urlparse(target)\n    segments = [s for s in parsed.path.split(\"/\") if s]\n    if len(segments) < 2:\n        return target, None, None\n\n    owner_repo = \"/\".join(segments[:2])\n    clone_url = f\"{parsed.scheme}://{parsed.netloc}/{owner_repo}\"\n    rest_segments = segments[2:]\n\n    # GitLab uses /-/tree/{branch}/, strip leading \"-\"\n    if rest_segments and rest_segments[0] == \"-\":\n        rest_segments = rest_segments[1:]\n\n    # Strip tree/{branch}/ prefix and extract branch\n    branch: str | None = None\n    if len(rest_segments) >= 2 and rest_segments[0] == \"tree\":\n        branch = rest_segments[1]\n        rest_segments = rest_segments[2:]\n\n    subpath = \"/\".join(rest_segments) or None\n    return clone_url, subpath, branch\n\n\ndef _resolve_source(target: str) -> tuple[Path, Path | None]:\n    \"\"\"Resolve plugin source to (local_dir, tmp_to_cleanup).\n\n    Returns the source directory and an optional temp directory that\n    the caller must clean up after use.\n    \"\"\"\n    import shutil\n    import tempfile\n\n    # Git URL\n    if target.startswith((\"https://\", \"git@\", \"http://\")) and (\n        \".git/\" in target\n        or target.endswith(\".git\")\n        or \"github.com/\" in target\n        or \"gitlab.com/\" in target\n    ):\n        import subprocess\n\n        clone_url, subpath, branch = _parse_git_url(target)\n\n        tmp = Path(tempfile.mkdtemp(prefix=\"kimi-plugin-\"))\n        typer.echo(f\"Cloning {clone_url}...\")\n        clone_cmd = [\"git\", \"clone\", \"--depth\", \"1\"]\n        if branch:\n            clone_cmd += [\"--branch\", branch]\n        clone_cmd += [clone_url, str(tmp / \"repo\")]\n        result = subprocess.run(\n            clone_cmd,\n            capture_output=True,\n            text=True,\n        )\n        if result.returncode != 0:\n            shutil.rmtree(tmp, ignore_errors=True)\n            typer.echo(\n                f\"Error: git clone failed: {result.stderr.strip()}\",\n                err=True,\n            )\n            raise typer.Exit(1)\n\n        repo_root = tmp / \"repo\"\n\n        if subpath:\n            source = (repo_root / subpath).resolve()\n            if not source.is_relative_to(repo_root.resolve()):\n                shutil.rmtree(tmp, ignore_errors=True)\n                typer.echo(\n                    f\"Error: subpath escapes repository: {subpath}\",\n                    err=True,\n                )\n                raise typer.Exit(1)\n            if not source.is_dir():\n                shutil.rmtree(tmp, ignore_errors=True)\n                typer.echo(\n                    f\"Error: subpath '{subpath}' not found in repository\",\n                    err=True,\n                )\n                raise typer.Exit(1)\n            if not (source / \"plugin.json\").exists():\n                shutil.rmtree(tmp, ignore_errors=True)\n                typer.echo(\n                    f\"Error: no plugin.json in '{subpath}'\",\n                    err=True,\n                )\n                raise typer.Exit(1)\n            return source, tmp\n\n        # No subpath — check root first\n        if (repo_root / \"plugin.json\").exists():\n            return repo_root, tmp\n\n        # Scan one level for available plugins\n        available = sorted(\n            d.name for d in repo_root.iterdir() if d.is_dir() and (d / \"plugin.json\").exists()\n        )\n        if available:\n            names = \"\\n\".join(f\"  - {n}\" for n in available)\n            typer.echo(\n                f\"Error: No plugin.json at repository root. \"\n                f\"Available plugins:\\n{names}\\n\"\n                f\"Use: kimi plugin install <url>/<plugin-name>\",\n                err=True,\n            )\n        else:\n            typer.echo(\n                \"Error: No plugin.json found in repository\",\n                err=True,\n            )\n        shutil.rmtree(tmp, ignore_errors=True)\n        raise typer.Exit(1)\n\n    p = Path(target).expanduser().resolve()\n\n    # Zip file\n    if p.is_file() and p.suffix == \".zip\":\n        import zipfile\n\n        tmp = Path(tempfile.mkdtemp(prefix=\"kimi-plugin-\"))\n        typer.echo(f\"Extracting {p.name}...\")\n        with zipfile.ZipFile(p, \"r\") as zf:\n            # Reject zip members that escape the extraction directory\n            for member in zf.namelist():\n                member_path = (tmp / member).resolve()\n                if not member_path.is_relative_to(tmp.resolve()):\n                    shutil.rmtree(tmp, ignore_errors=True)\n                    typer.echo(f\"Error: zip contains unsafe path: {member}\", err=True)\n                    raise typer.Exit(1)\n            zf.extractall(tmp)\n        # Find the directory containing plugin.json (may be nested one level)\n        for candidate in [tmp] + sorted(tmp.iterdir()):\n            if candidate.is_dir() and (candidate / \"plugin.json\").exists():\n                return candidate, tmp\n        # Check for __MACOSX and similar artifacts\n        dirs = [d for d in tmp.iterdir() if d.is_dir() and not d.name.startswith(\"_\")]\n        if len(dirs) == 1 and (dirs[0] / \"plugin.json\").exists():\n            return dirs[0], tmp\n        shutil.rmtree(tmp, ignore_errors=True)\n        typer.echo(\"Error: No plugin.json found in zip\", err=True)\n        raise typer.Exit(1)\n\n    # Local directory\n    if p.is_dir():\n        return p, None\n\n    typer.echo(f\"Error: {target} is not a directory, zip file, or git URL\", err=True)\n    raise typer.Exit(1)\n\n\n@cli.command(\"install\")\ndef install_cmd(\n    target: Annotated[str, typer.Argument(help=\"Plugin source: directory, .zip, or git URL\")],\n) -> None:\n    \"\"\"Install a plugin and inject host configuration.\"\"\"\n    import shutil\n\n    from kimi_cli.config import load_config\n    from kimi_cli.constant import VERSION\n    from kimi_cli.plugin.manager import get_plugins_dir, install_plugin\n\n    source, tmp_dir = _resolve_source(target)\n\n    try:\n        config = load_config()\n\n        from kimi_cli.auth.oauth import OAuthManager\n        from kimi_cli.llm import augment_provider_with_env_vars\n        from kimi_cli.plugin.manager import collect_host_values\n\n        # Apply env var overrides (install runs outside normal startup)\n        if config.default_model and config.default_model in config.models:\n            model = config.models[config.default_model]\n            if model.provider in config.providers:\n                augment_provider_with_env_vars(config.providers[model.provider], model)\n\n        oauth = OAuthManager(config)\n        host_values = collect_host_values(config, oauth)\n\n        if not host_values.get(\"api_key\"):\n            typer.echo(\n                \"Warning: No LLM provider configured. \"\n                \"Plugins requiring API key injection will fail. \"\n                \"Run 'kimi login' or configure a provider first.\",\n                err=True,\n            )\n\n        spec = install_plugin(\n            source=source,\n            plugins_dir=get_plugins_dir(),\n            host_values=host_values,\n            host_name=\"kimi-code\",\n            host_version=VERSION,\n        )\n    except PluginError as exc:\n        typer.echo(f\"Error: {exc}\", err=True)\n        raise typer.Exit(1) from exc\n    finally:\n        # Clean up temp directory from zip/git extraction\n        if tmp_dir is not None:\n            shutil.rmtree(tmp_dir, ignore_errors=True)\n\n    typer.echo(f\"Installed plugin '{spec.name}' v{spec.version}\")\n    if spec.runtime:\n        typer.echo(f\"  runtime: host={spec.runtime.host}, version={spec.runtime.host_version}\")\n\n\n@cli.command(\"list\")\ndef list_cmd() -> None:\n    \"\"\"List installed plugins.\"\"\"\n    from kimi_cli.plugin.manager import get_plugins_dir, list_plugins\n\n    plugins = list_plugins(get_plugins_dir())\n    if not plugins:\n        typer.echo(\"No plugins installed.\")\n        return\n\n    for p in plugins:\n        status = \"installed\" if p.runtime else \"not configured\"\n        typer.echo(f\"  {p.name} v{p.version} ({status})\")\n\n\n@cli.command(\"remove\")\ndef remove_cmd(\n    name: Annotated[str, typer.Argument(help=\"Plugin name to remove\")],\n) -> None:\n    \"\"\"Remove an installed plugin.\"\"\"\n    from kimi_cli.plugin.manager import get_plugins_dir, remove_plugin\n\n    try:\n        remove_plugin(name, get_plugins_dir())\n    except PluginError as exc:\n        typer.echo(f\"Error: {exc}\", err=True)\n        raise typer.Exit(1) from exc\n\n    typer.echo(f\"Removed plugin '{name}'\")\n\n\n@cli.command(\"info\")\ndef info_cmd(\n    name: Annotated[str, typer.Argument(help=\"Plugin name\")],\n) -> None:\n    \"\"\"Show plugin details.\"\"\"\n    from kimi_cli.plugin import parse_plugin_json\n    from kimi_cli.plugin.manager import get_plugins_dir\n\n    plugin_json = get_plugins_dir() / name / \"plugin.json\"\n    if not plugin_json.exists():\n        typer.echo(f\"Error: Plugin '{name}' not found\", err=True)\n        raise typer.Exit(1)\n\n    try:\n        spec = parse_plugin_json(plugin_json)\n    except PluginError as exc:\n        typer.echo(f\"Error: {exc}\", err=True)\n        raise typer.Exit(1) from exc\n\n    typer.echo(f\"Name:        {spec.name}\")\n    typer.echo(f\"Version:     {spec.version}\")\n    typer.echo(f\"Description: {spec.description or '(none)'}\")\n    typer.echo(f\"Config file: {spec.config_file or '(none)'}\")\n    if spec.inject:\n        typer.echo(f\"Inject:      {', '.join(f'{k} <- {v}' for k, v in spec.inject.items())}\")\n    if spec.runtime:\n        typer.echo(f\"Runtime:     host={spec.runtime.host}, version={spec.runtime.host_version}\")\n    else:\n        typer.echo(\"Runtime:     (not installed via host)\")\n"
  },
  {
    "path": "src/kimi_cli/cli/toad.py",
    "content": "import importlib.util\nimport shlex\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nimport typer\n\n\ndef _default_acp_command() -> list[str]:\n    argv0 = sys.argv[0]\n    if argv0:\n        resolved = shutil.which(argv0)\n        resolved_path = Path(resolved).expanduser() if resolved else Path(argv0).expanduser()\n        if (\n            resolved_path.exists()\n            and resolved_path.suffix != \".py\"\n            and not resolved_path.name.startswith((\"python\", \"pypy\"))\n        ):\n            return [str(resolved_path), \"acp\"]\n\n    return [sys.executable, \"-m\", \"kimi_cli.cli\", \"acp\"]\n\n\ndef _default_toad_command() -> list[str]:\n    if sys.version_info < (3, 14):\n        typer.echo(\"`kimi term` requires Python 3.14+ because Toad requires it.\", err=True)\n        raise typer.Exit(code=1)\n    if importlib.util.find_spec(\"toad\") is None:\n        typer.echo(\n            \"Toad dependency is missing. Install kimi-cli with Python 3.14+ to use `kimi term`.\",\n            err=True,\n        )\n        raise typer.Exit(code=1)\n    return [sys.executable, \"-m\", \"toad.cli\"]\n\n\ndef _extract_project_dir(extra_args: list[str]) -> Path | None:\n    work_dir: str | None = None\n    idx = 0\n    while idx < len(extra_args):\n        arg = extra_args[idx]\n        if arg in (\"--work-dir\", \"-w\"):\n            if idx + 1 < len(extra_args):\n                work_dir = extra_args[idx + 1]\n                idx += 2\n                continue\n        elif arg.startswith(\"--work-dir=\") or arg.startswith(\"-w=\"):\n            work_dir = arg.split(\"=\", 1)[1]\n        elif arg.startswith(\"-w\") and len(arg) > 2:\n            work_dir = arg[2:]\n        idx += 1\n\n    if not work_dir:\n        return None\n\n    return Path(work_dir).expanduser().resolve()\n\n\ndef run_term(ctx: typer.Context) -> None:\n    extra_args = list(ctx.args)\n    acp_args = _default_acp_command()\n    acp_command = shlex.join(acp_args)\n    toad_parts = _default_toad_command()\n    args = [*toad_parts, \"acp\", acp_command]\n    project_dir = _extract_project_dir(extra_args)\n    if project_dir is not None:\n        args.append(str(project_dir))\n\n    result = subprocess.run(args)\n    if result.returncode != 0:\n        raise typer.Exit(code=result.returncode)\n"
  },
  {
    "path": "src/kimi_cli/cli/vis.py",
    "content": "\"\"\"Vis command for Kimi Agent Tracing Visualizer.\"\"\"\n\nfrom typing import Annotated\n\nimport typer\n\ncli = typer.Typer(help=\"Run Kimi Agent Tracing Visualizer.\")\n\n\n@cli.callback(invoke_without_command=True)\ndef vis(\n    ctx: typer.Context,\n    port: Annotated[int, typer.Option(\"--port\", \"-p\", help=\"Port to bind to\")] = 5495,\n    open_browser: Annotated[\n        bool, typer.Option(\"--open/--no-open\", help=\"Open browser automatically\")\n    ] = True,\n    reload: Annotated[bool, typer.Option(\"--reload\", help=\"Enable auto-reload\")] = False,\n):\n    \"\"\"Launch the agent tracing visualizer.\"\"\"\n    from kimi_cli.vis.app import run_vis_server\n\n    run_vis_server(port=port, open_browser=open_browser, reload=reload)\n"
  },
  {
    "path": "src/kimi_cli/cli/web.py",
    "content": "\"\"\"Web UI command for Kimi Code CLI.\"\"\"\n\nfrom typing import Annotated\n\nimport typer\n\ncli = typer.Typer(help=\"Run Kimi Code CLI web interface.\")\n\n\n@cli.callback(invoke_without_command=True)\ndef web(\n    ctx: typer.Context,\n    host: Annotated[\n        str | None,\n        typer.Option(\"--host\", \"-h\", help=\"Bind to specific IP address\"),\n    ] = None,\n    network: Annotated[\n        bool,\n        typer.Option(\"--network\", \"-n\", help=\"Enable network access (bind to 0.0.0.0)\"),\n    ] = False,\n    port: Annotated[int, typer.Option(\"--port\", \"-p\", help=\"Port to bind to\")] = 5494,\n    reload: Annotated[bool, typer.Option(\"--reload\", help=\"Enable auto-reload\")] = False,\n    open_browser: Annotated[\n        bool, typer.Option(\"--open/--no-open\", help=\"Open browser automatically\")\n    ] = True,\n    auth_token: Annotated[\n        str | None,\n        typer.Option(\"--auth-token\", help=\"Bearer token for API authentication.\"),\n    ] = None,\n    allowed_origins: Annotated[\n        str | None,\n        typer.Option(\n            \"--allowed-origins\",\n            help=\"Comma-separated list of allowed Origin values.\",\n        ),\n    ] = None,\n    dangerously_omit_auth: Annotated[\n        bool,\n        typer.Option(\n            \"--dangerously-omit-auth\",\n            help=\"Disable auth checks (dangerous in public networks).\",\n        ),\n    ] = False,\n    restrict_sensitive_apis: Annotated[\n        bool | None,\n        typer.Option(\n            \"--restrict-sensitive-apis/--no-restrict-sensitive-apis\",\n            help=\"Disable sensitive APIs (config write, open-in, file access limits).\",\n        ),\n    ] = None,\n    lan_only: Annotated[\n        bool,\n        typer.Option(\n            \"--lan-only/--public\",\n            help=\"Only allow access from local network (default) or allow public access.\",\n        ),\n    ] = True,\n):\n    \"\"\"Run Kimi Code CLI web interface.\"\"\"\n    from kimi_cli.web.app import run_web_server\n\n    # Determine bind address\n    if host:\n        bind_host = host\n    elif network:\n        bind_host = \"0.0.0.0\"\n    else:\n        bind_host = \"127.0.0.1\"\n\n    run_web_server(\n        host=bind_host,\n        port=port,\n        reload=reload,\n        open_browser=open_browser,\n        auth_token=auth_token,\n        allowed_origins=allowed_origins,\n        dangerously_omit_auth=dangerously_omit_auth,\n        restrict_sensitive_apis=restrict_sensitive_apis,\n        lan_only=lan_only,\n    )\n"
  },
  {
    "path": "src/kimi_cli/config.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom typing import Literal, Self\n\nimport tomlkit\nfrom pydantic import (\n    AliasChoices,\n    BaseModel,\n    Field,\n    SecretStr,\n    ValidationError,\n    field_serializer,\n    model_validator,\n)\nfrom tomlkit.exceptions import TOMLKitError\n\nfrom kimi_cli.exception import ConfigError\nfrom kimi_cli.llm import ModelCapability, ProviderType\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.utils.logging import logger\n\n\nclass OAuthRef(BaseModel):\n    \"\"\"Reference to OAuth credentials stored outside the config file.\"\"\"\n\n    storage: Literal[\"keyring\", \"file\"] = \"file\"\n    \"\"\"Credential storage backend.\"\"\"\n    key: str\n    \"\"\"Storage key to locate OAuth credentials.\"\"\"\n\n\nclass LLMProvider(BaseModel):\n    \"\"\"LLM provider configuration.\"\"\"\n\n    type: ProviderType\n    \"\"\"Provider type\"\"\"\n    base_url: str\n    \"\"\"API base URL\"\"\"\n    api_key: SecretStr\n    \"\"\"API key\"\"\"\n    env: dict[str, str] | None = None\n    \"\"\"Environment variables to set before creating the provider instance\"\"\"\n    custom_headers: dict[str, str] | None = None\n    \"\"\"Custom headers to include in API requests\"\"\"\n    oauth: OAuthRef | None = None\n    \"\"\"OAuth credential reference (do not store tokens here).\"\"\"\n\n    @field_serializer(\"api_key\", when_used=\"json\")\n    def dump_secret(self, v: SecretStr):\n        return v.get_secret_value()\n\n\nclass LLMModel(BaseModel):\n    \"\"\"LLM model configuration.\"\"\"\n\n    provider: str\n    \"\"\"Provider name\"\"\"\n    model: str\n    \"\"\"Model name\"\"\"\n    max_context_size: int\n    \"\"\"Maximum context size (unit: tokens)\"\"\"\n    capabilities: set[ModelCapability] | None = None\n    \"\"\"Model capabilities\"\"\"\n\n\nclass LoopControl(BaseModel):\n    \"\"\"Agent loop control configuration.\"\"\"\n\n    max_steps_per_turn: int = Field(\n        default=100,\n        ge=1,\n        validation_alias=AliasChoices(\"max_steps_per_turn\", \"max_steps_per_run\"),\n    )\n    \"\"\"Maximum number of steps in one turn\"\"\"\n    max_retries_per_step: int = Field(default=3, ge=1)\n    \"\"\"Maximum number of retries in one step\"\"\"\n    max_ralph_iterations: int = Field(default=0, ge=-1)\n    \"\"\"Extra iterations after the first turn in Ralph mode. Use -1 for unlimited.\"\"\"\n    reserved_context_size: int = Field(default=50_000, ge=1000)\n    \"\"\"Reserved token count for LLM response generation. Auto-compaction triggers when\n    either context_tokens + reserved_context_size >= max_context_size or\n    context_tokens >= max_context_size * compaction_trigger_ratio. Default is 50000.\"\"\"\n    compaction_trigger_ratio: float = Field(default=0.85, ge=0.5, le=0.99)\n    \"\"\"Context usage ratio threshold for auto-compaction. Default is 0.85 (85%).\n    Auto-compaction triggers when context_tokens >= max_context_size * compaction_trigger_ratio\n    or when context_tokens + reserved_context_size >= max_context_size.\"\"\"\n\n\nclass BackgroundConfig(BaseModel):\n    \"\"\"Background task runtime configuration.\"\"\"\n\n    max_running_tasks: int = Field(default=4, ge=1)\n    read_max_bytes: int = Field(default=30_000, ge=1024)\n    notification_tail_lines: int = Field(default=20, ge=1)\n    notification_tail_chars: int = Field(default=3_000, ge=256)\n    wait_poll_interval_ms: int = Field(default=500, ge=50)\n    worker_heartbeat_interval_ms: int = Field(default=5_000, ge=100)\n    worker_stale_after_ms: int = Field(default=15_000, ge=1000)\n    kill_grace_period_ms: int = Field(default=2_000, ge=100)\n    keep_alive_on_exit: bool = Field(\n        default=False,\n        description=\"Keep background tasks alive when CLI exits. Default: kill on exit.\",\n    )\n\n\nclass NotificationConfig(BaseModel):\n    \"\"\"Notification runtime configuration.\"\"\"\n\n    claim_stale_after_ms: int = Field(default=15_000, ge=1000)\n\n\nclass MoonshotSearchConfig(BaseModel):\n    \"\"\"Moonshot Search configuration.\"\"\"\n\n    base_url: str\n    \"\"\"Base URL for Moonshot Search service.\"\"\"\n    api_key: SecretStr\n    \"\"\"API key for Moonshot Search service.\"\"\"\n    custom_headers: dict[str, str] | None = None\n    \"\"\"Custom headers to include in API requests.\"\"\"\n    oauth: OAuthRef | None = None\n    \"\"\"OAuth credential reference (do not store tokens here).\"\"\"\n\n    @field_serializer(\"api_key\", when_used=\"json\")\n    def dump_secret(self, v: SecretStr):\n        return v.get_secret_value()\n\n\nclass MoonshotFetchConfig(BaseModel):\n    \"\"\"Moonshot Fetch configuration.\"\"\"\n\n    base_url: str\n    \"\"\"Base URL for Moonshot Fetch service.\"\"\"\n    api_key: SecretStr\n    \"\"\"API key for Moonshot Fetch service.\"\"\"\n    custom_headers: dict[str, str] | None = None\n    \"\"\"Custom headers to include in API requests.\"\"\"\n    oauth: OAuthRef | None = None\n    \"\"\"OAuth credential reference (do not store tokens here).\"\"\"\n\n    @field_serializer(\"api_key\", when_used=\"json\")\n    def dump_secret(self, v: SecretStr):\n        return v.get_secret_value()\n\n\nclass Services(BaseModel):\n    \"\"\"Services configuration.\"\"\"\n\n    moonshot_search: MoonshotSearchConfig | None = None\n    \"\"\"Moonshot Search configuration.\"\"\"\n    moonshot_fetch: MoonshotFetchConfig | None = None\n    \"\"\"Moonshot Fetch configuration.\"\"\"\n\n\nclass MCPClientConfig(BaseModel):\n    \"\"\"MCP client configuration.\"\"\"\n\n    tool_call_timeout_ms: int = 60000\n    \"\"\"Timeout for tool calls in milliseconds.\"\"\"\n\n\nclass MCPConfig(BaseModel):\n    \"\"\"MCP configuration.\"\"\"\n\n    client: MCPClientConfig = Field(\n        default_factory=MCPClientConfig, description=\"MCP client configuration\"\n    )\n\n\nclass Config(BaseModel):\n    \"\"\"Main configuration structure.\"\"\"\n\n    is_from_default_location: bool = Field(\n        default=False,\n        description=\"Whether the config was loaded from the default location\",\n        exclude=True,\n    )\n    source_file: Path | None = Field(\n        default=None,\n        description=\"Path to the loaded config file. None when loaded from --config text.\",\n        exclude=True,\n    )\n    default_model: str = Field(default=\"\", description=\"Default model to use\")\n    default_thinking: bool = Field(default=False, description=\"Default thinking mode\")\n    default_yolo: bool = Field(default=False, description=\"Default yolo (auto-approve) mode\")\n    default_editor: str = Field(\n        default=\"\",\n        description=\"Default external editor command (e.g. 'vim', 'code --wait')\",\n    )\n    models: dict[str, LLMModel] = Field(default_factory=dict, description=\"List of LLM models\")\n    providers: dict[str, LLMProvider] = Field(\n        default_factory=dict, description=\"List of LLM providers\"\n    )\n    loop_control: LoopControl = Field(default_factory=LoopControl, description=\"Agent loop control\")\n    background: BackgroundConfig = Field(\n        default_factory=BackgroundConfig, description=\"Background task configuration\"\n    )\n    notifications: NotificationConfig = Field(\n        default_factory=NotificationConfig, description=\"Notification configuration\"\n    )\n    services: Services = Field(default_factory=Services, description=\"Services configuration\")\n    mcp: MCPConfig = Field(default_factory=MCPConfig, description=\"MCP configuration\")\n\n    @model_validator(mode=\"after\")\n    def validate_model(self) -> Self:\n        if self.default_model and self.default_model not in self.models:\n            raise ValueError(f\"Default model {self.default_model} not found in models\")\n        for model in self.models.values():\n            if model.provider not in self.providers:\n                raise ValueError(f\"Provider {model.provider} not found in providers\")\n        return self\n\n\ndef get_config_file() -> Path:\n    \"\"\"Get the configuration file path.\"\"\"\n    return get_share_dir() / \"config.toml\"\n\n\ndef get_default_config() -> Config:\n    \"\"\"Get the default configuration.\"\"\"\n    return Config(\n        default_model=\"\",\n        models={},\n        providers={},\n        services=Services(),\n    )\n\n\ndef load_config(config_file: Path | None = None) -> Config:\n    \"\"\"\n    Load configuration from config file.\n    If the config file does not exist, create it with default configuration.\n\n    Args:\n        config_file (Path | None): Path to the configuration file. If None, use default path.\n\n    Returns:\n        Validated Config object.\n\n    Raises:\n        ConfigError: If the configuration file is invalid.\n    \"\"\"\n    default_config_file = get_config_file().expanduser().resolve(strict=False)\n    if config_file is None:\n        config_file = default_config_file\n    config_file = config_file.expanduser().resolve(strict=False)\n    is_default_config_file = config_file == default_config_file\n    logger.debug(\"Loading config from file: {file}\", file=config_file)\n\n    # If the user hasn't provided an explicit config path, migrate legacy JSON config once.\n    if is_default_config_file and not config_file.exists():\n        _migrate_json_config_to_toml()\n\n    if not config_file.exists():\n        config = get_default_config()\n        logger.debug(\"No config file found, creating default config: {config}\", config=config)\n        save_config(config, config_file)\n        config.is_from_default_location = is_default_config_file\n        config.source_file = config_file\n        return config\n\n    try:\n        config_text = config_file.read_text(encoding=\"utf-8\")\n        if config_file.suffix.lower() == \".json\":\n            data = json.loads(config_text)\n        else:\n            data = tomlkit.loads(config_text)\n        config = Config.model_validate(data)\n    except json.JSONDecodeError as e:\n        raise ConfigError(f\"Invalid JSON in configuration file {config_file}: {e}\") from e\n    except TOMLKitError as e:\n        raise ConfigError(f\"Invalid TOML in configuration file {config_file}: {e}\") from e\n    except ValidationError as e:\n        raise ConfigError(f\"Invalid configuration file {config_file}: {e}\") from e\n    config.is_from_default_location = is_default_config_file\n    config.source_file = config_file\n    return config\n\n\ndef load_config_from_string(config_string: str) -> Config:\n    \"\"\"\n    Load configuration from a TOML or JSON string.\n\n    Args:\n        config_string (str): TOML or JSON configuration text.\n\n    Returns:\n        Validated Config object.\n\n    Raises:\n        ConfigError: If the configuration text is invalid.\n    \"\"\"\n    if not config_string.strip():\n        raise ConfigError(\"Configuration text cannot be empty\")\n\n    json_error: json.JSONDecodeError | None = None\n    try:\n        data = json.loads(config_string)\n    except json.JSONDecodeError as exc:\n        json_error = exc\n        data = None\n\n    if data is None:\n        try:\n            data = tomlkit.loads(config_string)\n        except TOMLKitError as toml_error:\n            raise ConfigError(\n                f\"Invalid configuration text: {json_error}; {toml_error}\"\n            ) from toml_error\n\n    try:\n        config = Config.model_validate(data)\n    except ValidationError as e:\n        raise ConfigError(f\"Invalid configuration text: {e}\") from e\n    config.is_from_default_location = False\n    config.source_file = None\n    return config\n\n\ndef save_config(config: Config, config_file: Path | None = None):\n    \"\"\"\n    Save configuration to config file.\n\n    Args:\n        config (Config): Config object to save.\n        config_file (Path | None): Path to the configuration file. If None, use default path.\n    \"\"\"\n    config_file = config_file or get_config_file()\n    logger.debug(\"Saving config to file: {file}\", file=config_file)\n    config_file.parent.mkdir(parents=True, exist_ok=True)\n    config_data = config.model_dump(mode=\"json\", exclude_none=True)\n    with open(config_file, \"w\", encoding=\"utf-8\") as f:\n        if config_file.suffix.lower() == \".json\":\n            f.write(json.dumps(config_data, ensure_ascii=False, indent=2))\n        else:\n            f.write(tomlkit.dumps(config_data))  # type: ignore[reportUnknownMemberType]\n\n\ndef _migrate_json_config_to_toml() -> None:\n    old_json_config_file = get_share_dir() / \"config.json\"\n    new_toml_config_file = get_share_dir() / \"config.toml\"\n\n    if not old_json_config_file.exists():\n        return\n    if new_toml_config_file.exists():\n        return\n\n    logger.info(\n        \"Migrating legacy config file from {old} to {new}\",\n        old=old_json_config_file,\n        new=new_toml_config_file,\n    )\n\n    try:\n        with open(old_json_config_file, encoding=\"utf-8\") as f:\n            data = json.load(f)\n        config = Config.model_validate(data)\n    except json.JSONDecodeError as e:\n        raise ConfigError(f\"Invalid JSON in legacy configuration file: {e}\") from e\n    except ValidationError as e:\n        raise ConfigError(f\"Invalid legacy configuration file: {e}\") from e\n\n    # Write new TOML config, then keep a backup of the original JSON file.\n    save_config(config, new_toml_config_file)\n    backup_path = old_json_config_file.with_name(\"config.json.bak\")\n    old_json_config_file.replace(backup_path)\n    logger.info(\"Legacy config backed up to {file}\", file=backup_path)\n"
  },
  {
    "path": "src/kimi_cli/constant.py",
    "content": "from __future__ import annotations\n\nfrom functools import cache\nfrom typing import TYPE_CHECKING\n\nNAME = \"Kimi Code CLI\"\n\nif TYPE_CHECKING:\n    VERSION: str\n    USER_AGENT: str\n\n\n@cache\ndef get_version() -> str:\n    from importlib import metadata\n\n    return metadata.version(\"kimi-cli\")\n\n\n@cache\ndef get_user_agent() -> str:\n    return f\"KimiCLI/{get_version()}\"\n\n\ndef __getattr__(name: str) -> str:\n    if name == \"VERSION\":\n        return get_version()\n    if name == \"USER_AGENT\":\n        return get_user_agent()\n    raise AttributeError(f\"module {__name__!r} has no attribute {name!r}\")\n\n\n__all__ = [\"NAME\", \"VERSION\", \"USER_AGENT\", \"get_version\", \"get_user_agent\"]\n"
  },
  {
    "path": "src/kimi_cli/deps/Makefile",
    "content": "THIS_DIR := $(patsubst %/,%,$(dir $(lastword $(MAKEFILE_LIST))))\nBIN_DIR := $(THIS_DIR)/bin\nTMP_DIR := $(THIS_DIR)/tmp\n\n# Allow override via environment: RG_VERSION=15.0.0 make download-ripgrep\nRG_VERSION ?= 15.0.0\nOS := $(shell uname -s)\nARCH := $(shell uname -m | tr '[:upper:]' '[:lower:]')\nRG_ARCHIVE_EXT := tar.gz\nRG_ARCHIVE_BIN := rg\nRG_BIN_SUFFIX :=\n\n# Map OS/ARCH to ripgrep TARGET name\n# See: https://github.com/BurntSushi/ripgrep/releases\nifeq ($(OS),Darwin)\n  ifeq ($(ARCH),arm64)\n    RG_TARGET := aarch64-apple-darwin\n  else ifeq ($(ARCH),x86_64)\n    RG_TARGET := x86_64-apple-darwin\n  else\n    $(error Unsupported macOS architecture: $(ARCH))\n  endif\nelse ifeq ($(OS),Linux)\n  ifeq ($(ARCH),x86_64)\n    RG_TARGET := x86_64-unknown-linux-musl\n  else ifeq ($(ARCH),aarch64)\n    RG_TARGET := aarch64-unknown-linux-gnu\n  else ifeq ($(ARCH),armv7l)\n    RG_TARGET := arm-unknown-linux-gnueabihf\n  else\n    $(error Unsupported Linux architecture: $(ARCH))\n  endif\nelse ifneq (,$(filter MSYS% MINGW%,$(OS)))\n  ifeq ($(ARCH),x86_64)\n    RG_TARGET := x86_64-pc-windows-msvc\n  else ifeq ($(ARCH),aarch64)\n    RG_TARGET := aarch64-pc-windows-msvc\n  else\n    $(error Unsupported Windows architecture: $(ARCH))\n  endif\n  RG_ARCHIVE_EXT := zip\n  RG_ARCHIVE_BIN := rg.exe\n  RG_BIN_SUFFIX := .exe\nelse\n  $(error Unsupported OS: $(OS))\nendif\n\nRG_ARCHIVE := ripgrep-$(RG_VERSION)-$(RG_TARGET).$(RG_ARCHIVE_EXT)\nRG_URL := https://github.com/BurntSushi/ripgrep/releases/download/$(RG_VERSION)/$(RG_ARCHIVE)\n\n\n.PHONY: download-ripgrep\ndownload-ripgrep:\n\t@echo \"==> Ensuring ripgrep is installed\"\n\t@if [ -f \"$(BIN_DIR)/rg$(RG_BIN_SUFFIX)\" ]; then \\\n\t\techo \"rg already installed at $(BIN_DIR)/rg$(RG_BIN_SUFFIX)\"; \\\n\telse \\\n\t\techo \"Downloading ripgrep $(RG_VERSION) from: $(RG_URL)\"; \\\n\t\tmkdir -p \"$(BIN_DIR)\" \"$(TMP_DIR)\"; \\\n\t\tARCHIVE_PATH=\"$(TMP_DIR)/$(RG_ARCHIVE)\"; \\\n\t\tif command -v curl >/dev/null 2>&1; then \\\n\t\t\tcurl -L --fail -o \"$$ARCHIVE_PATH\" \"$(RG_URL)\"; \\\n\t\telse \\\n\t\t\tif command -v wget >/dev/null 2>&1; then \\\n\t\t\t\twget -O \"$$ARCHIVE_PATH\" \"$(RG_URL)\"; \\\n\t\t\telse \\\n\t\t\t\techo \"Error: neither curl nor wget is available\" && exit 1; \\\n\t\t\tfi; \\\n\t\tfi; \\\n\t\tif [ \"$(RG_ARCHIVE_EXT)\" = \"zip\" ]; then \\\n\t\t\tARCHIVE_PATH=\"$$ARCHIVE_PATH\" TMP_DIR=\"$(TMP_DIR)\" python -c \"import os, zipfile; zipfile.ZipFile(os.environ['ARCHIVE_PATH']).extractall(os.environ['TMP_DIR'])\"; \\\n\t\telse \\\n\t\t\ttar -xzf \"$$ARCHIVE_PATH\" -C \"$(TMP_DIR)\"; \\\n\t\tfi; \\\n\t\tSRC_PATH=\"$(TMP_DIR)/ripgrep-$(RG_VERSION)-$(RG_TARGET)/$(RG_ARCHIVE_BIN)\"; \\\n\t\tDST_PATH=\"$(BIN_DIR)/rg$(RG_BIN_SUFFIX)\"; \\\n\t\tcp \"$$SRC_PATH\" \"$$DST_PATH\"; \\\n\t\tchmod +x \"$$DST_PATH\"; \\\n\t\techo \"rg installed at $$DST_PATH\"; \\\n\tfi\n\n\n.PHONY: download-deps\ndownload-deps: download-ripgrep\n"
  },
  {
    "path": "src/kimi_cli/exception.py",
    "content": "from __future__ import annotations\n\n\nclass KimiCLIException(Exception):\n    \"\"\"Base exception class for Kimi Code CLI.\"\"\"\n\n    pass\n\n\nclass ConfigError(KimiCLIException, ValueError):\n    \"\"\"Configuration error.\"\"\"\n\n    pass\n\n\nclass AgentSpecError(KimiCLIException, ValueError):\n    \"\"\"Agent specification error.\"\"\"\n\n    pass\n\n\nclass InvalidToolError(KimiCLIException, ValueError):\n    \"\"\"Invalid tool error.\"\"\"\n\n    pass\n\n\nclass SystemPromptTemplateError(KimiCLIException, ValueError):\n    \"\"\"System prompt template error.\"\"\"\n\n    pass\n\n\nclass MCPConfigError(KimiCLIException, ValueError):\n    \"\"\"MCP config error.\"\"\"\n\n    pass\n\n\nclass MCPRuntimeError(KimiCLIException, RuntimeError):\n    \"\"\"MCP runtime error.\"\"\"\n\n    pass\n"
  },
  {
    "path": "src/kimi_cli/llm.py",
    "content": "from __future__ import annotations\n\nimport json\nimport os\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Literal, cast, get_args\n\nfrom kosong.chat_provider import ChatProvider\nfrom pydantic import SecretStr\n\nfrom kimi_cli.constant import USER_AGENT\n\nif TYPE_CHECKING:\n    from kimi_cli.auth.oauth import OAuthManager\n    from kimi_cli.config import LLMModel, LLMProvider\n\ntype ProviderType = Literal[\n    \"kimi\",\n    \"openai_legacy\",\n    \"openai_responses\",\n    \"anthropic\",\n    \"google_genai\",  # for backward-compatibility, equals to `gemini`\n    \"gemini\",\n    \"vertexai\",\n    \"_echo\",\n    \"_scripted_echo\",\n    \"_chaos\",\n]\n\ntype ModelCapability = Literal[\"image_in\", \"video_in\", \"thinking\", \"always_thinking\"]\nALL_MODEL_CAPABILITIES: set[ModelCapability] = set(get_args(ModelCapability.__value__))\n\n\n@dataclass(slots=True)\nclass LLM:\n    chat_provider: ChatProvider\n    max_context_size: int\n    capabilities: set[ModelCapability]\n    model_config: LLMModel | None = None\n    provider_config: LLMProvider | None = None\n\n    @property\n    def model_name(self) -> str:\n        return self.chat_provider.model_name\n\n\ndef model_display_name(model_name: str | None) -> str:\n    if not model_name:\n        return \"\"\n    if model_name in (\"kimi-for-coding\", \"kimi-code\"):\n        return f\"{model_name} (powered by kimi-k2.5)\"\n    return model_name\n\n\ndef augment_provider_with_env_vars(provider: LLMProvider, model: LLMModel) -> dict[str, str]:\n    \"\"\"Override provider/model settings from environment variables.\n\n    Returns:\n        Mapping of environment variables that were applied.\n    \"\"\"\n    applied: dict[str, str] = {}\n\n    match provider.type:\n        case \"kimi\":\n            if base_url := os.getenv(\"KIMI_BASE_URL\"):\n                provider.base_url = base_url\n                applied[\"KIMI_BASE_URL\"] = base_url\n            if api_key := os.getenv(\"KIMI_API_KEY\"):\n                provider.api_key = SecretStr(api_key)\n                applied[\"KIMI_API_KEY\"] = \"******\"\n            if model_name := os.getenv(\"KIMI_MODEL_NAME\"):\n                model.model = model_name\n                applied[\"KIMI_MODEL_NAME\"] = model_name\n            if max_context_size := os.getenv(\"KIMI_MODEL_MAX_CONTEXT_SIZE\"):\n                model.max_context_size = int(max_context_size)\n                applied[\"KIMI_MODEL_MAX_CONTEXT_SIZE\"] = max_context_size\n            if capabilities := os.getenv(\"KIMI_MODEL_CAPABILITIES\"):\n                caps_lower = (cap.strip().lower() for cap in capabilities.split(\",\") if cap.strip())\n                model.capabilities = set(\n                    cast(ModelCapability, cap)\n                    for cap in caps_lower\n                    if cap in get_args(ModelCapability.__value__)\n                )\n                applied[\"KIMI_MODEL_CAPABILITIES\"] = capabilities\n        case \"openai_legacy\" | \"openai_responses\":\n            if base_url := os.getenv(\"OPENAI_BASE_URL\"):\n                provider.base_url = base_url\n            if api_key := os.getenv(\"OPENAI_API_KEY\"):\n                provider.api_key = SecretStr(api_key)\n        case _:\n            pass\n\n    return applied\n\n\ndef _kimi_default_headers(provider: LLMProvider, oauth: OAuthManager | None) -> dict[str, str]:\n    headers = {\"User-Agent\": USER_AGENT}\n    if oauth:\n        headers.update(oauth.common_headers())\n    if provider.custom_headers:\n        headers.update(provider.custom_headers)\n    return headers\n\n\ndef create_llm(\n    provider: LLMProvider,\n    model: LLMModel,\n    *,\n    thinking: bool | None = None,\n    session_id: str | None = None,\n    oauth: OAuthManager | None = None,\n) -> LLM | None:\n    if provider.type not in {\"_echo\", \"_scripted_echo\"} and (\n        not provider.base_url or not model.model\n    ):\n        return None\n\n    resolved_api_key = (\n        oauth.resolve_api_key(provider.api_key, provider.oauth)\n        if oauth and provider.oauth\n        else provider.api_key.get_secret_value()\n    )\n\n    match provider.type:\n        case \"kimi\":\n            from kosong.chat_provider.kimi import Kimi\n\n            chat_provider = Kimi(\n                model=model.model,\n                base_url=provider.base_url,\n                api_key=resolved_api_key,\n                default_headers=_kimi_default_headers(provider, oauth),\n            )\n\n            gen_kwargs: Kimi.GenerationKwargs = {}\n            if session_id:\n                gen_kwargs[\"prompt_cache_key\"] = session_id\n            if temperature := os.getenv(\"KIMI_MODEL_TEMPERATURE\"):\n                gen_kwargs[\"temperature\"] = float(temperature)\n            if top_p := os.getenv(\"KIMI_MODEL_TOP_P\"):\n                gen_kwargs[\"top_p\"] = float(top_p)\n            if max_tokens := os.getenv(\"KIMI_MODEL_MAX_TOKENS\"):\n                gen_kwargs[\"max_tokens\"] = int(max_tokens)\n\n            if gen_kwargs:\n                chat_provider = chat_provider.with_generation_kwargs(**gen_kwargs)\n        case \"openai_legacy\":\n            from kosong.contrib.chat_provider.openai_legacy import OpenAILegacy\n\n            chat_provider = OpenAILegacy(\n                model=model.model,\n                base_url=provider.base_url,\n                api_key=resolved_api_key,\n            )\n        case \"openai_responses\":\n            from kosong.contrib.chat_provider.openai_responses import OpenAIResponses\n\n            chat_provider = OpenAIResponses(\n                model=model.model,\n                base_url=provider.base_url,\n                api_key=resolved_api_key,\n            )\n        case \"anthropic\":\n            from kosong.contrib.chat_provider.anthropic import Anthropic\n\n            chat_provider = Anthropic(\n                model=model.model,\n                base_url=provider.base_url,\n                api_key=resolved_api_key,\n                default_max_tokens=50000,\n                metadata={\"user_id\": session_id} if session_id else None,\n            )\n        case \"google_genai\" | \"gemini\":\n            from kosong.contrib.chat_provider.google_genai import GoogleGenAI\n\n            chat_provider = GoogleGenAI(\n                model=model.model,\n                base_url=provider.base_url,\n                api_key=resolved_api_key,\n            )\n        case \"vertexai\":\n            from kosong.contrib.chat_provider.google_genai import GoogleGenAI\n\n            os.environ.update(provider.env or {})\n            chat_provider = GoogleGenAI(\n                model=model.model,\n                base_url=provider.base_url,\n                api_key=resolved_api_key,\n                vertexai=True,\n            )\n        case \"_echo\":\n            from kosong.chat_provider.echo import EchoChatProvider\n\n            chat_provider = EchoChatProvider()\n        case \"_scripted_echo\":\n            from kosong.chat_provider.echo import ScriptedEchoChatProvider\n\n            if provider.env:\n                os.environ.update(provider.env)\n            scripts = _load_scripted_echo_scripts()\n            trace_value = os.getenv(\"KIMI_SCRIPTED_ECHO_TRACE\", \"\")\n            trace = trace_value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n            chat_provider = ScriptedEchoChatProvider(scripts, trace=trace)\n        case \"_chaos\":\n            from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig\n            from kosong.chat_provider.kimi import Kimi\n\n            chat_provider = ChaosChatProvider(\n                provider=Kimi(\n                    model=model.model,\n                    base_url=provider.base_url,\n                    api_key=resolved_api_key,\n                    default_headers=_kimi_default_headers(provider, oauth),\n                ),\n                chaos_config=ChaosConfig(\n                    error_probability=0.8,\n                    error_types=[429, 500, 503],\n                ),\n            )\n\n    capabilities = derive_model_capabilities(model)\n\n    # Apply thinking if specified or if model always requires thinking\n    if \"always_thinking\" in capabilities or (thinking is True and \"thinking\" in capabilities):\n        chat_provider = chat_provider.with_thinking(\"high\")\n    elif thinking is False:\n        chat_provider = chat_provider.with_thinking(\"off\")\n    # If thinking is None and model doesn't always think, leave as-is (default behavior)\n\n    return LLM(\n        chat_provider=chat_provider,\n        max_context_size=model.max_context_size,\n        capabilities=capabilities,\n        model_config=model,\n        provider_config=provider,\n    )\n\n\ndef derive_model_capabilities(model: LLMModel) -> set[ModelCapability]:\n    capabilities = set(model.capabilities or ())\n    # Models with \"thinking\" in their name are always-thinking models\n    if \"thinking\" in model.model.lower() or \"reason\" in model.model.lower():\n        capabilities.update((\"thinking\", \"always_thinking\"))\n    # These models support thinking but can be toggled on/off\n    elif model.model in {\"kimi-for-coding\", \"kimi-code\"}:\n        capabilities.update((\"thinking\", \"image_in\", \"video_in\"))\n    return capabilities\n\n\ndef _load_scripted_echo_scripts() -> list[str]:\n    script_path = os.getenv(\"KIMI_SCRIPTED_ECHO_SCRIPTS\")\n    if not script_path:\n        raise ValueError(\"KIMI_SCRIPTED_ECHO_SCRIPTS is required for _scripted_echo.\")\n    path = Path(script_path).expanduser()\n    if not path.exists():\n        raise ValueError(f\"Scripted echo file not found: {path}\")\n    text = path.read_text(encoding=\"utf-8\")\n    try:\n        data: object = json.loads(text)\n    except json.JSONDecodeError:\n        scripts = [chunk.strip() for chunk in text.split(\"\\n---\\n\") if chunk.strip()]\n        if scripts:\n            return scripts\n        raise ValueError(\n            \"Scripted echo file must be a JSON array of strings or a text file \"\n            \"split by '\\\\n---\\\\n'.\"\n        ) from None\n    if isinstance(data, list):\n        data_list = cast(list[object], data)\n        if all(isinstance(item, str) for item in data_list):\n            return cast(list[str], data_list)\n    raise ValueError(\"Scripted echo JSON must be an array of strings.\")\n"
  },
  {
    "path": "src/kimi_cli/metadata.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom hashlib import md5\nfrom pathlib import Path\n\nfrom kaos import get_current_kaos\nfrom kaos.local import local_kaos\nfrom kaos.path import KaosPath\nfrom pydantic import BaseModel, ConfigDict, Field\n\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.utils.io import atomic_json_write\nfrom kimi_cli.utils.logging import logger\n\n\ndef get_metadata_file() -> Path:\n    return get_share_dir() / \"kimi.json\"\n\n\nclass WorkDirMeta(BaseModel):\n    \"\"\"Metadata for a work directory.\"\"\"\n\n    path: str\n    \"\"\"The full path of the work directory.\"\"\"\n\n    kaos: str = local_kaos.name\n    \"\"\"The name of the KAOS where the work directory is located.\"\"\"\n\n    last_session_id: str | None = None\n    \"\"\"Last session ID of this work directory.\"\"\"\n\n    @property\n    def sessions_dir(self) -> Path:\n        \"\"\"The directory to store sessions for this work directory.\"\"\"\n        path_md5 = md5(self.path.encode(encoding=\"utf-8\")).hexdigest()\n        dir_basename = path_md5 if self.kaos == local_kaos.name else f\"{self.kaos}_{path_md5}\"\n        session_dir = get_share_dir() / \"sessions\" / dir_basename\n        session_dir.mkdir(parents=True, exist_ok=True)\n        return session_dir\n\n\nclass Metadata(BaseModel):\n    \"\"\"Kimi metadata structure.\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n    work_dirs: list[WorkDirMeta] = Field(default_factory=list[WorkDirMeta])\n    \"\"\"Work directory list.\"\"\"\n\n    def get_work_dir_meta(self, path: KaosPath) -> WorkDirMeta | None:\n        \"\"\"Get the metadata for a work directory.\"\"\"\n        for wd in self.work_dirs:\n            if wd.path == str(path) and wd.kaos == get_current_kaos().name:\n                return wd\n        return None\n\n    def new_work_dir_meta(self, path: KaosPath) -> WorkDirMeta:\n        \"\"\"Create a new work directory metadata.\"\"\"\n        wd_meta = WorkDirMeta(path=str(path), kaos=get_current_kaos().name)\n        self.work_dirs.append(wd_meta)\n        return wd_meta\n\n\ndef load_metadata() -> Metadata:\n    metadata_file = get_metadata_file()\n    logger.debug(\"Loading metadata from file: {file}\", file=metadata_file)\n    if not metadata_file.exists():\n        logger.debug(\"No metadata file found, creating empty metadata\")\n        return Metadata()\n    with open(metadata_file, encoding=\"utf-8\") as f:\n        data = json.load(f)\n        return Metadata(**data)\n\n\ndef save_metadata(metadata: Metadata):\n    metadata_file = get_metadata_file()\n    logger.debug(\"Saving metadata to file: {file}\", file=metadata_file)\n    atomic_json_write(metadata.model_dump(), metadata_file)\n"
  },
  {
    "path": "src/kimi_cli/notifications/__init__.py",
    "content": "from .llm import build_notification_message, extract_notification_ids, is_notification_message\nfrom .manager import NotificationManager\nfrom .models import (\n    NotificationCategory,\n    NotificationDelivery,\n    NotificationDeliveryStatus,\n    NotificationEvent,\n    NotificationSeverity,\n    NotificationSink,\n    NotificationSinkState,\n    NotificationView,\n)\nfrom .notifier import NotificationWatcher\nfrom .store import NotificationStore\nfrom .wire import to_wire_notification\n\n__all__ = [\n    \"NotificationCategory\",\n    \"NotificationDelivery\",\n    \"NotificationDeliveryStatus\",\n    \"NotificationEvent\",\n    \"NotificationManager\",\n    \"NotificationSeverity\",\n    \"NotificationSink\",\n    \"NotificationSinkState\",\n    \"NotificationStore\",\n    \"NotificationView\",\n    \"NotificationWatcher\",\n    \"build_notification_message\",\n    \"extract_notification_ids\",\n    \"is_notification_message\",\n    \"to_wire_notification\",\n]\n"
  },
  {
    "path": "src/kimi_cli/notifications/llm.py",
    "content": "from __future__ import annotations\n\nimport re\nfrom collections.abc import Sequence\nfrom typing import TYPE_CHECKING\n\nfrom kosong.message import Message\n\nfrom kimi_cli.wire.types import TextPart\n\nfrom .models import NotificationView\n\nif TYPE_CHECKING:\n    from kimi_cli.soul.agent import Runtime\n\n_NOTIFICATION_ID_RE = re.compile(r'<notification id=\"([^\"]+)\"')\n\n\ndef build_notification_message(view: NotificationView, runtime: Runtime) -> Message:\n    event = view.event\n    lines = [\n        (\n            f'<notification id=\"{event.id}\" category=\"{event.category}\" '\n            f'type=\"{event.type}\" source_kind=\"{event.source_kind}\" source_id=\"{event.source_id}\">'\n        ),\n        f\"Title: {event.title}\",\n        f\"Severity: {event.severity}\",\n        event.body,\n    ]\n\n    if event.category == \"task\" and event.source_kind == \"background_task\":\n        task_view = runtime.background_tasks.get_task(event.source_id)\n        if task_view is not None:\n            tail = runtime.background_tasks.store.tail_output(\n                task_view.spec.id,\n                max_bytes=runtime.config.background.notification_tail_chars,\n                max_lines=runtime.config.background.notification_tail_lines,\n            )\n            lines.extend(\n                [\n                    \"<task-notification>\",\n                    f\"Task ID: {task_view.spec.id}\",\n                    f\"Task Type: {task_view.spec.kind}\",\n                    f\"Description: {task_view.spec.description}\",\n                    f\"Status: {task_view.runtime.status}\",\n                ]\n            )\n            if task_view.runtime.exit_code is not None:\n                lines.append(f\"Exit code: {task_view.runtime.exit_code}\")\n            if task_view.runtime.failure_reason:\n                lines.append(f\"Failure reason: {task_view.runtime.failure_reason}\")\n            if tail:\n                lines.extend([\"Output tail:\", tail])\n            lines.append(\"</task-notification>\")\n\n    lines.append(\"</notification>\")\n    return Message(role=\"user\", content=[TextPart(text=\"\\n\".join(lines))])\n\n\ndef extract_notification_ids(history: Sequence[Message]) -> set[str]:\n    ids: set[str] = set()\n    for message in history:\n        if message.role != \"user\":\n            continue\n        for part in message.content:\n            if not isinstance(part, TextPart):\n                continue\n            for match in _NOTIFICATION_ID_RE.finditer(part.text):\n                ids.add(match.group(1))\n    return ids\n\n\ndef is_notification_message(message: Message) -> bool:\n    if message.role != \"user\" or len(message.content) != 1:\n        return False\n    part = message.content[0]\n    return isinstance(part, TextPart) and part.text.lstrip().startswith(\"<notification \")\n"
  },
  {
    "path": "src/kimi_cli/notifications/manager.py",
    "content": "from __future__ import annotations\n\nimport time\nimport uuid\nfrom collections.abc import Awaitable, Callable\nfrom pathlib import Path\n\nfrom kimi_cli.config import NotificationConfig\nfrom kimi_cli.utils.logging import logger\n\nfrom .models import (\n    NotificationDelivery,\n    NotificationEvent,\n    NotificationSinkState,\n    NotificationView,\n)\nfrom .store import NotificationStore\n\n\nclass NotificationManager:\n    def __init__(self, root: Path, config: NotificationConfig) -> None:\n        self._config = config\n        self._store = NotificationStore(root)\n\n    @property\n    def store(self) -> NotificationStore:\n        return self._store\n\n    def new_id(self) -> str:\n        return f\"n{uuid.uuid4().hex[:8]}\"\n\n    def _initial_delivery(self, event: NotificationEvent) -> NotificationDelivery:\n        return NotificationDelivery(sinks={sink: NotificationSinkState() for sink in event.targets})\n\n    def find_by_dedupe_key(self, dedupe_key: str) -> NotificationView | None:\n        for view in self._store.list_views():\n            if view.event.dedupe_key == dedupe_key:\n                return view\n        return None\n\n    def publish(self, event: NotificationEvent) -> NotificationView:\n        if event.dedupe_key:\n            existing = self.find_by_dedupe_key(event.dedupe_key)\n            if existing is not None:\n                return existing\n        delivery = self._initial_delivery(event)\n        self._store.create_notification(event, delivery)\n        return NotificationView(event=event, delivery=delivery)\n\n    def recover(self) -> None:\n        now = time.time()\n        stale_after = self._config.claim_stale_after_ms / 1000\n        for view in self._store.list_views():\n            updated = False\n            delivery = view.delivery.model_copy(deep=True)\n            for sink_state in delivery.sinks.values():\n                if sink_state.status != \"claimed\" or sink_state.claimed_at is None:\n                    continue\n                if now - sink_state.claimed_at <= stale_after:\n                    continue\n                sink_state.status = \"pending\"\n                sink_state.claimed_at = None\n                updated = True\n            if updated:\n                self._store.write_delivery(view.event.id, delivery)\n\n    def claim_for_sink(self, sink: str, *, limit: int = 8) -> list[NotificationView]:\n        self.recover()\n        claimed: list[NotificationView] = []\n        now = time.time()\n        for view in reversed(self._store.list_views()):\n            sink_state = view.delivery.sinks.get(sink)\n            if sink_state is None or sink_state.status == \"acked\":\n                continue\n            if sink_state.status == \"claimed\":\n                continue\n            delivery = view.delivery.model_copy(deep=True)\n            target_state = delivery.sinks[sink]\n            target_state.status = \"claimed\"\n            target_state.claimed_at = now\n            self._store.write_delivery(view.event.id, delivery)\n            claimed.append(NotificationView(event=view.event, delivery=delivery))\n            if len(claimed) >= limit:\n                break\n        return claimed\n\n    async def deliver_pending(\n        self,\n        sink: str,\n        *,\n        on_notification: Callable[[NotificationView], Awaitable[None] | None],\n        limit: int = 8,\n        before_claim: Callable[[], object] | None = None,\n    ) -> list[NotificationView]:\n        \"\"\"Deliver pending notifications for one sink using a shared claim/ack flow.\n\n        If the handler raises for a notification, the error is logged and that\n        notification stays in ``claimed`` state (will be recovered later).\n        Delivery continues for remaining notifications.\n        \"\"\"\n        if before_claim is not None:\n            before_claim()\n\n        delivered: list[NotificationView] = []\n        for view in self.claim_for_sink(sink, limit=limit):\n            try:\n                result = on_notification(view)\n                if result is not None:\n                    await result\n            except Exception:\n                logger.exception(\n                    \"Notification handler failed for {sink}/{id}, leaving claimed for recovery\",\n                    sink=sink,\n                    id=view.event.id,\n                )\n                continue\n            delivered.append(self.ack(sink, view.event.id))\n        return delivered\n\n    def ack(self, sink: str, notification_id: str) -> NotificationView:\n        view = self._store.merged_view(notification_id)\n        delivery = view.delivery.model_copy(deep=True)\n        sink_state = delivery.sinks.get(sink)\n        if sink_state is None:\n            return view\n        sink_state.status = \"acked\"\n        sink_state.acked_at = time.time()\n        sink_state.claimed_at = None\n        self._store.write_delivery(notification_id, delivery)\n        return NotificationView(event=view.event, delivery=delivery)\n\n    def ack_ids(self, sink: str, notification_ids: set[str]) -> None:\n        for notification_id in notification_ids:\n            try:\n                self.ack(sink, notification_id)\n            except (FileNotFoundError, ValueError):\n                continue\n"
  },
  {
    "path": "src/kimi_cli/notifications/models.py",
    "content": "from __future__ import annotations\n\nimport time\nfrom typing import Any, Literal\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\ntype NotificationCategory = Literal[\"task\", \"agent\", \"system\"]\ntype NotificationSeverity = Literal[\"info\", \"success\", \"warning\", \"error\"]\ntype NotificationSink = Literal[\"llm\", \"wire\", \"shell\"]\ntype NotificationDeliveryStatus = Literal[\"pending\", \"claimed\", \"acked\"]\n\n\nclass NotificationEvent(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    version: int = 1\n    id: str\n    category: NotificationCategory\n    type: str\n    source_kind: str\n    source_id: str\n    title: str\n    body: str\n    severity: NotificationSeverity = \"info\"\n    created_at: float = Field(default_factory=time.time)\n    payload: dict[str, Any] = Field(default_factory=dict)\n    targets: list[NotificationSink] = Field(default_factory=lambda: [\"llm\", \"wire\", \"shell\"])\n    dedupe_key: str | None = None\n\n\nclass NotificationSinkState(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    status: NotificationDeliveryStatus = \"pending\"\n    claimed_at: float | None = None\n    acked_at: float | None = None\n\n\nclass NotificationDelivery(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    sinks: dict[str, NotificationSinkState] = Field(default_factory=dict)\n\n\nclass NotificationView(BaseModel):\n    model_config = ConfigDict(extra=\"ignore\")\n\n    event: NotificationEvent\n    delivery: NotificationDelivery\n"
  },
  {
    "path": "src/kimi_cli/notifications/notifier.py",
    "content": "import asyncio\nfrom collections.abc import Awaitable, Callable\n\nfrom kimi_cli.utils.logging import logger\n\nfrom .manager import NotificationManager\nfrom .models import NotificationSink, NotificationView\n\n\nclass NotificationWatcher:\n    def __init__(\n        self,\n        manager: NotificationManager,\n        *,\n        sink: NotificationSink,\n        on_notification: Callable[[NotificationView], Awaitable[None] | None],\n        before_poll: Callable[[], object] | None = None,\n        interval_s: float = 1.0,\n    ) -> None:\n        self._manager = manager\n        self._sink = sink\n        self._on_notification = on_notification\n        self._before_poll = before_poll\n        self._interval_s = interval_s\n\n    async def poll_once(self) -> list[NotificationView]:\n        return await self._manager.deliver_pending(\n            self._sink,\n            on_notification=self._on_notification,\n            before_claim=self._before_poll,\n        )\n\n    async def run_forever(self) -> None:\n        while True:\n            try:\n                await self.poll_once()\n            except asyncio.CancelledError:\n                raise\n            except Exception:\n                logger.exception(\"NotificationWatcher poll failed\")\n            await asyncio.sleep(self._interval_s)\n"
  },
  {
    "path": "src/kimi_cli/notifications/store.py",
    "content": "from __future__ import annotations\n\nimport re\nfrom pathlib import Path\n\nfrom kimi_cli.utils.io import atomic_json_write\n\nfrom .models import NotificationDelivery, NotificationEvent, NotificationView\n\n_VALID_NOTIFICATION_ID = re.compile(r\"^[a-z0-9]{2,20}$\")\n\n\ndef _validate_notification_id(notification_id: str) -> None:\n    if not _VALID_NOTIFICATION_ID.match(notification_id):\n        raise ValueError(f\"Invalid notification_id: {notification_id!r}\")\n\n\nclass NotificationStore:\n    EVENT_FILE = \"event.json\"\n    DELIVERY_FILE = \"delivery.json\"\n\n    def __init__(self, root: Path):\n        self._root = root\n\n    @property\n    def root(self) -> Path:\n        return self._root\n\n    def _ensure_root(self) -> Path:\n        \"\"\"Return the root directory, creating it if it does not exist.\"\"\"\n        self._root.mkdir(parents=True, exist_ok=True)\n        return self._root\n\n    def notification_dir(self, notification_id: str) -> Path:\n        _validate_notification_id(notification_id)\n        path = self._ensure_root() / notification_id\n        path.mkdir(parents=True, exist_ok=True)\n        return path\n\n    def notification_path(self, notification_id: str) -> Path:\n        _validate_notification_id(notification_id)\n        return self.root / notification_id\n\n    def event_path(self, notification_id: str) -> Path:\n        return self.notification_path(notification_id) / self.EVENT_FILE\n\n    def delivery_path(self, notification_id: str) -> Path:\n        return self.notification_path(notification_id) / self.DELIVERY_FILE\n\n    def create_notification(\n        self,\n        event: NotificationEvent,\n        delivery: NotificationDelivery,\n    ) -> None:\n        notification_dir = self.notification_dir(event.id)\n        atomic_json_write(event.model_dump(mode=\"json\"), notification_dir / self.EVENT_FILE)\n        atomic_json_write(delivery.model_dump(mode=\"json\"), notification_dir / self.DELIVERY_FILE)\n\n    def list_notification_ids(self) -> list[str]:\n        if not self.root.exists():\n            return []\n        notification_ids: list[str] = []\n        for path in sorted(self.root.iterdir()):\n            if not path.is_dir():\n                continue\n            if not (path / self.EVENT_FILE).exists():\n                continue\n            notification_ids.append(path.name)\n        return notification_ids\n\n    def read_event(self, notification_id: str) -> NotificationEvent:\n        return NotificationEvent.model_validate_json(\n            self.event_path(notification_id).read_text(encoding=\"utf-8\")\n        )\n\n    def write_event(self, event: NotificationEvent) -> None:\n        atomic_json_write(event.model_dump(mode=\"json\"), self.event_path(event.id))\n\n    def read_delivery(self, notification_id: str) -> NotificationDelivery:\n        path = self.delivery_path(notification_id)\n        if not path.exists():\n            return NotificationDelivery()\n        return NotificationDelivery.model_validate_json(path.read_text(encoding=\"utf-8\"))\n\n    def write_delivery(self, notification_id: str, delivery: NotificationDelivery) -> None:\n        atomic_json_write(delivery.model_dump(mode=\"json\"), self.delivery_path(notification_id))\n\n    def merged_view(self, notification_id: str) -> NotificationView:\n        return NotificationView(\n            event=self.read_event(notification_id),\n            delivery=self.read_delivery(notification_id),\n        )\n\n    def list_views(self) -> list[NotificationView]:\n        views = [\n            self.merged_view(notification_id) for notification_id in self.list_notification_ids()\n        ]\n        views.sort(key=lambda view: view.event.created_at, reverse=True)\n        return views\n"
  },
  {
    "path": "src/kimi_cli/notifications/wire.py",
    "content": "from __future__ import annotations\n\nfrom kimi_cli.wire.types import Notification\n\nfrom .models import NotificationView\n\n\ndef to_wire_notification(view: NotificationView) -> Notification:\n    event = view.event\n    return Notification(\n        id=event.id,\n        category=event.category,\n        type=event.type,\n        source_kind=event.source_kind,\n        source_id=event.source_id,\n        title=event.title,\n        body=event.body,\n        severity=event.severity,\n        created_at=event.created_at,\n        payload=event.payload,\n    )\n"
  },
  {
    "path": "src/kimi_cli/plugin/__init__.py",
    "content": "\"\"\"Plugin specification parsing and config injection.\"\"\"\n\nimport json\nfrom pathlib import Path\nfrom typing import Any\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass PluginError(Exception):\n    \"\"\"Raised when plugin.json is invalid or an operation fails.\"\"\"\n\n\nclass PluginRuntime(BaseModel):\n    \"\"\"Runtime information written by the host after installation.\"\"\"\n\n    host: str\n    host_version: str\n\n\nclass PluginToolSpec(BaseModel):\n    \"\"\"A tool declared by a plugin.\"\"\"\n\n    name: str\n    description: str\n    command: list[str]\n    parameters: dict[str, object] = Field(default_factory=dict)\n\n\nclass PluginSpec(BaseModel):\n    \"\"\"Parsed representation of a plugin.json file.\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n    name: str\n    version: str\n    description: str = \"\"\n    config_file: str | None = None\n    inject: dict[str, str] = Field(default_factory=dict)\n    tools: list[PluginToolSpec] = Field(default_factory=list)  # pyright: ignore[reportUnknownVariableType]\n    runtime: PluginRuntime | None = None\n\n\nPLUGIN_JSON = \"plugin.json\"\n\n\ndef parse_plugin_json(path: Path) -> PluginSpec:\n    \"\"\"Parse a plugin.json file and return a validated PluginSpec.\"\"\"\n    try:\n        data = json.loads(path.read_text(encoding=\"utf-8\"))\n    except (OSError, json.JSONDecodeError) as exc:\n        raise PluginError(f\"Failed to read {path}: {exc}\") from exc\n\n    if \"name\" not in data:\n        raise PluginError(f\"Missing required field 'name' in {path}\")\n    if \"version\" not in data:\n        raise PluginError(f\"Missing required field 'version' in {path}\")\n    if data.get(\"inject\") and not data.get(\"config_file\"):\n        raise PluginError(f\"'inject' requires 'config_file' in {path}\")\n\n    try:\n        return PluginSpec.model_validate(data)\n    except Exception as exc:\n        raise PluginError(f\"Invalid plugin.json schema in {path}: {exc}\") from exc\n\n\ndef inject_config(plugin_dir: Path, spec: PluginSpec, values: dict[str, str]) -> None:\n    \"\"\"Inject host values into the plugin's config file.\n\n    Args:\n        plugin_dir: Root directory of the installed plugin.\n        spec: Parsed plugin spec.\n        values: Map of standard inject keys to actual values (e.g. {\"api_key\": \"sk-xxx\"}).\n    \"\"\"\n    if not spec.inject or not spec.config_file:\n        return\n\n    config_path = (plugin_dir / spec.config_file).resolve()\n    if not config_path.is_relative_to(plugin_dir.resolve()):\n        raise PluginError(f\"config_file escapes plugin directory: {spec.config_file}\")\n    if not config_path.exists():\n        raise PluginError(f\"Config file not found: {config_path}\")\n\n    try:\n        config = json.loads(config_path.read_text(encoding=\"utf-8\"))\n    except (OSError, json.JSONDecodeError) as exc:\n        raise PluginError(f\"Failed to read config file {config_path}: {exc}\") from exc\n\n    for target_path, source_key in spec.inject.items():\n        if source_key not in values:\n            raise PluginError(f\"Host does not provide required inject key '{source_key}'\")\n        _set_nested(config, target_path, values[source_key])\n\n    config_path.write_text(\n        json.dumps(config, ensure_ascii=False, indent=2),\n        encoding=\"utf-8\",\n    )\n\n\ndef write_runtime(plugin_dir: Path, runtime: PluginRuntime) -> None:\n    \"\"\"Write runtime info into plugin.json.\"\"\"\n    plugin_json_path = plugin_dir / PLUGIN_JSON\n    try:\n        data = json.loads(plugin_json_path.read_text(encoding=\"utf-8\"))\n    except (OSError, json.JSONDecodeError) as exc:\n        raise PluginError(f\"Failed to read {plugin_json_path}: {exc}\") from exc\n    data[\"runtime\"] = runtime.model_dump()\n    plugin_json_path.write_text(\n        json.dumps(data, ensure_ascii=False, indent=2),\n        encoding=\"utf-8\",\n    )\n\n\ndef _set_nested(obj: dict[str, Any], dotted_path: str, value: object) -> None:\n    \"\"\"Set a value in a nested dict using dot-separated path.\n\n    Creates intermediate dicts if they don't exist.\n    \"\"\"\n    keys = dotted_path.split(\".\")\n    for key in keys[:-1]:\n        if key not in obj or not isinstance(obj[key], dict):\n            obj[key] = {}\n        obj = obj[key]\n    obj[keys[-1]] = value\n"
  },
  {
    "path": "src/kimi_cli/plugin/manager.py",
    "content": "\"\"\"Plugin installation, removal, and listing.\"\"\"\n\nfrom __future__ import annotations\n\nimport shutil\nimport tempfile\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nfrom kimi_cli.plugin import (\n    PLUGIN_JSON,\n    PluginError,\n    PluginRuntime,\n    PluginSpec,\n    inject_config,\n    parse_plugin_json,\n    write_runtime,\n)\nfrom kimi_cli.share import get_share_dir\n\nif TYPE_CHECKING:\n    from kimi_cli.auth.oauth import OAuthManager\n    from kimi_cli.config import Config\n\n\ndef get_plugins_dir() -> Path:\n    \"\"\"Return the plugins installation directory (~/.kimi/plugins/).\"\"\"\n    return get_share_dir() / \"plugins\"\n\n\ndef collect_host_values(config: Config, oauth: OAuthManager) -> dict[str, str]:\n    \"\"\"Collect host values (api_key, base_url) for plugin injection.\n\n    Resolves credentials from the default provider, handling OAuth tokens\n    and static API keys.  Callers that run outside the normal startup flow\n    (e.g. ``install_cmd``) should apply environment-variable overrides\n    (``augment_provider_with_env_vars``) to the provider **before** calling\n    this function; the main app startup already does that.\n    \"\"\"\n    values: dict[str, str] = {}\n    if not config.default_model or config.default_model not in config.models:\n        return values\n    model = config.models[config.default_model]\n    if model.provider not in config.providers:\n        return values\n    provider = config.providers[model.provider]\n    api_key = oauth.resolve_api_key(provider.api_key, provider.oauth)\n    if api_key:\n        values[\"api_key\"] = api_key\n    values[\"base_url\"] = provider.base_url\n    return values\n\n\ndef _validate_name(name: str, plugins_dir: Path) -> Path:\n    \"\"\"Resolve and validate plugin name, returning the safe destination path.\"\"\"\n    dest = (plugins_dir / name).resolve()\n    if not dest.is_relative_to(plugins_dir.resolve()):\n        raise PluginError(f\"Invalid plugin name: {name}\")\n    return dest\n\n\ndef install_plugin(\n    *,\n    source: Path,\n    plugins_dir: Path,\n    host_values: dict[str, str],\n    host_name: str,\n    host_version: str,\n) -> PluginSpec:\n    \"\"\"Install a plugin from a source directory.\n\n    Stages the new copy to a temp dir first, so a failed upgrade\n    does not destroy the previous installation.\n    \"\"\"\n    source_plugin_json = source / PLUGIN_JSON\n    if not source_plugin_json.exists():\n        raise PluginError(f\"No plugin.json found in {source}\")\n\n    spec = parse_plugin_json(source_plugin_json)\n    dest = _validate_name(spec.name, plugins_dir)\n\n    # Stage to a temp dir inside plugins_dir so rename is atomic on same fs\n    plugins_dir.mkdir(parents=True, exist_ok=True)\n    staging = Path(tempfile.mkdtemp(prefix=f\".{spec.name}-\", dir=plugins_dir))\n    try:\n        # Copy source into staging\n        staging_plugin = staging / spec.name\n        shutil.copytree(source, staging_plugin)\n\n        # Apply inject + runtime on the staged copy\n        inject_config(staging_plugin, spec, host_values)\n        runtime = PluginRuntime(host=host_name, host_version=host_version)\n        write_runtime(staging_plugin, runtime)\n\n        # Swap: remove old, move staged into place\n        if dest.exists():\n            shutil.rmtree(dest)\n        staging_plugin.rename(dest)\n    except Exception:\n        # On any failure, clean up staging but leave existing install intact\n        shutil.rmtree(staging, ignore_errors=True)\n        raise\n    finally:\n        # Clean up staging dir shell (may be empty after successful rename)\n        shutil.rmtree(staging, ignore_errors=True)\n\n    # Re-read to return the installed spec (with runtime)\n    return parse_plugin_json(dest / PLUGIN_JSON)\n\n\ndef refresh_plugin_configs(plugins_dir: Path, host_values: dict[str, str]) -> None:\n    \"\"\"Re-inject host values into all installed plugin config files.\n\n    Called at startup so that OAuth tokens and other credentials\n    stay fresh even after the initial install.\n    \"\"\"\n    if not plugins_dir.is_dir():\n        return\n\n    for child in sorted(plugins_dir.iterdir()):\n        plugin_json = child / PLUGIN_JSON\n        if not child.is_dir() or not plugin_json.is_file():\n            continue\n        try:\n            spec = parse_plugin_json(plugin_json)\n            if spec.inject and spec.config_file:\n                inject_config(child, spec, host_values)\n        except Exception:\n            continue\n\n\ndef list_plugins(plugins_dir: Path) -> list[PluginSpec]:\n    \"\"\"List all installed plugins.\"\"\"\n    if not plugins_dir.is_dir():\n        return []\n\n    plugins: list[PluginSpec] = []\n    for child in sorted(plugins_dir.iterdir()):\n        plugin_json = child / PLUGIN_JSON\n        if child.is_dir() and plugin_json.is_file():\n            try:\n                plugins.append(parse_plugin_json(plugin_json))\n            except PluginError:\n                continue\n    return plugins\n\n\ndef remove_plugin(name: str, plugins_dir: Path) -> None:\n    \"\"\"Remove an installed plugin.\"\"\"\n    dest = _validate_name(name, plugins_dir)\n    if not dest.exists():\n        raise PluginError(f\"Plugin '{name}' not found in {plugins_dir}\")\n    shutil.rmtree(dest)\n"
  },
  {
    "path": "src/kimi_cli/plugin/tool.py",
    "content": "\"\"\"Plugin tool wrapper — runs plugin-declared tools as subprocesses.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nfrom kosong.tooling import CallableTool, ToolError, ToolOk\nfrom kosong.tooling.error import ToolRuntimeError\nfrom loguru import logger\n\nfrom kimi_cli.plugin import PluginToolSpec\nfrom kimi_cli.tools.utils import ToolRejectedError\nfrom kimi_cli.utils.subprocess_env import get_clean_env\nfrom kimi_cli.wire.types import ToolReturnValue\n\nif TYPE_CHECKING:\n    from kimi_cli.config import Config\n    from kimi_cli.soul.approval import Approval\n\n\ndef _get_host_values(config: Config) -> dict[str, str]:\n    \"\"\"Extract current host values (api_key, base_url) from config.\n\n    Reads the latest provider credentials, which may have been\n    refreshed by OAuth since plugin install time.\n    \"\"\"\n    from kimi_cli.auth.oauth import OAuthManager\n    from kimi_cli.plugin.manager import collect_host_values\n\n    oauth = OAuthManager(config)\n    return collect_host_values(config, oauth)\n\n\nclass PluginTool(CallableTool):\n    \"\"\"A tool that executes a plugin command in a subprocess.\n\n    Parameters are passed via stdin as JSON.\n    stdout is captured as the tool result.\n    Host credentials are injected as environment variables at runtime\n    (not baked into config files) to handle OAuth token refresh.\n    \"\"\"\n\n    def __init__(\n        self,\n        tool_spec: PluginToolSpec,\n        plugin_dir: Path,\n        *,\n        inject: dict[str, str],\n        config: Config,\n        approval: Approval | None = None,\n        **kwargs: Any,\n    ):\n        super().__init__(\n            name=tool_spec.name,\n            description=tool_spec.description,\n            parameters=tool_spec.parameters or {\"type\": \"object\", \"properties\": {}},\n            **kwargs,\n        )\n        self._command = tool_spec.command\n        self._plugin_dir = plugin_dir\n        self._inject = inject  # e.g. {\"kimiCodeAPIKey\": \"api_key\"}\n        self._config = config\n        self._approval = approval\n\n    def _build_env(self) -> dict[str, str]:\n        \"\"\"Build env vars with fresh host credentials for the subprocess.\"\"\"\n        env = get_clean_env()\n        if self._inject:\n            host_values = _get_host_values(self._config)\n            for target_key, source_key in self._inject.items():\n                if source_key in host_values:\n                    # Inject as env var using the plugin's config key name\n                    # e.g. kimiCodeAPIKey=<fresh api_key>\n                    env[target_key] = host_values[source_key]\n        return env\n\n    async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:\n        if self._approval is not None:\n            description = f\"Run plugin tool `{self.name}`.\"\n            if not await self._approval.request(self.name, f\"plugin:{self.name}\", description):\n                return ToolRejectedError()\n\n        params_json = json.dumps(kwargs, ensure_ascii=False)\n\n        try:\n            proc = await asyncio.create_subprocess_exec(\n                *self._command,\n                stdin=asyncio.subprocess.PIPE,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n                cwd=str(self._plugin_dir),\n                env=self._build_env(),\n            )\n        except Exception as exc:\n            return ToolRuntimeError(str(exc))\n\n        try:\n            stdout, stderr = await asyncio.wait_for(\n                proc.communicate(input=params_json.encode(\"utf-8\")),\n                timeout=120,\n            )\n        except asyncio.CancelledError:\n            proc.kill()\n            await proc.wait()\n            raise\n        except TimeoutError:\n            proc.kill()\n            await proc.wait()\n            return ToolError(\n                message=f\"Plugin tool '{self.name}' timed out after 120s.\",\n                brief=\"Timeout\",\n            )\n\n        output = stdout.decode(\"utf-8\", errors=\"replace\").strip()\n        err_output = stderr.decode(\"utf-8\", errors=\"replace\").strip()\n\n        if proc.returncode != 0:\n            error_msg = err_output or output or f\"Exit code {proc.returncode}\"\n            return ToolError(\n                message=f\"Plugin tool '{self.name}' failed: {error_msg}\",\n                brief=f\"Exit {proc.returncode}\",\n            )\n\n        if err_output:\n            logger.debug(\"Plugin tool {name} stderr: {err}\", name=self.name, err=err_output)\n\n        return ToolOk(output=output)\n\n\ndef load_plugin_tools(\n    plugins_dir: Path, config: Config, *, approval: Approval | None = None\n) -> list[PluginTool]:\n    \"\"\"Scan installed plugins and create PluginTool instances for declared tools.\"\"\"\n    from kimi_cli.plugin import PLUGIN_JSON, PluginError, parse_plugin_json\n\n    if not plugins_dir.is_dir():\n        return []\n\n    tools: list[PluginTool] = []\n    for child in sorted(plugins_dir.iterdir()):\n        plugin_json = child / PLUGIN_JSON\n        if not child.is_dir() or not plugin_json.is_file():\n            continue\n        try:\n            spec = parse_plugin_json(plugin_json)\n        except PluginError:\n            continue\n        for tool_spec in spec.tools:\n            try:\n                tool = PluginTool(\n                    tool_spec,\n                    plugin_dir=child,\n                    inject=spec.inject,\n                    config=config,\n                    approval=approval,\n                )\n            except Exception:\n                logger.warning(\n                    \"Skipping invalid plugin tool: {name} (from {plugin})\",\n                    name=tool_spec.name,\n                    plugin=spec.name,\n                )\n                continue\n            tools.append(tool)\n            logger.info(\n                \"Loaded plugin tool: {name} (from {plugin})\",\n                name=tool_spec.name,\n                plugin=spec.name,\n            )\n    return tools\n"
  },
  {
    "path": "src/kimi_cli/prompts/__init__.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\nINIT = (Path(__file__).parent / \"init.md\").read_text(encoding=\"utf-8\")\nCOMPACT = (Path(__file__).parent / \"compact.md\").read_text(encoding=\"utf-8\")\n"
  },
  {
    "path": "src/kimi_cli/prompts/compact.md",
    "content": "\n---\n\nThe above is a list of messages in an agent conversation. You are now given a task to compact this conversation context according to specific priorities and rules.\n\n**Compression Priorities (in order):**\n1. **Current Task State**: What is being worked on RIGHT NOW\n2. **Errors & Solutions**: All encountered errors and their resolutions\n3. **Code Evolution**: Final working versions only (remove intermediate attempts)\n4. **System Context**: Project structure, dependencies, environment setup\n5. **Design Decisions**: Architectural choices and their rationale\n6. **TODO Items**: Unfinished tasks and known issues\n\n**Compression Rules:**\n- MUST KEEP: Error messages, stack traces, working solutions, current task\n- MERGE: Similar discussions into single summary points\n- REMOVE: Redundant explanations, failed attempts (keep lessons learned), verbose comments\n- CONDENSE: Long code blocks → keep signatures + key logic only\n\n**Special Handling:**\n- For code: Keep full version if < 20 lines, otherwise keep signature + key logic\n- For errors: Keep full error message + final solution\n- For discussions: Extract decisions and action items only\n\n**Required Output Structure:**\n\n<current_focus>\n[What we're working on now]\n</current_focus>\n\n<environment>\n- [Key setup/config points]\n- ...more...\n</environment>\n\n<completed_tasks>\n- [Task]: [Brief outcome]\n- ...more...\n</completed_tasks>\n\n<active_issues>\n- [Issue]: [Status/Next steps]\n- ...more...\n</active_issues>\n\n<code_state>\n\n<file>\n[filename]\n\n**Summary:**\n[What this code file does]\n\n**Key elements:**\n- [Important functions/classes]\n- ...more...\n\n**Latest version:**\n[Critical code snippets in this file]\n</file>\n\n<file>\n[filename]\n...Similar as above...\n</file>\n\n...more files...\n</code_state>\n\n<important_context>\n- [Any crucial information not covered above]\n- ...more...\n</important_context>\n"
  },
  {
    "path": "src/kimi_cli/prompts/init.md",
    "content": "You are a software engineering expert with many years of programming experience. Please explore the current project directory to understand the project's architecture and main details.\n\nTask requirements:\n1. Analyze the project structure and identify key configuration files (such as pyproject.toml, package.json, Cargo.toml, etc.).\n2. Understand the project's technology stack, build process and runtime architecture.\n3. Identify how the code is organized and main module divisions.\n4. Discover project-specific development conventions, testing strategies, and deployment processes.\n\nAfter the exploration, you should do a thorough summary of your findings and overwrite it into `AGENTS.md` file in the project root. You need to refer to what is already in the file when you do so.\n\nFor your information, `AGENTS.md` is a file intended to be read by AI coding agents. Expect the reader of this file know nothing about the project.\n\nYou should compose this file according to the actual project content. Do not make any assumptions or generalizations. Ensure the information is accurate and useful. You must use the natural language that is mainly used in the project's comments and documentation.\n\nPopular sections that people usually write in `AGENTS.md` are:\n\n- Project overview\n- Build and test commands\n- Code style guidelines\n- Testing instructions\n- Security considerations\n"
  },
  {
    "path": "src/kimi_cli/py.typed",
    "content": ""
  },
  {
    "path": "src/kimi_cli/session.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport builtins\nimport json\nimport shutil\nimport uuid\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom textwrap import shorten\n\nfrom kaos.path import KaosPath\nfrom kosong.message import Message\n\nfrom kimi_cli.metadata import WorkDirMeta, load_metadata, save_metadata\nfrom kimi_cli.session_state import SessionState, load_session_state, save_session_state\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire.file import WireFile\nfrom kimi_cli.wire.types import TurnBegin\n\n\n@dataclass(slots=True, kw_only=True)\nclass Session:\n    \"\"\"A session of a work directory.\"\"\"\n\n    # static metadata\n    id: str\n    \"\"\"The session ID.\"\"\"\n    work_dir: KaosPath\n    \"\"\"The absolute path of the work directory.\"\"\"\n    work_dir_meta: WorkDirMeta\n    \"\"\"The metadata of the work directory.\"\"\"\n    context_file: Path\n    \"\"\"The absolute path to the file storing the message history.\"\"\"\n    wire_file: WireFile\n    \"\"\"The wire message log file wrapper.\"\"\"\n\n    # session state\n    state: SessionState\n    \"\"\"Persisted session state (approval, dynamic subagents, etc.).\"\"\"\n\n    # refreshable metadata\n    title: str\n    \"\"\"The title of the session.\"\"\"\n    updated_at: float\n    \"\"\"The timestamp of the last update to the session.\"\"\"\n\n    @property\n    def dir(self) -> Path:\n        \"\"\"The absolute path of the session directory.\"\"\"\n        path = self.work_dir_meta.sessions_dir / self.id\n        path.mkdir(parents=True, exist_ok=True)\n        return path\n\n    def is_empty(self) -> bool:\n        \"\"\"Whether the session has any context history.\"\"\"\n        if not self.wire_file.is_empty():\n            return False\n        try:\n            with self.context_file.open(encoding=\"utf-8\") as f:\n                for line in f:\n                    line = line.strip()\n                    if not line:\n                        continue\n                    role = json.loads(line).get(\"role\")\n                    if isinstance(role, str) and not role.startswith(\"_\"):\n                        return False\n        except FileNotFoundError:\n            return True\n        except (OSError, ValueError, TypeError):\n            logger.exception(\"Failed to read context file {file}:\", file=self.context_file)\n            return False\n        return True\n\n    def save_state(self) -> None:\n        \"\"\"Persist the session state to disk.\"\"\"\n        save_session_state(self.state, self.dir)\n\n    async def delete(self) -> None:\n        \"\"\"Delete the session directory.\"\"\"\n        session_dir = self.work_dir_meta.sessions_dir / self.id\n        if not session_dir.exists():\n            return\n        await asyncio.to_thread(shutil.rmtree, session_dir, True)\n\n    async def refresh(self) -> None:\n        self.title = f\"Untitled ({self.id})\"\n        self.updated_at = self.context_file.stat().st_mtime if self.context_file.exists() else 0.0\n\n        try:\n            async for record in self.wire_file.iter_records():\n                wire_msg = record.to_wire_message()\n                if isinstance(wire_msg, TurnBegin):\n                    title = shorten(\n                        Message(role=\"user\", content=wire_msg.user_input).extract_text(\" \"),\n                        width=50,\n                    )\n                    self.title = f\"{title} ({self.id})\"\n                    return\n        except Exception:\n            logger.exception(\n                \"Failed to derive session title from wire file {file}:\",\n                file=self.wire_file.path,\n            )\n\n    @staticmethod\n    async def create(\n        work_dir: KaosPath,\n        session_id: str | None = None,\n        _context_file: Path | None = None,\n    ) -> Session:\n        \"\"\"Create a new session for a work directory.\"\"\"\n        work_dir = work_dir.canonical()\n        logger.debug(\"Creating new session for work directory: {work_dir}\", work_dir=work_dir)\n\n        metadata = load_metadata()\n        work_dir_meta = metadata.get_work_dir_meta(work_dir)\n        if work_dir_meta is None:\n            work_dir_meta = metadata.new_work_dir_meta(work_dir)\n\n        if session_id is None:\n            session_id = str(uuid.uuid4())\n        session_dir = work_dir_meta.sessions_dir / session_id\n        session_dir.mkdir(parents=True, exist_ok=True)\n\n        if _context_file is None:\n            context_file = session_dir / \"context.jsonl\"\n        else:\n            logger.warning(\n                \"Using provided context file: {context_file}\", context_file=_context_file\n            )\n            _context_file.parent.mkdir(parents=True, exist_ok=True)\n            if _context_file.exists():\n                assert _context_file.is_file()\n            context_file = _context_file\n\n        if context_file.exists():\n            # truncate if exists\n            logger.warning(\n                \"Context file already exists, truncating: {context_file}\", context_file=context_file\n            )\n            context_file.unlink()\n        context_file.touch()\n\n        save_metadata(metadata)\n\n        session = Session(\n            id=session_id,\n            work_dir=work_dir,\n            work_dir_meta=work_dir_meta,\n            context_file=context_file,\n            wire_file=WireFile(path=session_dir / \"wire.jsonl\"),\n            state=SessionState(),\n            title=\"\",\n            updated_at=0.0,\n        )\n        await session.refresh()\n        return session\n\n    @staticmethod\n    async def find(work_dir: KaosPath, session_id: str) -> Session | None:\n        \"\"\"Find a session by work directory and session ID.\"\"\"\n        work_dir = work_dir.canonical()\n        logger.debug(\n            \"Finding session for work directory: {work_dir}, session ID: {session_id}\",\n            work_dir=work_dir,\n            session_id=session_id,\n        )\n\n        metadata = load_metadata()\n        work_dir_meta = metadata.get_work_dir_meta(work_dir)\n        if work_dir_meta is None:\n            logger.debug(\"Work directory never been used\")\n            return None\n\n        _migrate_session_context_file(work_dir_meta, session_id)\n\n        session_dir = work_dir_meta.sessions_dir / session_id\n        if not session_dir.is_dir():\n            logger.debug(\"Session directory not found: {session_dir}\", session_dir=session_dir)\n            return None\n\n        context_file = session_dir / \"context.jsonl\"\n        if not context_file.exists():\n            logger.debug(\n                \"Session context file not found: {context_file}\", context_file=context_file\n            )\n            return None\n\n        session = Session(\n            id=session_id,\n            work_dir=work_dir,\n            work_dir_meta=work_dir_meta,\n            context_file=context_file,\n            wire_file=WireFile(path=session_dir / \"wire.jsonl\"),\n            state=load_session_state(session_dir),\n            title=\"\",\n            updated_at=0.0,\n        )\n        await session.refresh()\n        return session\n\n    @staticmethod\n    async def list(work_dir: KaosPath) -> builtins.list[Session]:\n        \"\"\"List all sessions for a work directory.\"\"\"\n        work_dir = work_dir.canonical()\n        logger.debug(\"Listing sessions for work directory: {work_dir}\", work_dir=work_dir)\n\n        metadata = load_metadata()\n        work_dir_meta = metadata.get_work_dir_meta(work_dir)\n        if work_dir_meta is None:\n            logger.debug(\"Work directory never been used\")\n            return []\n\n        session_ids = {\n            path.name if path.is_dir() else path.stem\n            for path in work_dir_meta.sessions_dir.iterdir()\n            if path.is_dir() or path.suffix == \".jsonl\"\n        }\n\n        sessions: list[Session] = []\n        for session_id in session_ids:\n            _migrate_session_context_file(work_dir_meta, session_id)\n            session_dir = work_dir_meta.sessions_dir / session_id\n            if not session_dir.is_dir():\n                logger.debug(\"Session directory not found: {session_dir}\", session_dir=session_dir)\n                continue\n            context_file = session_dir / \"context.jsonl\"\n            if not context_file.exists():\n                logger.debug(\n                    \"Session context file not found: {context_file}\", context_file=context_file\n                )\n                continue\n            session = Session(\n                id=session_id,\n                work_dir=work_dir,\n                work_dir_meta=work_dir_meta,\n                context_file=context_file,\n                wire_file=WireFile(path=session_dir / \"wire.jsonl\"),\n                state=load_session_state(session_dir),\n                title=\"\",\n                updated_at=0.0,\n            )\n            if session.is_empty():\n                logger.debug(\n                    \"Session context file is empty: {context_file}\", context_file=context_file\n                )\n                continue\n            await session.refresh()\n            sessions.append(session)\n        sessions.sort(key=lambda session: session.updated_at, reverse=True)\n        return sessions\n\n    @staticmethod\n    async def continue_(work_dir: KaosPath) -> Session | None:\n        \"\"\"Get the last session for a work directory.\"\"\"\n        work_dir = work_dir.canonical()\n        logger.debug(\"Continuing session for work directory: {work_dir}\", work_dir=work_dir)\n\n        metadata = load_metadata()\n        work_dir_meta = metadata.get_work_dir_meta(work_dir)\n        if work_dir_meta is None:\n            logger.debug(\"Work directory never been used\")\n            return None\n        if work_dir_meta.last_session_id is None:\n            logger.debug(\"Work directory never had a session\")\n            return None\n\n        logger.debug(\n            \"Found last session for work directory: {session_id}\",\n            session_id=work_dir_meta.last_session_id,\n        )\n        return await Session.find(work_dir, work_dir_meta.last_session_id)\n\n\ndef _migrate_session_context_file(work_dir_meta: WorkDirMeta, session_id: str) -> None:\n    old_context_file = work_dir_meta.sessions_dir / f\"{session_id}.jsonl\"\n    new_context_file = work_dir_meta.sessions_dir / session_id / \"context.jsonl\"\n    if old_context_file.exists() and not new_context_file.exists():\n        new_context_file.parent.mkdir(parents=True, exist_ok=True)\n        old_context_file.rename(new_context_file)\n        logger.info(\n            \"Migrated session context file from {old} to {new}\",\n            old=old_context_file,\n            new=new_context_file,\n        )\n"
  },
  {
    "path": "src/kimi_cli/session_state.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nfrom pydantic import BaseModel, Field, ValidationError\n\nfrom kimi_cli.utils.io import atomic_json_write\nfrom kimi_cli.utils.logging import logger\n\nSTATE_FILE_NAME = \"state.json\"\n\n\nclass ApprovalStateData(BaseModel):\n    yolo: bool = False\n    auto_approve_actions: set[str] = Field(default_factory=set)\n\n\nclass DynamicSubagentSpec(BaseModel):\n    name: str\n    system_prompt: str\n\n\ndef _default_dynamic_subagents() -> list[DynamicSubagentSpec]:\n    return []\n\n\nclass SessionState(BaseModel):\n    version: int = 1\n    approval: ApprovalStateData = Field(default_factory=ApprovalStateData)\n    dynamic_subagents: list[DynamicSubagentSpec] = Field(default_factory=_default_dynamic_subagents)\n    additional_dirs: list[str] = Field(default_factory=list)\n    plan_mode: bool = False\n    plan_session_id: str | None = None\n    plan_slug: str | None = None\n\n\ndef load_session_state(session_dir: Path) -> SessionState:\n    state_file = session_dir / STATE_FILE_NAME\n    if not state_file.exists():\n        return SessionState()\n    try:\n        with open(state_file, encoding=\"utf-8\") as f:\n            return SessionState.model_validate(json.load(f))\n    except (json.JSONDecodeError, ValidationError, UnicodeDecodeError):\n        logger.warning(\"Corrupted state file, using defaults: {path}\", path=state_file)\n        return SessionState()\n\n\ndef save_session_state(state: SessionState, session_dir: Path) -> None:\n    state_file = session_dir / STATE_FILE_NAME\n    atomic_json_write(state.model_dump(mode=\"json\"), state_file)\n"
  },
  {
    "path": "src/kimi_cli/share.py",
    "content": "from __future__ import annotations\n\nimport os\nfrom pathlib import Path\n\n\ndef get_share_dir() -> Path:\n    \"\"\"Get the share directory path.\"\"\"\n    if share_dir := os.getenv(\"KIMI_SHARE_DIR\"):\n        share_dir = Path(share_dir)\n    else:\n        share_dir = Path.home() / \".kimi\"\n    share_dir.mkdir(parents=True, exist_ok=True)\n    return share_dir\n"
  },
  {
    "path": "src/kimi_cli/skill/__init__.py",
    "content": "\"\"\"Skill specification discovery and loading utilities.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Iterable, Iterator\nfrom pathlib import Path\nfrom typing import Literal\n\nfrom kaos import get_current_kaos\nfrom kaos.local import local_kaos\nfrom kaos.path import KaosPath\nfrom pydantic import BaseModel, ConfigDict\n\nfrom kimi_cli import logger\nfrom kimi_cli.skill.flow import Flow, FlowError\nfrom kimi_cli.skill.flow.d2 import parse_d2_flowchart\nfrom kimi_cli.skill.flow.mermaid import parse_mermaid_flowchart\nfrom kimi_cli.utils.frontmatter import parse_frontmatter\n\nSkillType = Literal[\"standard\", \"flow\"]\n\n\ndef get_builtin_skills_dir() -> Path:\n    \"\"\"\n    Get the built-in skills directory path.\n    \"\"\"\n    return Path(__file__).parent.parent / \"skills\"\n\n\ndef get_user_skills_dir_candidates() -> tuple[KaosPath, ...]:\n    \"\"\"\n    Get user-level skills directory candidates in priority order.\n    \"\"\"\n    return (\n        KaosPath.home() / \".config\" / \"agents\" / \"skills\",\n        KaosPath.home() / \".agents\" / \"skills\",\n        KaosPath.home() / \".kimi\" / \"skills\",\n        KaosPath.home() / \".claude\" / \"skills\",\n        KaosPath.home() / \".codex\" / \"skills\",\n    )\n\n\ndef get_project_skills_dir_candidates(work_dir: KaosPath) -> tuple[KaosPath, ...]:\n    \"\"\"\n    Get project-level skills directory candidates in priority order.\n    \"\"\"\n    return (\n        work_dir / \".agents\" / \"skills\",\n        work_dir / \".kimi\" / \"skills\",\n        work_dir / \".claude\" / \"skills\",\n        work_dir / \".codex\" / \"skills\",\n    )\n\n\ndef _supports_builtin_skills() -> bool:\n    \"\"\"Return True when the active KAOS backend can read bundled skills.\"\"\"\n    current_name = get_current_kaos().name\n    return current_name in (local_kaos.name, \"acp\")\n\n\nasync def find_first_existing_dir(candidates: Iterable[KaosPath]) -> KaosPath | None:\n    \"\"\"\n    Return the first existing directory from candidates.\n    \"\"\"\n    for candidate in candidates:\n        if await candidate.is_dir():\n            return candidate\n    return None\n\n\nasync def find_user_skills_dir() -> KaosPath | None:\n    \"\"\"\n    Return the first existing user-level skills directory.\n    \"\"\"\n    return await find_first_existing_dir(get_user_skills_dir_candidates())\n\n\nasync def find_project_skills_dir(work_dir: KaosPath) -> KaosPath | None:\n    \"\"\"\n    Return the first existing project-level skills directory.\n    \"\"\"\n    return await find_first_existing_dir(get_project_skills_dir_candidates(work_dir))\n\n\nasync def resolve_skills_roots(\n    work_dir: KaosPath,\n    *,\n    skills_dir_override: KaosPath | None = None,\n) -> list[KaosPath]:\n    \"\"\"\n    Resolve layered skill roots in priority order.\n\n    Built-in skills load first when supported by the active KAOS backend. When an\n    override is provided, user/project discovery is skipped.\n    \"\"\"\n    from kimi_cli.plugin.manager import get_plugins_dir\n\n    roots: list[KaosPath] = []\n    if _supports_builtin_skills():\n        roots.append(KaosPath.unsafe_from_local_path(get_builtin_skills_dir()))\n    if skills_dir_override is not None:\n        roots.append(skills_dir_override)\n    else:\n        if user_dir := await find_user_skills_dir():\n            roots.append(user_dir)\n        if project_dir := await find_project_skills_dir(work_dir):\n            roots.append(project_dir)\n    # Plugins are always discoverable, even when --skills-dir is set\n    plugins_path = get_plugins_dir()\n    if plugins_path.is_dir():\n        roots.append(KaosPath.unsafe_from_local_path(plugins_path))\n    return roots\n\n\ndef normalize_skill_name(name: str) -> str:\n    \"\"\"Normalize a skill name for lookup.\"\"\"\n    return name.casefold()\n\n\ndef index_skills(skills: Iterable[Skill]) -> dict[str, Skill]:\n    \"\"\"Build a lookup table for skills by normalized name.\"\"\"\n    return {normalize_skill_name(skill.name): skill for skill in skills}\n\n\nasync def discover_skills_from_roots(skills_dirs: Iterable[KaosPath]) -> list[Skill]:\n    \"\"\"\n    Discover skills from multiple directory roots.\n    \"\"\"\n    skills_by_name: dict[str, Skill] = {}\n    for skills_dir in skills_dirs:\n        for skill in await discover_skills(skills_dir):\n            skills_by_name[normalize_skill_name(skill.name)] = skill\n    return sorted(skills_by_name.values(), key=lambda s: s.name)\n\n\nasync def read_skill_text(skill: Skill) -> str | None:\n    \"\"\"Read the SKILL.md contents for a skill.\"\"\"\n    try:\n        return (await skill.skill_md_file.read_text(encoding=\"utf-8\")).strip()\n    except OSError as exc:\n        logger.warning(\n            \"Failed to read skill file {path}: {error}\",\n            path=skill.skill_md_file,\n            error=exc,\n        )\n        return None\n\n\nclass Skill(BaseModel):\n    \"\"\"Information about a single skill.\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\", arbitrary_types_allowed=True)\n\n    name: str\n    description: str\n    type: SkillType = \"standard\"\n    dir: KaosPath\n    flow: Flow | None = None\n\n    @property\n    def skill_md_file(self) -> KaosPath:\n        \"\"\"Path to the SKILL.md file.\"\"\"\n        return self.dir / \"SKILL.md\"\n\n\nasync def discover_skills(skills_dir: KaosPath) -> list[Skill]:\n    \"\"\"\n    Discover all skills in the given directory.\n\n    Args:\n        skills_dir: Kaos path to the directory containing skills.\n\n    Returns:\n        List of Skill objects, one for each valid skill found.\n    \"\"\"\n    if not await skills_dir.is_dir():\n        return []\n\n    skills: list[Skill] = []\n\n    async for skill_dir in skills_dir.iterdir():\n        if not await skill_dir.is_dir():\n            continue\n\n        skill_md = skill_dir / \"SKILL.md\"\n        if not await skill_md.is_file():\n            continue\n\n        try:\n            content = await skill_md.read_text(encoding=\"utf-8\")\n            skills.append(parse_skill_text(content, dir_path=skill_dir))\n        except Exception as exc:\n            logger.info(\"Skipping invalid skill at {}: {}\", skill_md, exc)\n            continue\n\n    return sorted(skills, key=lambda s: s.name)\n\n\ndef parse_skill_text(content: str, *, dir_path: KaosPath) -> Skill:\n    \"\"\"\n    Parse SKILL.md contents to extract name and description.\n    \"\"\"\n    frontmatter = parse_frontmatter(content) or {}\n\n    name = frontmatter.get(\"name\") or dir_path.name\n    description = frontmatter.get(\"description\") or \"No description provided.\"\n    skill_type = frontmatter.get(\"type\") or \"standard\"\n    if skill_type not in (\"standard\", \"flow\"):\n        raise ValueError(f'Invalid skill type \"{skill_type}\"')\n    flow = None\n    if skill_type == \"flow\":\n        try:\n            flow = _parse_flow_from_skill(content)\n        except ValueError as exc:\n            logger.error(\"Failed to parse flow skill {name}: {error}\", name=name, error=exc)\n            skill_type = \"standard\"\n            flow = None\n\n    return Skill(\n        name=name,\n        description=description,\n        type=skill_type,\n        dir=dir_path,\n        flow=flow,\n    )\n\n\ndef _parse_flow_from_skill(content: str) -> Flow:\n    for lang, code in _iter_fenced_codeblocks(content):\n        if lang == \"mermaid\":\n            return _parse_flow_block(parse_mermaid_flowchart, code)\n        if lang == \"d2\":\n            return _parse_flow_block(parse_d2_flowchart, code)\n    raise ValueError(\"Flow skills require a mermaid or d2 code block in SKILL.md.\")\n\n\ndef _parse_flow_block(parser: Callable[[str], Flow], code: str) -> Flow:\n    try:\n        return parser(code)\n    except FlowError as exc:\n        raise ValueError(f\"Invalid flow diagram: {exc}\") from exc\n\n\ndef _iter_fenced_codeblocks(content: str) -> Iterator[tuple[str, str]]:\n    fence = \"\"\n    fence_char = \"\"\n    lang = \"\"\n    buf: list[str] = []\n    in_block = False\n\n    for line in content.splitlines():\n        stripped = line.lstrip()\n        if not in_block:\n            if match := _parse_fence_open(stripped):\n                fence, fence_char, info = match\n                lang = _normalize_code_lang(info)\n                in_block = True\n                buf = []\n            continue\n\n        if _is_fence_close(stripped, fence_char, len(fence)):\n            yield lang, \"\\n\".join(buf).strip(\"\\n\")\n            in_block = False\n            fence = \"\"\n            fence_char = \"\"\n            lang = \"\"\n            buf = []\n            continue\n\n        buf.append(line)\n\n\ndef _normalize_code_lang(info: str) -> str:\n    if not info:\n        return \"\"\n    lang = info.split()[0].strip().lower()\n    if lang.startswith(\"{\") and lang.endswith(\"}\"):\n        lang = lang[1:-1].strip()\n    return lang\n\n\ndef _parse_fence_open(line: str) -> tuple[str, str, str] | None:\n    if not line or line[0] not in (\"`\", \"~\"):\n        return None\n    fence_char = line[0]\n    count = 0\n    for ch in line:\n        if ch == fence_char:\n            count += 1\n        else:\n            break\n    if count < 3:\n        return None\n    fence = fence_char * count\n    info = line[count:].strip()\n    return fence, fence_char, info\n\n\ndef _is_fence_close(line: str, fence_char: str, fence_len: int) -> bool:\n    if not fence_char or not line or line[0] != fence_char:\n        return False\n    count = 0\n    for ch in line:\n        if ch == fence_char:\n            count += 1\n        else:\n            break\n    if count < fence_len:\n        return False\n    return not line[count:].strip()\n"
  },
  {
    "path": "src/kimi_cli/skill/flow/__init__.py",
    "content": "from __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass\nfrom typing import Literal\n\nfrom kosong.message import ContentPart\n\nFlowNodeKind = Literal[\"begin\", \"end\", \"task\", \"decision\"]\n\n\nclass FlowError(ValueError):\n    \"\"\"Base error for flow parsing/validation.\"\"\"\n\n\nclass FlowParseError(FlowError):\n    \"\"\"Raised when prompt flow parsing fails.\"\"\"\n\n\nclass FlowValidationError(FlowError):\n    \"\"\"Raised when a flowchart fails validation.\"\"\"\n\n\n@dataclass(frozen=True, slots=True)\nclass FlowNode:\n    id: str\n    label: str | list[ContentPart]\n    kind: FlowNodeKind\n\n\n@dataclass(frozen=True, slots=True)\nclass FlowEdge:\n    src: str\n    dst: str\n    label: str | None\n\n\n@dataclass(slots=True)\nclass Flow:\n    nodes: dict[str, FlowNode]\n    outgoing: dict[str, list[FlowEdge]]\n    begin_id: str\n    end_id: str\n\n\n_CHOICE_RE = re.compile(r\"<choice>([^<]*)</choice>\")\n\n\ndef parse_choice(text: str) -> str | None:\n    matches = _CHOICE_RE.findall(text or \"\")\n    if not matches:\n        return None\n    return matches[-1].strip()\n\n\ndef validate_flow(\n    nodes: dict[str, FlowNode],\n    outgoing: dict[str, list[FlowEdge]],\n) -> tuple[str, str]:\n    begin_ids = [node.id for node in nodes.values() if node.kind == \"begin\"]\n    end_ids = [node.id for node in nodes.values() if node.kind == \"end\"]\n\n    if len(begin_ids) != 1:\n        raise FlowValidationError(f\"Expected exactly one BEGIN node, found {len(begin_ids)}\")\n    if len(end_ids) != 1:\n        raise FlowValidationError(f\"Expected exactly one END node, found {len(end_ids)}\")\n\n    begin_id = begin_ids[0]\n    end_id = end_ids[0]\n\n    reachable: set[str] = set()\n    queue: list[str] = [begin_id]\n    while queue:\n        node_id = queue.pop()\n        if node_id in reachable:\n            continue\n        reachable.add(node_id)\n        for edge in outgoing.get(node_id, []):\n            if edge.dst not in reachable:\n                queue.append(edge.dst)\n\n    for node in nodes.values():\n        if node.id not in reachable:\n            continue\n        edges = outgoing.get(node.id, [])\n        if len(edges) <= 1:\n            continue\n        labels: list[str] = []\n        for edge in edges:\n            if edge.label is None or not edge.label.strip():\n                raise FlowValidationError(f'Node \"{node.id}\" has an unlabeled edge')\n            labels.append(edge.label)\n        if len(set(labels)) != len(labels):\n            raise FlowValidationError(f'Node \"{node.id}\" has duplicate edge labels')\n\n    if end_id not in reachable:\n        raise FlowValidationError(\"END node is not reachable from BEGIN\")\n\n    return begin_id, end_id\n"
  },
  {
    "path": "src/kimi_cli/skill/flow/d2.py",
    "content": "from __future__ import annotations\n\nimport re\nfrom collections.abc import Iterable\nfrom dataclasses import dataclass\n\nfrom . import (\n    Flow,\n    FlowEdge,\n    FlowNode,\n    FlowNodeKind,\n    FlowParseError,\n    validate_flow,\n)\n\n_NODE_ID_RE = re.compile(r\"[A-Za-z0-9_][A-Za-z0-9_./-]*\")\n_BLOCK_TAG_RE = re.compile(r\"^\\|md$\")\n_PROPERTY_SEGMENTS = {\n    \"shape\",\n    \"style\",\n    \"label\",\n    \"link\",\n    \"icon\",\n    \"near\",\n    \"width\",\n    \"height\",\n    \"direction\",\n    \"grid-rows\",\n    \"grid-columns\",\n    \"grid-gap\",\n    \"font-size\",\n    \"font-family\",\n    \"font-color\",\n    \"stroke\",\n    \"fill\",\n    \"opacity\",\n    \"padding\",\n    \"border-radius\",\n    \"shadow\",\n    \"sketch\",\n    \"animated\",\n    \"multiple\",\n    \"constraint\",\n    \"tooltip\",\n}\n\n\n@dataclass(frozen=True, slots=True)\nclass _NodeDef:\n    node: FlowNode\n    explicit: bool\n\n\ndef parse_d2_flowchart(text: str) -> Flow:\n    # Normalize D2 markdown blocks into quoted labels so the parser can stay line-based.\n    text = _normalize_markdown_blocks(text)\n    nodes: dict[str, _NodeDef] = {}\n    outgoing: dict[str, list[FlowEdge]] = {}\n\n    for line_no, statement in _iter_top_level_statements(text):\n        if _has_unquoted_token(statement, \"->\"):\n            _parse_edge_statement(statement, line_no, nodes, outgoing)\n        else:\n            _parse_node_statement(statement, line_no, nodes)\n\n    flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()}\n    for node_id in flow_nodes:\n        outgoing.setdefault(node_id, [])\n\n    flow_nodes = _infer_decision_nodes(flow_nodes, outgoing)\n    begin_id, end_id = validate_flow(flow_nodes, outgoing)\n    return Flow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id)\n\n\ndef _normalize_markdown_blocks(text: str) -> str:\n    normalized = text.replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n    lines = normalized.split(\"\\n\")\n    out_lines: list[str] = []\n    i = 0\n    line_no = 1\n\n    while i < len(lines):\n        line = lines[i]\n        prefix, suffix = _split_unquoted_once(line, \":\")\n        if suffix is None:\n            out_lines.append(line)\n            i += 1\n            line_no += 1\n            continue\n\n        suffix_clean = _strip_unquoted_comment(suffix).strip()\n        # Only treat `: |md` as a markdown block starter.\n        if not _BLOCK_TAG_RE.fullmatch(suffix_clean):\n            out_lines.append(line)\n            i += 1\n            line_no += 1\n            continue\n\n        start_line = line_no\n        block_lines: list[str] = []\n        i += 1\n        line_no += 1\n        while i < len(lines):\n            block_line = lines[i]\n            if block_line.strip() == \"|\":\n                break\n            block_lines.append(block_line)\n            i += 1\n            line_no += 1\n        if i >= len(lines):\n            raise FlowParseError(_line_error(start_line, \"Unclosed markdown block\"))\n\n        # Convert the block into a multiline quoted string label.\n        dedented = _dedent_block(block_lines)\n        if dedented:\n            escaped = [_escape_quoted_line(line) for line in dedented]\n            out_lines.append(f'{prefix}: \"{escaped[0]}')\n            for line in escaped[1:]:\n                out_lines.append(line)\n            out_lines[-1] = f'{out_lines[-1]}\"'\n            out_lines.extend([\"\", \"\"])\n        else:\n            out_lines.append(f'{prefix}: \"\"')\n            out_lines.append(\"\")\n\n        i += 1\n        line_no += 1\n\n    return \"\\n\".join(out_lines)\n\n\ndef _strip_unquoted_comment(text: str) -> str:\n    in_single = False\n    in_double = False\n    escape = False\n    for idx, ch in enumerate(text):\n        if escape:\n            escape = False\n            continue\n        if ch == \"\\\\\" and (in_single or in_double):\n            escape = True\n            continue\n        if ch == \"'\" and not in_double:\n            in_single = not in_single\n            continue\n        if ch == '\"' and not in_single:\n            in_double = not in_double\n            continue\n        if ch == \"#\" and not in_single and not in_double:\n            return text[:idx]\n    return text\n\n\ndef _dedent_block(lines: list[str]) -> list[str]:\n    indent: int | None = None\n    for line in lines:\n        if not line.strip():\n            continue\n        stripped = line.lstrip(\" \\t\")\n        lead = len(line) - len(stripped)\n        if indent is None or lead < indent:\n            indent = lead\n    if indent is None:\n        return [\"\" for _ in lines]\n    return [line[indent:] if len(line) >= indent else \"\" for line in lines]\n\n\ndef _escape_quoted_line(line: str) -> str:\n    return line.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n\n\ndef _iter_top_level_statements(text: str) -> Iterable[tuple[int, str]]:\n    text = text.replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n    brace_depth = 0\n    in_single = False\n    in_double = False\n    escape = False\n    drop_line = False\n    buf: list[str] = []\n    line_no = 1\n    stmt_line = 1\n    i = 0\n\n    while i < len(text):\n        ch = text[i]\n        next_ch = text[i + 1] if i + 1 < len(text) else \"\"\n\n        if ch == \"\\\\\" and next_ch == \"\\n\":\n            i += 2\n            line_no += 1\n            continue\n\n        if ch == \"\\n\":\n            # Preserve newlines inside quoted strings (used for markdown block labels).\n            if (in_single or in_double) and brace_depth == 0 and not drop_line:\n                buf.append(\"\\n\")\n                line_no += 1\n                i += 1\n                continue\n            if brace_depth == 0 and not in_single and not in_double and not drop_line:\n                statement = \"\".join(buf).strip()\n                if statement:\n                    yield stmt_line, statement\n            buf = []\n            drop_line = False\n            stmt_line = line_no + 1\n            line_no += 1\n            i += 1\n            continue\n\n        if not in_single and not in_double:\n            if ch == \"#\":\n                while i < len(text) and text[i] != \"\\n\":\n                    i += 1\n                continue\n            if ch == \"{\":\n                if brace_depth == 0:\n                    statement = \"\".join(buf).strip()\n                    if statement:\n                        yield stmt_line, statement\n                    drop_line = True\n                    buf.clear()\n                brace_depth += 1\n                i += 1\n                continue\n            if ch == \"}\" and brace_depth > 0:\n                brace_depth -= 1\n                i += 1\n                continue\n            if ch == \"}\" and brace_depth == 0:\n                raise FlowParseError(_line_error(line_no, \"Unmatched '}'\"))\n\n        if ch == \"'\" and not in_double and not escape:\n            in_single = not in_single\n        elif ch == '\"' and not in_single and not escape:\n            in_double = not in_double\n\n        if escape:\n            escape = False\n        elif ch == \"\\\\\" and (in_single or in_double):\n            escape = True\n\n        if brace_depth == 0 and not drop_line:\n            buf.append(ch)\n\n        i += 1\n\n    if brace_depth != 0:\n        raise FlowParseError(_line_error(line_no, \"Unclosed '{' block\"))\n    if in_single or in_double:\n        raise FlowParseError(_line_error(line_no, \"Unclosed string\"))\n\n    statement = \"\".join(buf).strip()\n    if statement:\n        yield stmt_line, statement\n\n\ndef _has_unquoted_token(text: str, token: str) -> bool:\n    parts = _split_on_token(text, token)\n    return len(parts) > 1\n\n\ndef _parse_edge_statement(\n    statement: str,\n    line_no: int,\n    nodes: dict[str, _NodeDef],\n    outgoing: dict[str, list[FlowEdge]],\n) -> None:\n    parts = _split_on_token(statement, \"->\")\n    if len(parts) < 2:\n        raise FlowParseError(_line_error(line_no, \"Expected edge arrow\"))\n\n    last_part = parts[-1]\n    target_text, edge_label = _split_unquoted_once(last_part, \":\")\n    parts[-1] = target_text\n\n    node_ids: list[str] = []\n    for idx, part in enumerate(parts):\n        node_id = _parse_node_id(part, line_no, allow_inline_label=(idx < len(parts) - 1))\n        node_ids.append(node_id)\n\n    if any(_is_property_path(node_id) for node_id in node_ids):\n        return\n    if len(node_ids) < 2:\n        raise FlowParseError(_line_error(line_no, \"Edge must have at least two nodes\"))\n\n    label = _parse_label(edge_label, line_no) if edge_label is not None else None\n    for idx in range(len(node_ids) - 1):\n        edge = FlowEdge(\n            src=node_ids[idx],\n            dst=node_ids[idx + 1],\n            label=label if idx == len(node_ids) - 2 else None,\n        )\n        outgoing.setdefault(edge.src, []).append(edge)\n        outgoing.setdefault(edge.dst, [])\n\n    for node_id in node_ids:\n        _add_node(nodes, node_id=node_id, label=None, explicit=False, line_no=line_no)\n\n\ndef _parse_node_statement(statement: str, line_no: int, nodes: dict[str, _NodeDef]) -> None:\n    node_text, label_text = _split_unquoted_once(statement, \":\")\n    if label_text is not None and _is_property_path(node_text):\n        return\n    node_id = _parse_node_id(node_text, line_no, allow_inline_label=False)\n    label = None\n    explicit = False\n    if label_text is not None and not label_text.strip():\n        return\n    if label_text is not None:\n        label = _parse_label(label_text, line_no)\n        explicit = True\n    _add_node(nodes, node_id=node_id, label=label, explicit=explicit, line_no=line_no)\n\n\ndef _parse_node_id(text: str, line_no: int, *, allow_inline_label: bool) -> str:\n    cleaned = text.strip()\n    if allow_inline_label and \":\" in cleaned:\n        cleaned = _split_unquoted_once(cleaned, \":\")[0].strip()\n    if not cleaned:\n        raise FlowParseError(_line_error(line_no, \"Expected node id\"))\n    match = _NODE_ID_RE.fullmatch(cleaned)\n    if not match:\n        raise FlowParseError(_line_error(line_no, f'Invalid node id \"{cleaned}\"'))\n    return match.group(0)\n\n\ndef _is_property_path(node_id: str) -> bool:\n    if \".\" not in node_id:\n        return False\n    parts = [part for part in node_id.split(\".\") if part]\n    for part in parts[1:]:\n        if part in _PROPERTY_SEGMENTS or part.startswith(\"style\"):\n            return True\n    return parts[-1] in _PROPERTY_SEGMENTS\n\n\ndef _parse_label(text: str, line_no: int) -> str:\n    label = text.strip()\n    if not label:\n        raise FlowParseError(_line_error(line_no, \"Label cannot be empty\"))\n    if label[0] in {\"'\", '\"'}:\n        return _parse_quoted_label(label, line_no)\n    return label\n\n\ndef _parse_quoted_label(text: str, line_no: int) -> str:\n    quote = text[0]\n    buf: list[str] = []\n    escape = False\n    i = 1\n    while i < len(text):\n        ch = text[i]\n        if escape:\n            buf.append(ch)\n            escape = False\n            i += 1\n            continue\n        if ch == \"\\\\\":\n            escape = True\n            i += 1\n            continue\n        if ch == quote:\n            trailing = text[i + 1 :].strip()\n            if trailing:\n                raise FlowParseError(_line_error(line_no, \"Unexpected trailing content\"))\n            return \"\".join(buf)\n        buf.append(ch)\n        i += 1\n    raise FlowParseError(_line_error(line_no, \"Unclosed quoted label\"))\n\n\ndef _split_on_token(text: str, token: str) -> list[str]:\n    parts: list[str] = []\n    buf: list[str] = []\n    in_single = False\n    in_double = False\n    escape = False\n    i = 0\n\n    while i < len(text):\n        if not in_single and not in_double and text.startswith(token, i):\n            parts.append(\"\".join(buf).strip())\n            buf = []\n            i += len(token)\n            continue\n        ch = text[i]\n        if escape:\n            escape = False\n        elif ch == \"\\\\\" and (in_single or in_double):\n            escape = True\n        elif ch == \"'\" and not in_double:\n            in_single = not in_single\n        elif ch == '\"' and not in_single:\n            in_double = not in_double\n        buf.append(ch)\n        i += 1\n\n    if in_single or in_double:\n        raise FlowParseError(\"Unclosed string in statement\")\n    parts.append(\"\".join(buf).strip())\n    return parts\n\n\ndef _split_unquoted_once(text: str, token: str) -> tuple[str, str | None]:\n    in_single = False\n    in_double = False\n    escape = False\n    for idx, ch in enumerate(text):\n        if escape:\n            escape = False\n            continue\n        if ch == \"\\\\\" and (in_single or in_double):\n            escape = True\n            continue\n        if ch == \"'\" and not in_double:\n            in_single = not in_single\n            continue\n        if ch == '\"' and not in_single:\n            in_double = not in_double\n            continue\n        if ch == token and not in_single and not in_double:\n            return text[:idx].strip(), text[idx + 1 :].strip()\n    return text.strip(), None\n\n\ndef _add_node(\n    nodes: dict[str, _NodeDef],\n    *,\n    node_id: str,\n    label: str | None,\n    explicit: bool,\n    line_no: int,\n) -> FlowNode:\n    label = label if label is not None else node_id\n    label_norm = label.strip().lower()\n    if not label:\n        raise FlowParseError(_line_error(line_no, \"Node label cannot be empty\"))\n\n    kind: FlowNodeKind = \"task\"\n    if label_norm == \"begin\":\n        kind = \"begin\"\n    elif label_norm == \"end\":\n        kind = \"end\"\n\n    node = FlowNode(id=node_id, label=label, kind=kind)\n    existing = nodes.get(node_id)\n    if existing is None:\n        nodes[node_id] = _NodeDef(node=node, explicit=explicit)\n        return node\n\n    if existing.node == node:\n        return existing.node\n\n    if not explicit and existing.explicit:\n        return existing.node\n\n    if explicit and not existing.explicit:\n        nodes[node_id] = _NodeDef(node=node, explicit=True)\n        return node\n\n    raise FlowParseError(_line_error(line_no, f'Conflicting definition for node \"{node_id}\"'))\n\n\ndef _infer_decision_nodes(\n    nodes: dict[str, FlowNode],\n    outgoing: dict[str, list[FlowEdge]],\n) -> dict[str, FlowNode]:\n    updated: dict[str, FlowNode] = {}\n    for node_id, node in nodes.items():\n        kind = node.kind\n        if kind == \"task\" and len(outgoing.get(node_id, [])) > 1:\n            kind = \"decision\"\n        if kind != node.kind:\n            updated[node_id] = FlowNode(id=node.id, label=node.label, kind=kind)\n        else:\n            updated[node_id] = node\n    return updated\n\n\ndef _line_error(line_no: int, message: str) -> str:\n    return f\"Line {line_no}: {message}\"\n"
  },
  {
    "path": "src/kimi_cli/skill/flow/mermaid.py",
    "content": "from __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass\n\nfrom . import (\n    Flow,\n    FlowEdge,\n    FlowNode,\n    FlowNodeKind,\n    FlowParseError,\n    validate_flow,\n)\n\n\n@dataclass(frozen=True, slots=True)\nclass _NodeSpec:\n    node_id: str\n    label: str | None\n\n\n@dataclass(slots=True)\nclass _NodeDef:\n    node: FlowNode\n    explicit: bool\n\n\n_NODE_ID_RE = re.compile(r\"[A-Za-z0-9_][A-Za-z0-9_-]*\")\n_HEADER_RE = re.compile(r\"^(flowchart|graph)\\b\", re.IGNORECASE)\n\n_SHAPES = {\n    \"[\": \"]\",\n    \"(\": \")\",\n    \"{\": \"}\",\n}\n_PIPE_LABEL_RE = re.compile(r\"\\|([^|]*)\\|\")\n_EDGE_LABEL_RE = re.compile(r\"--\\s*([^>-][^>]*)\\s*-->\")\n_ARROW_RE = re.compile(r\"[-.=]+>\")\n\n\ndef parse_mermaid_flowchart(text: str) -> Flow:\n    nodes: dict[str, _NodeDef] = {}\n    outgoing: dict[str, list[FlowEdge]] = {}\n\n    for line_no, raw_line in enumerate(text.splitlines(), start=1):\n        line = _strip_comment(raw_line).strip()\n        if not line or line.startswith(\"%%\"):\n            continue\n        if _HEADER_RE.match(line):\n            continue\n        if _is_style_line(line):\n            continue\n        line = _strip_style_tokens(line)\n\n        edge = _try_parse_edge_line(line, line_no)\n        if edge is not None:\n            src_spec, label, dst_spec = edge\n            src_node = _add_node(nodes, src_spec, line_no)\n            dst_node = _add_node(nodes, dst_spec, line_no)\n            flow_edge = FlowEdge(src=src_node.id, dst=dst_node.id, label=label)\n            outgoing.setdefault(flow_edge.src, []).append(flow_edge)\n            outgoing.setdefault(flow_edge.dst, [])\n            continue\n\n        node_spec = _try_parse_node_line(line, line_no)\n        if node_spec is not None:\n            _add_node(nodes, node_spec, line_no)\n\n    flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()}\n    for node_id in flow_nodes:\n        outgoing.setdefault(node_id, [])\n\n    flow_nodes = _infer_decision_nodes(flow_nodes, outgoing)\n    begin_id, end_id = validate_flow(flow_nodes, outgoing)\n    return Flow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id)\n\n\ndef _try_parse_edge_line(line: str, line_no: int) -> tuple[_NodeSpec, str | None, _NodeSpec] | None:\n    try:\n        src_spec, idx = _parse_node_token(line, 0, line_no)\n    except FlowParseError:\n        return None\n\n    normalized, label = _normalize_edge_line(line)\n    idx = _skip_ws(normalized, idx)\n    if \">\" not in normalized[idx:]:\n        if \"---\" not in normalized[idx:]:\n            return None\n        normalized = normalized[:idx] + normalized[idx:].replace(\"---\", \"-->\", 1)\n\n    normalized = _ARROW_RE.sub(\"-->\", normalized)\n    arrow_idx = normalized.rfind(\">\")\n    if arrow_idx == -1:\n        return None\n\n    dst_start = _skip_ws(normalized, arrow_idx + 1)\n    try:\n        dst_spec, _ = _parse_node_token(normalized, dst_start, line_no)\n    except FlowParseError:\n        return None\n\n    return src_spec, label, dst_spec\n\n\ndef _parse_node_token(line: str, idx: int, line_no: int) -> tuple[_NodeSpec, int]:\n    match = _NODE_ID_RE.match(line, idx)\n    if not match:\n        raise FlowParseError(_line_error(line_no, \"Expected node id\"))\n    node_id = match.group(0)\n    idx = match.end()\n\n    if idx >= len(line) or line[idx] not in _SHAPES:\n        return _NodeSpec(node_id=node_id, label=None), idx\n\n    close_char = _SHAPES[line[idx]]\n    idx += 1\n    label, idx = _parse_label(line, idx, close_char, line_no)\n    return _NodeSpec(node_id=node_id, label=label), idx\n\n\ndef _parse_label(line: str, idx: int, close_char: str, line_no: int) -> tuple[str, int]:\n    if idx >= len(line):\n        raise FlowParseError(_line_error(line_no, \"Expected node label\"))\n    if close_char == \")\" and line[idx] == \"[\":\n        label, idx = _parse_label(line, idx + 1, \"]\", line_no)\n        while idx < len(line) and line[idx].isspace():\n            idx += 1\n        if idx >= len(line) or line[idx] != \")\":\n            raise FlowParseError(_line_error(line_no, \"Unclosed node label\"))\n        return label, idx + 1\n    if line[idx] == '\"':\n        idx += 1\n        buf: list[str] = []\n        while idx < len(line):\n            ch = line[idx]\n            if ch == '\"':\n                idx += 1\n                while idx < len(line) and line[idx].isspace():\n                    idx += 1\n                if idx >= len(line) or line[idx] != close_char:\n                    raise FlowParseError(_line_error(line_no, \"Unclosed node label\"))\n                return \"\".join(buf), idx + 1\n            if ch == \"\\\\\" and idx + 1 < len(line):\n                buf.append(line[idx + 1])\n                idx += 2\n                continue\n            buf.append(ch)\n            idx += 1\n        raise FlowParseError(_line_error(line_no, \"Unclosed quoted label\"))\n\n    end = line.find(close_char, idx)\n    if end == -1:\n        raise FlowParseError(_line_error(line_no, \"Unclosed node label\"))\n    label = line[idx:end].strip()\n    if not label:\n        raise FlowParseError(_line_error(line_no, \"Node label cannot be empty\"))\n    return label, end + 1\n\n\ndef _skip_ws(line: str, idx: int) -> int:\n    while idx < len(line) and line[idx].isspace():\n        idx += 1\n    return idx\n\n\ndef _add_node(nodes: dict[str, _NodeDef], spec: _NodeSpec, line_no: int) -> FlowNode:\n    label = spec.label if spec.label is not None else spec.node_id\n    label_norm = label.strip().lower()\n    if not label:\n        raise FlowParseError(_line_error(line_no, \"Node label cannot be empty\"))\n\n    kind: FlowNodeKind = \"task\"\n    if label_norm == \"begin\":\n        kind = \"begin\"\n    elif label_norm == \"end\":\n        kind = \"end\"\n\n    node = FlowNode(id=spec.node_id, label=label, kind=kind)\n    explicit = spec.label is not None\n\n    existing = nodes.get(spec.node_id)\n    if existing is None:\n        nodes[spec.node_id] = _NodeDef(node=node, explicit=explicit)\n        return node\n\n    if existing.node == node:\n        return existing.node\n\n    if not explicit and existing.explicit:\n        return existing.node\n\n    if explicit and not existing.explicit:\n        nodes[spec.node_id] = _NodeDef(node=node, explicit=True)\n        return node\n\n    raise FlowParseError(_line_error(line_no, f'Conflicting definition for node \"{spec.node_id}\"'))\n\n\ndef _line_error(line_no: int, message: str) -> str:\n    return f\"Line {line_no}: {message}\"\n\n\ndef _strip_comment(line: str) -> str:\n    if \"%%\" not in line:\n        return line\n    return line.split(\"%%\", 1)[0]\n\n\ndef _is_style_line(line: str) -> bool:\n    lowered = line.lower()\n    if lowered in (\"end\",):\n        return True\n    return lowered.startswith(\n        (\n            \"classdef \",\n            \"class \",\n            \"style \",\n            \"linkstyle \",\n            \"click \",\n            \"subgraph \",\n            \"direction \",\n        )\n    )\n\n\ndef _strip_style_tokens(line: str) -> str:\n    return re.sub(r\":::[A-Za-z0-9_-]+\", \"\", line)\n\n\ndef _try_parse_node_line(line: str, line_no: int) -> _NodeSpec | None:\n    try:\n        node_spec, _ = _parse_node_token(line, 0, line_no)\n    except FlowParseError:\n        return None\n    return node_spec\n\n\ndef _normalize_edge_line(line: str) -> tuple[str, str | None]:\n    label = None\n    normalized = line\n    pipe_match = _PIPE_LABEL_RE.search(normalized)\n    if pipe_match:\n        label = pipe_match.group(1).strip() or None\n        normalized = normalized[: pipe_match.start()] + normalized[pipe_match.end() :]\n    if label is None:\n        edge_match = _EDGE_LABEL_RE.search(normalized)\n        if edge_match:\n            label = edge_match.group(1).strip() or None\n            normalized = normalized[: edge_match.start()] + \"-->\" + normalized[edge_match.end() :]\n    return normalized, label\n\n\ndef _infer_decision_nodes(\n    nodes: dict[str, FlowNode],\n    outgoing: dict[str, list[FlowEdge]],\n) -> dict[str, FlowNode]:\n    updated: dict[str, FlowNode] = {}\n    for node_id, node in nodes.items():\n        kind = node.kind\n        if kind == \"task\" and len(outgoing.get(node_id, [])) > 1:\n            kind = \"decision\"\n        if kind != node.kind:\n            updated[node_id] = FlowNode(id=node.id, label=node.label, kind=kind)\n        else:\n            updated[node_id] = node\n    return updated\n"
  },
  {
    "path": "src/kimi_cli/skills/kimi-cli-help/SKILL.md",
    "content": "---\nname: kimi-cli-help\ndescription: Answer Kimi Code CLI usage, configuration, and troubleshooting questions. Use when user asks about Kimi Code CLI installation, setup, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, how something works internally, or any questions about Kimi Code CLI itself.\n---\n\n# Kimi Code CLI Help\n\nHelp users with Kimi Code CLI questions by consulting documentation and source code.\n\n## Strategy\n\n1. **Prefer official documentation** for most questions\n2. **Read local source** when in kimi-cli project itself, or when user is developing with kimi-cli as a library (e.g., importing from `kimi_cli` in their code)\n3. **Clone and explore source** for complex internals not covered in docs - **ask user for confirmation first**\n\n## Documentation\n\nBase URL: `https://moonshotai.github.io/kimi-cli/`\n\nFetch documentation index to find relevant pages:\n\n```\nhttps://moonshotai.github.io/kimi-cli/llms.txt\n```\n\n### Page URL Pattern\n\n- English: `https://moonshotai.github.io/kimi-cli/en/...`\n- Chinese: `https://moonshotai.github.io/kimi-cli/zh/...`\n\n### Topic Mapping\n\n| Topic | Page |\n|-------|------|\n| Installation, first run | `/en/guides/getting-started.md` |\n| Config files | `/en/configuration/config-files.md` |\n| Providers, models | `/en/configuration/providers.md` |\n| Environment variables | `/en/configuration/env-vars.md` |\n| Slash commands | `/en/reference/slash-commands.md` |\n| CLI flags | `/en/reference/kimi-command.md` |\n| Keyboard shortcuts | `/en/reference/keyboard.md` |\n| MCP | `/en/customization/mcp.md` |\n| Agents | `/en/customization/agents.md` |\n| Skills | `/en/customization/skills.md` |\n| FAQ | `/en/faq.md` |\n\n## Source Code\n\nRepository: `https://github.com/MoonshotAI/kimi-cli`\n\nWhen to read source:\n\n- In kimi-cli project directory (check `pyproject.toml` for `name = \"kimi-cli\"`)\n- User is importing `kimi_cli` as a library in their project\n- Question about internals not covered in docs (ask user before cloning)\n"
  },
  {
    "path": "src/kimi_cli/skills/skill-creator/SKILL.md",
    "content": "---\nname: skill-creator\ndescription: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Kimi's capabilities with specialized knowledge, workflows, or tool integrations.\n---\n\n# Skill Creator\n\nThis skill provides guidance for creating effective skills.\n\n## About Skills\n\nSkills are modular, self-contained packages that extend Kimi's capabilities by providing\nspecialized knowledge, workflows, and tools. Think of them as \"onboarding guides\" for specific\ndomains or tasks—they transform Kimi from a general-purpose agent into a specialized agent\nequipped with procedural knowledge that no model can fully possess.\n\n### What Skills Provide\n\n1. Specialized workflows - Multi-step procedures for specific domains\n2. Tool integrations - Instructions for working with specific file formats or APIs\n3. Domain expertise - Company-specific knowledge, schemas, business logic\n4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks\n\n## Core Principles\n\n### Concise is Key\n\nThe context window is a public good. Skills share the context window with everything else Kimi needs: system prompt, conversation history, other Skills' metadata, and the actual user request.\n\n**Default assumption: Kimi is already very smart.** Only add context Kimi doesn't already have. Challenge each piece of information: \"Does Kimi really need this explanation?\" and \"Does this paragraph justify its token cost?\"\n\nPrefer concise examples over verbose explanations.\n\n### Set Appropriate Degrees of Freedom\n\nMatch the level of specificity to the task's fragility and variability:\n\n**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.\n\n**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.\n\n**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.\n\nThink of Kimi as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).\n\n### Anatomy of a Skill\n\nEvery skill consists of a required SKILL.md file and optional bundled resources:\n\n```\nskill-name/\n├── SKILL.md (required)\n│   ├── YAML frontmatter metadata (required)\n│   │   ├── name: (required)\n│   │   └── description: (required)\n│   └── Markdown instructions (required)\n└── Bundled Resources (optional)\n    ├── scripts/          - Executable code (Python/Bash/etc.)\n    ├── references/       - Documentation intended to be loaded into context as needed\n    └── assets/           - Files used in output (templates, icons, fonts, etc.)\n```\n\n#### SKILL.md (required)\n\nEvery SKILL.md consists of:\n\n- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Kimi reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.\n- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).\n\n#### Bundled Resources (optional)\n\n##### Scripts (`scripts/`)\n\nExecutable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.\n\n- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed\n- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks\n- **Benefits**: Token efficient, deterministic, may be executed without loading into context\n- **Note**: Scripts may still need to be read by Kimi for patching or environment-specific adjustments\n\n##### References (`references/`)\n\nDocumentation and reference material intended to be loaded as needed into context to inform Kimi's process and thinking.\n\n- **When to include**: For documentation that Kimi should reference while working\n- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications\n- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides\n- **Benefits**: Keeps SKILL.md lean, loaded only when Kimi determines it's needed\n- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md\n- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.\n\n##### Assets (`assets/`)\n\nFiles not intended to be loaded into context, but rather used within the output Kimi produces.\n\n- **When to include**: When the skill needs files that will be used in the final output\n- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography\n- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified\n- **Benefits**: Separates output resources from documentation, enables Kimi to use files without loading them into context\n\n#### What to Not Include in a Skill\n\nA skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:\n\n- README.md\n- INSTALLATION_GUIDE.md\n- QUICK_REFERENCE.md\n- CHANGELOG.md\n- etc.\n\nThe skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.\n\n### Progressive Disclosure Design Principle\n\nSkills use a three-level loading system to manage context efficiently:\n\n1. **Metadata (name + description)** - Always in context (~100 words)\n2. **SKILL.md body** - When skill triggers (<5k words)\n3. **Bundled resources** - As needed by Kimi (Unlimited because scripts can be executed without reading into context window)\n\n#### Progressive Disclosure Patterns\n\nKeep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.\n\n**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.\n\n**Pattern 1: High-level guide with references**\n\n```markdown\n# PDF Processing\n\n## Quick start\n\nExtract text with pdfplumber:\n[code example]\n\n## Advanced features\n\n- **Form filling**: See [FORMS.md](FORMS.md) for complete guide\n- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods\n- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns\n```\n\nKimi loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.\n\n**Pattern 2: Domain-specific organization**\n\nFor Skills with multiple domains, organize content by domain to avoid loading irrelevant context:\n\n```\nbigquery-skill/\n├── SKILL.md (overview and navigation)\n└── reference/\n    ├── finance.md (revenue, billing metrics)\n    ├── sales.md (opportunities, pipeline)\n    ├── product.md (API usage, features)\n    └── marketing.md (campaigns, attribution)\n```\n\nWhen a user asks about sales metrics, Kimi only reads sales.md.\n\nSimilarly, for skills supporting multiple frameworks or variants, organize by variant:\n\n```\ncloud-deploy/\n├── SKILL.md (workflow + provider selection)\n└── references/\n    ├── aws.md (AWS deployment patterns)\n    ├── gcp.md (GCP deployment patterns)\n    └── azure.md (Azure deployment patterns)\n```\n\nWhen the user chooses AWS, Kimi only reads aws.md.\n\n**Pattern 3: Conditional details**\n\nShow basic content, link to advanced content:\n\n```markdown\n# DOCX Processing\n\n## Creating documents\n\nUse docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).\n\n## Editing documents\n\nFor simple edits, modify the XML directly.\n\n**For tracked changes**: See [REDLINING.md](REDLINING.md)\n**For OOXML details**: See [OOXML.md](OOXML.md)\n```\n\nKimi reads REDLINING.md or OOXML.md only when the user needs those features.\n\n**Important guidelines:**\n\n- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.\n- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Kimi can see the full scope when previewing.\n\n## Skill Locations and Discovery\n\nKimi Code CLI loads skills in layers (built-in -> user -> project). Within each layer, it uses the\nfirst existing directory in priority order. Built-in skills only load for LocalKaos or ACPKaos.\n\n**User level** (by priority):\n- `~/.config/agents/skills/` (recommended)\n- `~/.kimi/skills/`\n- `~/.claude/skills/`\n\n**Project level**:\n- `.agents/skills/`\n\n`--skills-dir` overrides discovery and loads only that directory (built-ins still load when\nsupported).\n\n## Skill Creation Process\n\nSkill creation involves these steps:\n\n1. Understand the skill with concrete examples\n2. Plan reusable skill contents (scripts, references, assets)\n3. Initialize the skill (run init_skill.py)\n4. Edit the skill (implement resources and write SKILL.md)\n5. Package the skill (run package_skill.py)\n6. Iterate based on real usage\n\nFollow these steps in order, skipping only if there is a clear reason why they are not applicable.\n\n### Skill Naming\n\n- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., \"Plan Mode\" -> `plan-mode`).\n- When generating names, generate a name under 64 characters (letters, digits, hyphens).\n- Prefer short, verb-led phrases that describe the action.\n- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`).\n- Name the skill folder exactly after the skill name.\n\n### Step 1: Understanding the Skill with Concrete Examples\n\nSkip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.\n\nTo create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.\n\nFor example, when building an image-editor skill, relevant questions include:\n\n- \"What functionality should the image-editor skill support? Editing, rotating, anything else?\"\n- \"Can you give some examples of how this skill would be used?\"\n- \"I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?\"\n- \"What would a user say that should trigger this skill?\"\n\nTo avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.\n\nConclude this step when there is a clear sense of the functionality the skill should support.\n\n### Step 2: Planning the Reusable Skill Contents\n\nTo turn concrete examples into an effective skill, analyze each example by:\n\n1. Considering how to execute on the example from scratch\n2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly\n\nExample: When building a `pdf-editor` skill to handle queries like \"Help me rotate this PDF,\" the analysis shows:\n\n1. Rotating a PDF requires re-writing the same code each time\n2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill\n\nExample: When designing a `frontend-webapp-builder` skill for queries like \"Build me a todo app\" or \"Build me a dashboard to track my steps,\" the analysis shows:\n\n1. Writing a frontend webapp requires the same boilerplate HTML/React each time\n2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill\n\nExample: When building a `big-query` skill to handle queries like \"How many users have logged in today?\" the analysis shows:\n\n1. Querying BigQuery requires re-discovering the table schemas and relationships each time\n2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill\n\nTo establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.\n\n### Step 3: Initializing the Skill\n\nAt this point, it is time to actually create the skill.\n\nSkip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.\n\nWhen creating a new skill from scratch, create a new skill directory with a required `SKILL.md`\nfile and any optional resource directories that the skill needs (`scripts/`, `references/`,\n`assets/`). Create only the directories you intend to populate.\n\nAfter initialization, customize the SKILL.md and add resources as needed.\n\n### Step 4: Edit the Skill\n\nWhen editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Kimi to use. Include information that would be beneficial and non-obvious to Kimi. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Kimi instance execute these tasks more effectively.\n\n#### Learn Proven Design Patterns\n\nCapture proven design patterns directly in this SKILL.md:\n\n- **Multi-step processes**: Clearly describe sequential workflows and conditional branches, including triggers, decision points, and expected outputs at each step.\n- **Specific output formats or quality standards**: Document required output shapes, templates, and examples directly in this SKILL.md so they are easy to follow.\n\n#### Start with Reusable Skill Contents\n\nTo begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.\n\nAdded scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.\n\nDelete any placeholder files that are not needed for the skill. Only create resource directories that are actually required.\n\n#### Update SKILL.md\n\n**Writing Guidelines:** Always use imperative/infinitive form.\n\n##### Frontmatter\n\nWrite the YAML frontmatter with `name` and `description`:\n\n- `name`: The skill name\n- `description`: This is the primary triggering mechanism for your skill, and helps Kimi understand when to use the skill.\n  - Include both what the Skill does and specific triggers/contexts for when to use it.\n  - Include all \"when to use\" information here - Not in the body. The body is only loaded after triggering, so \"When to Use This Skill\" sections in the body are not helpful to Kimi.\n  - Example description for a `docx` skill: \"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Kimi needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks\"\n\nDo not include any other fields in YAML frontmatter.\n\n##### Body\n\nWrite instructions for using the skill and its bundled resources.\n\n### Step 5: Packaging a Skill\n\nOnce development of the skill is complete, package it into a distributable `.skill` file (a zip\narchive). Before packaging, validate that the skill meets all requirements:\n\n1. **Validate** the skill, checking:\n\n   - YAML frontmatter format and required fields\n   - Skill naming conventions and directory structure\n   - Description completeness and quality\n   - File organization and resource references\n\n2. **Package** the skill if validation passes:\n\n   - Create an archive of the skill's root folder (the folder containing `SKILL.md` and all related files).\n   - Ensure the archive preserves the internal directory structure.\n   - Name the archive `<skill-name>.skill` (for example, `my-skill.skill`). The `.skill` file is a zip file with a `.skill` extension.\n\nExample packaging command:\n\n```bash\ncd <skills-root>\nzip -r my-skill.skill my-skill\n```\n\nIf validation fails (for example, due to malformed frontmatter, missing files, or an incomplete\ndescription), fix the issues and repackage the skill.\n\n### Step 6: Iterate\n\nAfter testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.\n\n**Iteration workflow:**\n\n1. Use the skill on real tasks\n2. Notice struggles or inefficiencies\n3. Identify how SKILL.md or bundled resources should be updated\n4. Implement changes and test again\n"
  },
  {
    "path": "src/kimi_cli/soul/__init__.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nfrom collections.abc import Callable, Coroutine\nfrom contextvars import ContextVar\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING, Any, Protocol, runtime_checkable\n\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.file import WireFile\nfrom kimi_cli.wire.types import ContentPart, MCPStatusSnapshot, WireMessage\n\nif TYPE_CHECKING:\n    from kimi_cli.llm import LLM, ModelCapability\n    from kimi_cli.soul.agent import Runtime\n    from kimi_cli.utils.slashcmd import SlashCommand\n\n\nclass LLMNotSet(Exception):\n    \"\"\"Raised when the LLM is not set.\"\"\"\n\n    def __init__(self) -> None:\n        super().__init__(\"LLM not set\")\n\n\nclass LLMNotSupported(Exception):\n    \"\"\"Raised when the LLM does not have required capabilities.\"\"\"\n\n    def __init__(self, llm: LLM, capabilities: list[ModelCapability]):\n        self.llm = llm\n        self.capabilities = capabilities\n        capabilities_str = \"capability\" if len(capabilities) == 1 else \"capabilities\"\n        super().__init__(\n            f\"LLM model '{llm.model_name}' does not support required {capabilities_str}: \"\n            f\"{', '.join(capabilities)}.\"\n        )\n\n\nclass MaxStepsReached(Exception):\n    \"\"\"Raised when the maximum number of steps is reached.\"\"\"\n\n    n_steps: int\n    \"\"\"The number of steps that have been taken.\"\"\"\n\n    def __init__(self, n_steps: int):\n        super().__init__(f\"Max number of steps reached: {n_steps}\")\n        self.n_steps = n_steps\n\n\ndef format_token_count(n: int) -> str:\n    \"\"\"Format token count as compact string, e.g. 28.5k, 128k, 1.2m.\"\"\"\n    suffix = \"\"\n    if n >= 1_000_000:\n        value = n / 1_000_000\n        suffix = \"m\"\n    elif n >= 1_000:\n        value = n / 1_000\n        suffix = \"k\"\n    else:\n        return str(n)\n\n    # Keep one decimal when needed, but drop trailing \".0\".\n    compact = f\"{value:.1f}\".rstrip(\"0\").rstrip(\".\")\n    return f\"{compact}{suffix}\"\n\n\ndef format_context_status(\n    context_usage: float,\n    context_tokens: int = 0,\n    max_context_tokens: int = 0,\n) -> str:\n    \"\"\"Format context status string for display in status bar.\"\"\"\n    bounded = max(0.0, min(context_usage, 1.0))\n    if max_context_tokens > 0:\n        used = format_token_count(context_tokens)\n        total = format_token_count(max_context_tokens)\n        return f\"context: {bounded:.1%} ({used}/{total})\"\n    return f\"context: {bounded:.1%}\"\n\n\n@dataclass(frozen=True, slots=True)\nclass StatusSnapshot:\n    context_usage: float\n    \"\"\"The usage of the context, in percentage.\"\"\"\n    yolo_enabled: bool = False\n    \"\"\"Whether YOLO (auto-approve) mode is enabled.\"\"\"\n    plan_mode: bool = False\n    \"\"\"Whether plan mode (read-only research and planning) is active.\"\"\"\n    context_tokens: int = 0\n    \"\"\"The number of tokens currently in the context.\"\"\"\n    max_context_tokens: int = 0\n    \"\"\"The maximum number of tokens the context can hold.\"\"\"\n    mcp_status: MCPStatusSnapshot | None = None\n    \"\"\"The current MCP startup snapshot, if MCP is configured.\"\"\"\n\n\n@runtime_checkable\nclass Soul(Protocol):\n    @property\n    def name(self) -> str:\n        \"\"\"The name of the soul.\"\"\"\n        ...\n\n    @property\n    def model_name(self) -> str:\n        \"\"\"The name of the LLM model used by the soul. Empty string if LLM is not set.\"\"\"\n        ...\n\n    @property\n    def model_capabilities(self) -> set[ModelCapability] | None:\n        \"\"\"The capabilities of the LLM model used by the soul. None if LLM is not set.\"\"\"\n        ...\n\n    @property\n    def thinking(self) -> bool | None:\n        \"\"\"\n        Whether thinking mode is currently enabled.\n        None if LLM is not set or thinking mode is not set explicitly.\n        \"\"\"\n        ...\n\n    @property\n    def status(self) -> StatusSnapshot:\n        \"\"\"The current status of the soul. The returned value is immutable.\"\"\"\n        ...\n\n    @property\n    def available_slash_commands(self) -> list[SlashCommand[Any]]:\n        \"\"\"List of available slash commands supported by the soul.\"\"\"\n        ...\n\n    async def run(self, user_input: str | list[ContentPart]):\n        \"\"\"\n        Run the agent with the given user input until the max steps or no more tool calls.\n\n        Args:\n            user_input (str | list[ContentPart]): The user input to the agent.\n                Can be a slash command call or natural language input.\n\n        Raises:\n            LLMNotSet: When the LLM is not set.\n            LLMNotSupported: When the LLM does not have required capabilities.\n            ChatProviderError: When the LLM provider returns an error.\n            MaxStepsReached: When the maximum number of steps is reached.\n            asyncio.CancelledError: When the run is cancelled by user.\n        \"\"\"\n        ...\n\n\ntype UILoopFn = Callable[[Wire], Coroutine[Any, Any, None]]\n\"\"\"A long-running async function to visualize the agent behavior.\"\"\"\n\n\nclass RunCancelled(Exception):\n    \"\"\"The run was cancelled by the cancel event.\"\"\"\n\n\nasync def run_soul(\n    soul: Soul,\n    user_input: str | list[ContentPart],\n    ui_loop_fn: UILoopFn,\n    cancel_event: asyncio.Event,\n    wire_file: WireFile | None = None,\n    runtime: Runtime | None = None,\n) -> None:\n    \"\"\"\n    Run the soul with the given user input, connecting it to the UI loop with a `Wire`.\n\n    `cancel_event` is a outside handle that can be used to cancel the run. When the\n    event is set, the run will be gracefully stopped and a `RunCancelled` will be raised.\n\n    Raises:\n        LLMNotSet: When the LLM is not set.\n        LLMNotSupported: When the LLM does not have required capabilities.\n        ChatProviderError: When the LLM provider returns an error.\n        MaxStepsReached: When the maximum number of steps is reached.\n        RunCancelled: When the run is cancelled by the cancel event.\n    \"\"\"\n    wire = Wire(file_backend=wire_file)\n    wire_token = _current_wire.set(wire)\n\n    logger.debug(\"Starting UI loop with function: {ui_loop_fn}\", ui_loop_fn=ui_loop_fn)\n    ui_task = asyncio.create_task(ui_loop_fn(wire))\n\n    logger.debug(\"Starting soul run\")\n    soul_task = asyncio.create_task(soul.run(user_input))\n    notification_task = asyncio.create_task(_pump_notifications_to_wire(runtime, wire))\n\n    cancel_event_task = asyncio.create_task(cancel_event.wait())\n    await asyncio.wait(\n        [soul_task, cancel_event_task],\n        return_when=asyncio.FIRST_COMPLETED,\n    )\n\n    try:\n        if cancel_event.is_set():\n            logger.debug(\"Cancelling the run task\")\n            soul_task.cancel()\n            try:\n                await soul_task\n            except asyncio.CancelledError:\n                raise RunCancelled from None\n        else:\n            assert soul_task.done()  # either stop event is set or the run task is done\n            cancel_event_task.cancel()\n            with contextlib.suppress(asyncio.CancelledError):\n                await cancel_event_task\n            soul_task.result()  # this will raise if any exception was raised in the run task\n    finally:\n        notification_task.cancel()\n        with contextlib.suppress(asyncio.CancelledError):\n            await notification_task\n        try:\n            await _deliver_notifications_to_wire_once(runtime, wire)\n        except Exception:\n            logger.exception(\"Failed to flush notifications to wire during shutdown\")\n        logger.debug(\"Shutting down the UI loop\")\n        # shutting down the wire should break the UI loop\n        wire.shutdown()\n        await wire.join()\n        try:\n            await asyncio.wait_for(ui_task, timeout=0.5)\n        except QueueShutDown:\n            logger.debug(\"UI loop shut down\")\n            pass\n        except TimeoutError:\n            logger.warning(\"UI loop timed out\")\n        finally:\n            _current_wire.reset(wire_token)\n\n\n_current_wire = ContextVar[Wire | None](\"current_wire\", default=None)\n\n\ndef get_wire_or_none() -> Wire | None:\n    \"\"\"\n    Get the current wire or None.\n    Expect to be not None when called from anywhere in the agent loop.\n    \"\"\"\n    return _current_wire.get()\n\n\ndef wire_send(msg: WireMessage) -> None:\n    \"\"\"\n    Send a wire message to the current wire.\n    Take this as `print` and `input` for souls.\n    Souls should always use this function to send wire messages.\n    \"\"\"\n    wire = get_wire_or_none()\n    assert wire is not None, \"Wire is expected to be set when soul is running\"\n    wire.soul_side.send(msg)\n\n\nasync def _pump_notifications_to_wire(runtime: Runtime | None, wire: Wire) -> None:\n    while True:\n        try:\n            await _deliver_notifications_to_wire_once(runtime, wire)\n        except asyncio.CancelledError:\n            raise\n        except Exception:\n            logger.exception(\"Notification wire pump failed\")\n        await asyncio.sleep(1.0)\n\n\nasync def _deliver_notifications_to_wire_once(runtime: Runtime | None, wire: Wire) -> None:\n    if runtime is None or runtime.role != \"root\":\n        return\n\n    from kimi_cli.notifications import NotificationView, to_wire_notification\n\n    def _send_notification(view: NotificationView) -> None:\n        wire.soul_side.send(to_wire_notification(view))\n\n    await runtime.notifications.deliver_pending(\n        \"wire\",\n        limit=8,\n        before_claim=runtime.background_tasks.reconcile,\n        on_notification=_send_notification,\n    )\n"
  },
  {
    "path": "src/kimi_cli/soul/agent.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom collections.abc import Mapping\nfrom dataclasses import asdict, dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, Literal\n\nimport pydantic\nfrom jinja2 import Environment as JinjaEnvironment\nfrom jinja2 import StrictUndefined, TemplateError, UndefinedError\nfrom kaos.path import KaosPath\nfrom kosong.tooling import Toolset\n\nfrom kimi_cli.agentspec import load_agent_spec\nfrom kimi_cli.auth.oauth import OAuthManager\nfrom kimi_cli.background import BackgroundTaskManager\nfrom kimi_cli.config import Config\nfrom kimi_cli.exception import MCPConfigError, SystemPromptTemplateError\nfrom kimi_cli.llm import LLM\nfrom kimi_cli.notifications import NotificationManager\nfrom kimi_cli.session import Session\nfrom kimi_cli.skill import Skill, discover_skills_from_roots, index_skills, resolve_skills_roots\nfrom kimi_cli.soul.approval import Approval, ApprovalState\nfrom kimi_cli.soul.denwarenji import DenwaRenji\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.utils.environment import Environment\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.path import list_directory\n\nif TYPE_CHECKING:\n    from fastmcp.mcp_config import MCPConfig\n\n\n@dataclass(frozen=True, slots=True, kw_only=True)\nclass BuiltinSystemPromptArgs:\n    \"\"\"Builtin system prompt arguments.\"\"\"\n\n    KIMI_NOW: str\n    \"\"\"The current datetime.\"\"\"\n    KIMI_WORK_DIR: KaosPath\n    \"\"\"The absolute path of current working directory.\"\"\"\n    KIMI_WORK_DIR_LS: str\n    \"\"\"The directory listing of current working directory.\"\"\"\n    KIMI_AGENTS_MD: str  # TODO: move to first message from system prompt\n    \"\"\"The content of AGENTS.md.\"\"\"\n    KIMI_SKILLS: str\n    \"\"\"Formatted information about available skills.\"\"\"\n    KIMI_ADDITIONAL_DIRS_INFO: str\n    \"\"\"Formatted information about additional directories in the workspace.\"\"\"\n\n\nasync def load_agents_md(work_dir: KaosPath) -> str | None:\n    paths = [\n        work_dir / \"AGENTS.md\",\n        work_dir / \"agents.md\",\n    ]\n    for path in paths:\n        if await path.is_file():\n            logger.info(\"Loaded agents.md: {path}\", path=path)\n            return (await path.read_text()).strip()\n    logger.info(\"No AGENTS.md found in {work_dir}\", work_dir=work_dir)\n    return None\n\n\n@dataclass(slots=True, kw_only=True)\nclass Runtime:\n    \"\"\"Agent runtime.\"\"\"\n\n    config: Config\n    oauth: OAuthManager\n    llm: LLM | None  # we do not freeze the `Runtime` dataclass because LLM can be changed\n    session: Session\n    builtin_args: BuiltinSystemPromptArgs\n    denwa_renji: DenwaRenji\n    approval: Approval\n    labor_market: LaborMarket\n    environment: Environment\n    notifications: NotificationManager\n    background_tasks: BackgroundTaskManager\n    skills: dict[str, Skill]\n    additional_dirs: list[KaosPath]\n    role: Literal[\"root\", \"fixed_subagent\", \"dynamic_subagent\"] = \"root\"\n\n    @staticmethod\n    async def create(\n        config: Config,\n        oauth: OAuthManager,\n        llm: LLM | None,\n        session: Session,\n        yolo: bool,\n        skills_dir: KaosPath | None = None,\n    ) -> Runtime:\n        ls_output, agents_md, environment = await asyncio.gather(\n            list_directory(session.work_dir),\n            load_agents_md(session.work_dir),\n            Environment.detect(),\n        )\n\n        # Discover and format skills\n        skills_roots = await resolve_skills_roots(session.work_dir, skills_dir_override=skills_dir)\n        skills = await discover_skills_from_roots(skills_roots)\n        skills_by_name = index_skills(skills)\n        logger.info(\"Discovered {count} skill(s)\", count=len(skills))\n        skills_formatted = \"\\n\".join(\n            (\n                f\"- {skill.name}\\n\"\n                f\"  - Path: {skill.skill_md_file}\\n\"\n                f\"  - Description: {skill.description}\"\n            )\n            for skill in skills\n        )\n\n        # Restore additional directories from session state, pruning stale entries\n        additional_dirs: list[KaosPath] = []\n        pruned = False\n        valid_dir_strs: list[str] = []\n        for dir_str in session.state.additional_dirs:\n            d = KaosPath(dir_str).canonical()\n            if await d.is_dir():\n                additional_dirs.append(d)\n                valid_dir_strs.append(dir_str)\n            else:\n                logger.warning(\n                    \"Additional directory no longer exists, removing from state: {dir}\",\n                    dir=dir_str,\n                )\n                pruned = True\n        if pruned:\n            session.state.additional_dirs = valid_dir_strs\n            session.save_state()\n\n        # Format additional dirs info for system prompt\n        additional_dirs_info = \"\"\n        if additional_dirs:\n            parts: list[str] = []\n            for d in additional_dirs:\n                try:\n                    dir_ls = await list_directory(d)\n                except OSError:\n                    logger.warning(\n                        \"Cannot list additional directory, skipping listing: {dir}\", dir=d\n                    )\n                    dir_ls = \"[directory not readable]\"\n                parts.append(f\"### `{d}`\\n\\n```\\n{dir_ls}\\n```\")\n            additional_dirs_info = \"\\n\\n\".join(parts)\n\n        # Merge CLI flag with persisted session state\n        effective_yolo = yolo or session.state.approval.yolo\n        saved_actions = set(session.state.approval.auto_approve_actions)\n\n        def _on_approval_change() -> None:\n            session.state.approval.yolo = approval_state.yolo\n            session.state.approval.auto_approve_actions = set(approval_state.auto_approve_actions)\n            session.save_state()\n\n        approval_state = ApprovalState(\n            yolo=effective_yolo,\n            auto_approve_actions=saved_actions,\n            on_change=_on_approval_change,\n        )\n        notifications = NotificationManager(\n            session.context_file.parent / \"notifications\",\n            config.notifications,\n        )\n\n        return Runtime(\n            config=config,\n            oauth=oauth,\n            llm=llm,\n            session=session,\n            builtin_args=BuiltinSystemPromptArgs(\n                KIMI_NOW=datetime.now().astimezone().isoformat(),\n                KIMI_WORK_DIR=session.work_dir,\n                KIMI_WORK_DIR_LS=ls_output,\n                KIMI_AGENTS_MD=agents_md or \"\",\n                KIMI_SKILLS=skills_formatted or \"No skills found.\",\n                KIMI_ADDITIONAL_DIRS_INFO=additional_dirs_info,\n            ),\n            denwa_renji=DenwaRenji(),\n            approval=Approval(state=approval_state),\n            labor_market=LaborMarket(),\n            environment=environment,\n            notifications=notifications,\n            background_tasks=BackgroundTaskManager(\n                session,\n                config.background,\n                notifications=notifications,\n            ),\n            skills=skills_by_name,\n            additional_dirs=additional_dirs,\n            role=\"root\",\n        )\n\n    def copy_for_fixed_subagent(self) -> Runtime:\n        \"\"\"Clone runtime for fixed subagent.\"\"\"\n        return Runtime(\n            config=self.config,\n            oauth=self.oauth,\n            llm=self.llm,\n            session=self.session,\n            builtin_args=self.builtin_args,\n            denwa_renji=DenwaRenji(),  # subagent must have its own DenwaRenji\n            approval=self.approval.share(),\n            labor_market=LaborMarket(),  # fixed subagent has its own LaborMarket\n            environment=self.environment,\n            notifications=self.notifications,\n            background_tasks=self.background_tasks.copy_for_role(\"fixed_subagent\"),\n            skills=self.skills,\n            # Share the same list reference so /add-dir mutations propagate to all agents\n            additional_dirs=self.additional_dirs,\n            role=\"fixed_subagent\",\n        )\n\n    def copy_for_dynamic_subagent(self) -> Runtime:\n        \"\"\"Clone runtime for dynamic subagent.\"\"\"\n        return Runtime(\n            config=self.config,\n            oauth=self.oauth,\n            llm=self.llm,\n            session=self.session,\n            builtin_args=self.builtin_args,\n            denwa_renji=DenwaRenji(),  # subagent must have its own DenwaRenji\n            approval=self.approval.share(),\n            labor_market=self.labor_market,  # dynamic subagent shares LaborMarket with main agent\n            environment=self.environment,\n            notifications=self.notifications,\n            background_tasks=self.background_tasks.copy_for_role(\"dynamic_subagent\"),\n            skills=self.skills,\n            # Share the same list reference so /add-dir mutations propagate to all agents\n            additional_dirs=self.additional_dirs,\n            role=\"dynamic_subagent\",\n        )\n\n\n@dataclass(frozen=True, slots=True, kw_only=True)\nclass Agent:\n    \"\"\"The loaded agent.\"\"\"\n\n    name: str\n    system_prompt: str\n    toolset: Toolset\n    runtime: Runtime\n    \"\"\"Each agent has its own runtime, which should be derived from its main agent.\"\"\"\n\n\nclass LaborMarket:\n    def __init__(self):\n        self.fixed_subagents: dict[str, Agent] = {}\n        self.fixed_subagent_descs: dict[str, str] = {}\n        self.dynamic_subagents: dict[str, Agent] = {}\n\n    @property\n    def subagents(self) -> Mapping[str, Agent]:\n        \"\"\"Get all subagents in the labor market.\"\"\"\n        return {**self.fixed_subagents, **self.dynamic_subagents}\n\n    def add_fixed_subagent(self, name: str, agent: Agent, description: str):\n        \"\"\"Add a fixed subagent.\"\"\"\n        self.fixed_subagents[name] = agent\n        self.fixed_subagent_descs[name] = description\n\n    def add_dynamic_subagent(self, name: str, agent: Agent):\n        \"\"\"Add a dynamic subagent.\"\"\"\n        self.dynamic_subagents[name] = agent\n\n\nasync def load_agent(\n    agent_file: Path,\n    runtime: Runtime,\n    *,\n    mcp_configs: list[MCPConfig] | list[dict[str, Any]],\n    start_mcp_loading: bool = True,\n    _restore_dynamic_subagents: bool = True,\n) -> Agent:\n    \"\"\"\n    Load agent from specification file.\n\n    Raises:\n        FileNotFoundError: When the agent file is not found.\n        AgentSpecError(KimiCLIException, ValueError): When the agent specification is invalid.\n        SystemPromptTemplateError(KimiCLIException, ValueError): When the system prompt template\n            is invalid.\n        InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded.\n        MCPConfigError(KimiCLIException, ValueError): When any MCP configuration is invalid.\n        MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be connected.\n    \"\"\"\n    logger.info(\"Loading agent: {agent_file}\", agent_file=agent_file)\n    agent_spec = load_agent_spec(agent_file)\n\n    system_prompt = _load_system_prompt(\n        agent_spec.system_prompt_path,\n        agent_spec.system_prompt_args,\n        runtime.builtin_args,\n    )\n\n    # load subagents before loading tools because Task tool depends on LaborMarket on initialization\n    for subagent_name, subagent_spec in agent_spec.subagents.items():\n        logger.debug(\"Loading subagent: {subagent_name}\", subagent_name=subagent_name)\n        subagent = await load_agent(\n            subagent_spec.path,\n            runtime.copy_for_fixed_subagent(),\n            mcp_configs=mcp_configs,\n            start_mcp_loading=start_mcp_loading,\n            _restore_dynamic_subagents=False,\n        )\n        runtime.labor_market.add_fixed_subagent(subagent_name, subagent, subagent_spec.description)\n\n    toolset = KimiToolset()\n    tool_deps = {\n        KimiToolset: toolset,\n        Runtime: runtime,\n        # TODO: remove all the following dependencies and use Runtime instead\n        Config: runtime.config,\n        BuiltinSystemPromptArgs: runtime.builtin_args,\n        Session: runtime.session,\n        DenwaRenji: runtime.denwa_renji,\n        Approval: runtime.approval,\n        LaborMarket: runtime.labor_market,\n        Environment: runtime.environment,\n    }\n    tools = agent_spec.tools\n    if agent_spec.exclude_tools:\n        logger.debug(\"Excluding tools: {tools}\", tools=agent_spec.exclude_tools)\n        tools = [tool for tool in tools if tool not in agent_spec.exclude_tools]\n    toolset.load_tools(tools, tool_deps)\n\n    # Load plugin tools\n    from kimi_cli.plugin.manager import get_plugins_dir\n    from kimi_cli.plugin.tool import load_plugin_tools\n\n    plugin_tools = load_plugin_tools(get_plugins_dir(), runtime.config, approval=runtime.approval)\n    for plugin_tool in plugin_tools:\n        if toolset.find(plugin_tool.name) is not None:\n            logger.warning(\n                \"Plugin tool '{name}' conflicts with an existing tool, skipping\",\n                name=plugin_tool.name,\n            )\n            continue\n        toolset.add(plugin_tool)\n\n    if mcp_configs:\n        validated_mcp_configs: list[MCPConfig] = []\n        if mcp_configs:\n            from fastmcp.mcp_config import MCPConfig\n\n            for mcp_config in mcp_configs:\n                try:\n                    validated_mcp_configs.append(\n                        mcp_config\n                        if isinstance(mcp_config, MCPConfig)\n                        else MCPConfig.model_validate(mcp_config)\n                    )\n                except pydantic.ValidationError as e:\n                    raise MCPConfigError(f\"Invalid MCP config: {e}\") from e\n        if start_mcp_loading:\n            await toolset.load_mcp_tools(validated_mcp_configs, runtime, in_background=True)\n        else:\n            toolset.defer_mcp_tool_loading(validated_mcp_configs, runtime)\n\n    # Restore dynamic subagents from persisted session state\n    # Skip for fixed subagents — they have their own isolated LaborMarket\n    if _restore_dynamic_subagents:\n        for subagent_spec in runtime.session.state.dynamic_subagents:\n            if subagent_spec.name not in runtime.labor_market.subagents:\n                subagent = Agent(\n                    name=subagent_spec.name,\n                    system_prompt=subagent_spec.system_prompt,\n                    toolset=toolset,\n                    runtime=runtime.copy_for_dynamic_subagent(),\n                )\n                runtime.labor_market.add_dynamic_subagent(subagent_spec.name, subagent)\n\n    return Agent(\n        name=agent_spec.name,\n        system_prompt=system_prompt,\n        toolset=toolset,\n        runtime=runtime,\n    )\n\n\ndef _load_system_prompt(\n    path: Path, args: dict[str, str], builtin_args: BuiltinSystemPromptArgs\n) -> str:\n    logger.info(\"Loading system prompt: {path}\", path=path)\n    system_prompt = path.read_text(encoding=\"utf-8\").strip()\n    logger.debug(\n        \"Substituting system prompt with builtin args: {builtin_args}, spec args: {spec_args}\",\n        builtin_args=builtin_args,\n        spec_args=args,\n    )\n    env = JinjaEnvironment(\n        keep_trailing_newline=True,\n        lstrip_blocks=True,\n        trim_blocks=True,\n        variable_start_string=\"${\",\n        variable_end_string=\"}\",\n        undefined=StrictUndefined,\n    )\n    try:\n        template = env.from_string(system_prompt)\n        return template.render(asdict(builtin_args), **args)\n    except UndefinedError as exc:\n        raise SystemPromptTemplateError(f\"Missing system prompt arg in {path}: {exc}\") from exc\n    except TemplateError as exc:\n        raise SystemPromptTemplateError(f\"Invalid system prompt template: {path}: {exc}\") from exc\n"
  },
  {
    "path": "src/kimi_cli/soul/approval.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport uuid\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom typing import Literal\n\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.utils.aioqueue import Queue\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire.types import DisplayBlock\n\n\n@dataclass(frozen=True, slots=True, kw_only=True)\nclass Request:\n    id: str\n    tool_call_id: str\n    sender: str\n    action: str\n    description: str\n    display: list[DisplayBlock]\n\n\ntype Response = Literal[\"approve\", \"approve_for_session\", \"reject\"]\n\n\nclass ApprovalState:\n    def __init__(\n        self,\n        yolo: bool = False,\n        auto_approve_actions: set[str] | None = None,\n        on_change: Callable[[], None] | None = None,\n    ):\n        self.yolo = yolo\n        self.auto_approve_actions: set[str] = auto_approve_actions or set()\n        \"\"\"Set of action names that should automatically be approved.\"\"\"\n        self._on_change = on_change\n\n    def notify_change(self) -> None:\n        if self._on_change is not None:\n            self._on_change()\n\n\nclass Approval:\n    def __init__(self, yolo: bool = False, *, state: ApprovalState | None = None):\n        self._request_queue = Queue[Request]()\n        self._requests: dict[str, tuple[Request, asyncio.Future[bool]]] = {}\n        self._state = state or ApprovalState(yolo=yolo)\n\n    def share(self) -> Approval:\n        \"\"\"Create a new approval queue that shares state (yolo + auto-approve).\"\"\"\n        return Approval(state=self._state)\n\n    def set_yolo(self, yolo: bool) -> None:\n        self._state.yolo = yolo\n        self._state.notify_change()\n\n    def is_yolo(self) -> bool:\n        return self._state.yolo\n\n    async def request(\n        self,\n        sender: str,\n        action: str,\n        description: str,\n        display: list[DisplayBlock] | None = None,\n    ) -> bool:\n        \"\"\"\n        Request approval for the given action. Intended to be called by tools.\n\n        Args:\n            sender (str): The name of the sender.\n            action (str): The action to request approval for.\n                This is used to identify the action for auto-approval.\n            description (str): The description of the action. This is used to display to the user.\n\n        Returns:\n            bool: True if the action is approved, False otherwise.\n\n        Raises:\n            RuntimeError: If the approval is requested from outside a tool call.\n        \"\"\"\n        tool_call = get_current_tool_call_or_none()\n        if tool_call is None:\n            raise RuntimeError(\"Approval must be requested from a tool call.\")\n\n        logger.debug(\n            \"{tool_name} ({tool_call_id}) requesting approval: {action} {description}\",\n            tool_name=tool_call.function.name,\n            tool_call_id=tool_call.id,\n            action=action,\n            description=description,\n        )\n        if self._state.yolo:\n            return True\n\n        if action in self._state.auto_approve_actions:\n            return True\n\n        request = Request(\n            id=str(uuid.uuid4()),\n            tool_call_id=tool_call.id,\n            sender=sender,\n            action=action,\n            description=description,\n            display=display or [],\n        )\n        approved_future = asyncio.Future[bool]()\n        self._request_queue.put_nowait(request)\n        self._requests[request.id] = (request, approved_future)\n        return await approved_future\n\n    async def fetch_request(self) -> Request:\n        \"\"\"\n        Fetch an approval request from the queue. Intended to be called by the soul.\n        \"\"\"\n        while True:\n            request = await self._request_queue.get()\n            if request.action in self._state.auto_approve_actions:\n                # the action is not auto-approved when the request was created, but now it should be\n                logger.debug(\n                    \"Auto-approving previously requested action: {action}\", action=request.action\n                )\n                self.resolve_request(request.id, \"approve\")\n                continue\n\n            return request\n\n    def resolve_request(self, request_id: str, response: Response) -> None:\n        \"\"\"\n        Resolve an approval request with the given response. Intended to be called by the soul.\n\n        Args:\n            request_id (str): The ID of the request to resolve.\n            response (Response): The response to the request.\n\n        Raises:\n            KeyError: If there is no pending request with the given ID.\n        \"\"\"\n        request_tuple = self._requests.pop(request_id, None)\n        if request_tuple is None:\n            raise KeyError(f\"No pending request with ID {request_id}\")\n        request, future = request_tuple\n\n        logger.debug(\n            \"Received approval response for request {request_id}: {response}\",\n            request_id=request_id,\n            response=response,\n        )\n        match response:\n            case \"approve\":\n                future.set_result(True)\n            case \"approve_for_session\":\n                self._state.auto_approve_actions.add(request.action)\n                self._state.notify_change()\n                future.set_result(True)\n            case \"reject\":\n                future.set_result(False)\n"
  },
  {
    "path": "src/kimi_cli/soul/compaction.py",
    "content": "from __future__ import annotations\n\nfrom collections.abc import Sequence\nfrom typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable\n\nimport kosong\nfrom kosong.chat_provider import TokenUsage\nfrom kosong.message import Message\nfrom kosong.tooling.empty import EmptyToolset\n\nimport kimi_cli.prompts as prompts\nfrom kimi_cli.llm import LLM\nfrom kimi_cli.soul.message import system\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire.types import ContentPart, TextPart, ThinkPart\n\n\nclass CompactionResult(NamedTuple):\n    messages: Sequence[Message]\n    usage: TokenUsage | None\n\n    @property\n    def estimated_token_count(self) -> int:\n        \"\"\"Estimate the token count of the compacted messages.\n\n        When LLM usage is available, ``usage.output`` gives the exact token count\n        of the generated summary (the first message).  Preserved messages (all\n        subsequent messages) are estimated from their text length.\n\n        When usage is not available (no compaction LLM call was made), all\n        messages are estimated from text length.\n\n        The estimate is intentionally conservative — it will be replaced by the\n        real value on the next LLM call.\n        \"\"\"\n        if self.usage is not None and len(self.messages) > 0:\n            summary_tokens = self.usage.output\n            preserved_tokens = estimate_text_tokens(self.messages[1:])\n            return summary_tokens + preserved_tokens\n\n        return estimate_text_tokens(self.messages)\n\n\ndef estimate_text_tokens(messages: Sequence[Message]) -> int:\n    \"\"\"Estimate tokens from message text content using a character-based heuristic.\"\"\"\n    total_chars = 0\n    for msg in messages:\n        for part in msg.content:\n            if isinstance(part, TextPart):\n                total_chars += len(part.text)\n    # ~4 chars per token for English; somewhat underestimates for CJK text,\n    # but this is a temporary estimate that gets corrected on the next LLM call.\n    return total_chars // 4\n\n\ndef should_auto_compact(\n    token_count: int,\n    max_context_size: int,\n    *,\n    trigger_ratio: float,\n    reserved_context_size: int,\n) -> bool:\n    \"\"\"Determine whether auto-compaction should be triggered.\n\n    Returns True when either condition is met (whichever fires first):\n    - Ratio-based: token_count >= max_context_size * trigger_ratio\n    - Reserved-based: token_count + reserved_context_size >= max_context_size\n    \"\"\"\n    return (\n        token_count >= max_context_size * trigger_ratio\n        or token_count + reserved_context_size >= max_context_size\n    )\n\n\n@runtime_checkable\nclass Compaction(Protocol):\n    async def compact(\n        self, messages: Sequence[Message], llm: LLM, *, custom_instruction: str = \"\"\n    ) -> CompactionResult:\n        \"\"\"\n        Compact a sequence of messages into a new sequence of messages.\n\n        Args:\n            messages (Sequence[Message]): The messages to compact.\n            llm (LLM): The LLM to use for compaction.\n            custom_instruction: Optional user instruction to guide compaction focus.\n\n        Returns:\n            CompactionResult: The compacted messages and token usage from the compaction LLM call.\n\n        Raises:\n            ChatProviderError: When the chat provider returns an error.\n        \"\"\"\n        ...\n\n\nif TYPE_CHECKING:\n\n    def type_check(simple: SimpleCompaction):\n        _: Compaction = simple\n\n\nclass SimpleCompaction:\n    def __init__(self, max_preserved_messages: int = 2) -> None:\n        self.max_preserved_messages = max_preserved_messages\n\n    async def compact(\n        self, messages: Sequence[Message], llm: LLM, *, custom_instruction: str = \"\"\n    ) -> CompactionResult:\n        compact_message, to_preserve = self.prepare(messages, custom_instruction=custom_instruction)\n        if compact_message is None:\n            return CompactionResult(messages=to_preserve, usage=None)\n\n        # Call kosong.step to get the compacted context\n        # TODO: set max completion tokens\n        logger.debug(\"Compacting context...\")\n        result = await kosong.step(\n            chat_provider=llm.chat_provider,\n            system_prompt=\"You are a helpful assistant that compacts conversation context.\",\n            toolset=EmptyToolset(),\n            history=[compact_message],\n        )\n        if result.usage:\n            logger.debug(\n                \"Compaction used {input} input tokens and {output} output tokens\",\n                input=result.usage.input,\n                output=result.usage.output,\n            )\n\n        content: list[ContentPart] = [\n            system(\"Previous context has been compacted. Here is the compaction output:\")\n        ]\n        compacted_msg = result.message\n\n        # drop thinking parts if any\n        content.extend(part for part in compacted_msg.content if not isinstance(part, ThinkPart))\n        compacted_messages: list[Message] = [Message(role=\"user\", content=content)]\n        compacted_messages.extend(to_preserve)\n        return CompactionResult(messages=compacted_messages, usage=result.usage)\n\n    class PrepareResult(NamedTuple):\n        compact_message: Message | None\n        to_preserve: Sequence[Message]\n\n    def prepare(\n        self, messages: Sequence[Message], *, custom_instruction: str = \"\"\n    ) -> PrepareResult:\n        if not messages or self.max_preserved_messages <= 0:\n            return self.PrepareResult(compact_message=None, to_preserve=messages)\n\n        history = list(messages)\n        preserve_start_index = len(history)\n        n_preserved = 0\n        for index in range(len(history) - 1, -1, -1):\n            if history[index].role in {\"user\", \"assistant\"}:\n                n_preserved += 1\n                if n_preserved == self.max_preserved_messages:\n                    preserve_start_index = index\n                    break\n\n        if n_preserved < self.max_preserved_messages:\n            return self.PrepareResult(compact_message=None, to_preserve=messages)\n\n        to_compact = history[:preserve_start_index]\n        to_preserve = history[preserve_start_index:]\n\n        if not to_compact:\n            # Let's hope this won't exceed the context size limit\n            return self.PrepareResult(compact_message=None, to_preserve=to_preserve)\n\n        # Create input message for compaction\n        compact_message = Message(role=\"user\", content=[])\n        for i, msg in enumerate(to_compact):\n            compact_message.content.append(\n                TextPart(text=f\"## Message {i + 1}\\nRole: {msg.role}\\nContent:\\n\")\n            )\n            compact_message.content.extend(\n                part for part in msg.content if isinstance(part, TextPart)\n            )\n        prompt_text = \"\\n\" + prompts.COMPACT\n        if custom_instruction:\n            prompt_text += (\n                \"\\n\\n**User's Custom Compaction Instruction:**\\n\"\n                \"The user has specifically requested the following focus during compaction. \"\n                \"You MUST prioritize this instruction above the default compression priorities:\\n\"\n                f\"{custom_instruction}\"\n            )\n        compact_message.content.append(TextPart(text=prompt_text))\n        return self.PrepareResult(compact_message=compact_message, to_preserve=to_preserve)\n"
  },
  {
    "path": "src/kimi_cli/soul/context.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom collections.abc import Sequence\nfrom pathlib import Path\n\nimport aiofiles\nimport aiofiles.os\nfrom kosong.message import Message\n\nfrom kimi_cli.soul.message import system\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.path import next_available_rotation\n\n\nclass Context:\n    def __init__(self, file_backend: Path):\n        self._file_backend = file_backend\n        self._history: list[Message] = []\n        self._token_count: int = 0\n        self._next_checkpoint_id: int = 0\n        \"\"\"The ID of the next checkpoint, starting from 0, incremented after each checkpoint.\"\"\"\n        self._system_prompt: str | None = None\n\n    async def restore(self) -> bool:\n        logger.debug(\"Restoring context from file: {file_backend}\", file_backend=self._file_backend)\n        if self._history:\n            logger.error(\"The context storage is already modified\")\n            raise RuntimeError(\"The context storage is already modified\")\n        if not self._file_backend.exists():\n            logger.debug(\"No context file found, skipping restoration\")\n            return False\n        if self._file_backend.stat().st_size == 0:\n            logger.debug(\"Empty context file, skipping restoration\")\n            return False\n\n        async with aiofiles.open(self._file_backend, encoding=\"utf-8\") as f:\n            async for line in f:\n                if not line.strip():\n                    continue\n                line_json = json.loads(line)\n                if line_json[\"role\"] == \"_system_prompt\":\n                    self._system_prompt = line_json[\"content\"]\n                    continue\n                if line_json[\"role\"] == \"_usage\":\n                    self._token_count = line_json[\"token_count\"]\n                    continue\n                if line_json[\"role\"] == \"_checkpoint\":\n                    self._next_checkpoint_id = line_json[\"id\"] + 1\n                    continue\n                message = Message.model_validate(line_json)\n                self._history.append(message)\n\n        return True\n\n    @property\n    def history(self) -> Sequence[Message]:\n        return self._history\n\n    @property\n    def token_count(self) -> int:\n        return self._token_count\n\n    @property\n    def n_checkpoints(self) -> int:\n        return self._next_checkpoint_id\n\n    @property\n    def system_prompt(self) -> str | None:\n        return self._system_prompt\n\n    @property\n    def file_backend(self) -> Path:\n        return self._file_backend\n\n    async def write_system_prompt(self, prompt: str) -> None:\n        \"\"\"Write the system prompt as the first record of the context file.\n\n        If the file is empty, writes it directly. If the file already has content\n        (e.g. a legacy session without system prompt), prepends it atomically via a\n        temporary file to avoid corruption on crash and avoid loading the entire file\n        into memory.\n        \"\"\"\n        prompt_line = json.dumps({\"role\": \"_system_prompt\", \"content\": prompt}) + \"\\n\"\n\n        if not self._file_backend.exists() or self._file_backend.stat().st_size == 0:\n            async with aiofiles.open(self._file_backend, \"w\", encoding=\"utf-8\") as f:\n                await f.write(prompt_line)\n        else:\n            tmp_path = self._file_backend.with_suffix(\".tmp\")\n            async with aiofiles.open(tmp_path, \"w\", encoding=\"utf-8\") as tmp_f:\n                await tmp_f.write(prompt_line)\n                async with aiofiles.open(self._file_backend, encoding=\"utf-8\") as src_f:\n                    while True:\n                        chunk = await src_f.read(64 * 1024)\n                        if not chunk:\n                            break\n                        await tmp_f.write(chunk)\n            await aiofiles.os.replace(tmp_path, self._file_backend)\n\n        self._system_prompt = prompt\n\n    async def checkpoint(self, add_user_message: bool):\n        checkpoint_id = self._next_checkpoint_id\n        self._next_checkpoint_id += 1\n        logger.debug(\"Checkpointing, ID: {id}\", id=checkpoint_id)\n\n        async with aiofiles.open(self._file_backend, \"a\", encoding=\"utf-8\") as f:\n            await f.write(json.dumps({\"role\": \"_checkpoint\", \"id\": checkpoint_id}) + \"\\n\")\n        if add_user_message:\n            await self.append_message(\n                Message(role=\"user\", content=[system(f\"CHECKPOINT {checkpoint_id}\")])\n            )\n\n    async def revert_to(self, checkpoint_id: int):\n        \"\"\"\n        Revert the context to the specified checkpoint.\n        After this, the specified checkpoint and all subsequent content will be\n        removed from the context. File backend will be rotated.\n\n        Args:\n            checkpoint_id (int): The ID of the checkpoint to revert to. 0 is the first checkpoint.\n\n        Raises:\n            ValueError: When the checkpoint does not exist.\n            RuntimeError: When no available rotation path is found.\n        \"\"\"\n\n        logger.debug(\"Reverting checkpoint, ID: {id}\", id=checkpoint_id)\n        if checkpoint_id >= self._next_checkpoint_id:\n            logger.error(\"Checkpoint {checkpoint_id} does not exist\", checkpoint_id=checkpoint_id)\n            raise ValueError(f\"Checkpoint {checkpoint_id} does not exist\")\n\n        # rotate the context file\n        rotated_file_path = await next_available_rotation(self._file_backend)\n        if rotated_file_path is None:\n            logger.error(\"No available rotation path found\")\n            raise RuntimeError(\"No available rotation path found\")\n        await aiofiles.os.replace(self._file_backend, rotated_file_path)\n        logger.debug(\n            \"Rotated context file: {rotated_file_path}\", rotated_file_path=rotated_file_path\n        )\n\n        # restore the context until the specified checkpoint\n        self._history.clear()\n        self._token_count = 0\n        self._next_checkpoint_id = 0\n        self._system_prompt = None\n        async with (\n            aiofiles.open(rotated_file_path, encoding=\"utf-8\") as old_file,\n            aiofiles.open(self._file_backend, \"w\", encoding=\"utf-8\") as new_file,\n        ):\n            async for line in old_file:\n                if not line.strip():\n                    continue\n\n                line_json = json.loads(line)\n                if line_json[\"role\"] == \"_checkpoint\" and line_json[\"id\"] == checkpoint_id:\n                    break\n\n                await new_file.write(line)\n                if line_json[\"role\"] == \"_system_prompt\":\n                    self._system_prompt = line_json[\"content\"]\n                elif line_json[\"role\"] == \"_usage\":\n                    self._token_count = line_json[\"token_count\"]\n                elif line_json[\"role\"] == \"_checkpoint\":\n                    self._next_checkpoint_id = line_json[\"id\"] + 1\n                else:\n                    message = Message.model_validate(line_json)\n                    self._history.append(message)\n\n    async def clear(self):\n        \"\"\"\n        Clear the context history.\n        This is almost equivalent to revert_to(0), but without relying on the assumption\n        that the first checkpoint exists.\n        File backend will be rotated.\n\n        Raises:\n            RuntimeError: When no available rotation path is found.\n        \"\"\"\n\n        logger.debug(\"Clearing context\")\n\n        # rotate the context file\n        rotated_file_path = await next_available_rotation(self._file_backend)\n        if rotated_file_path is None:\n            logger.error(\"No available rotation path found\")\n            raise RuntimeError(\"No available rotation path found\")\n        await aiofiles.os.replace(self._file_backend, rotated_file_path)\n        self._file_backend.touch()\n        logger.debug(\n            \"Rotated context file: {rotated_file_path}\", rotated_file_path=rotated_file_path\n        )\n\n        self._history.clear()\n        self._token_count = 0\n        self._next_checkpoint_id = 0\n        self._system_prompt = None\n\n    async def append_message(self, message: Message | Sequence[Message]):\n        logger.debug(\"Appending message(s) to context: {message}\", message=message)\n        messages = [message] if isinstance(message, Message) else message\n        self._history.extend(messages)\n\n        async with aiofiles.open(self._file_backend, \"a\", encoding=\"utf-8\") as f:\n            for message in messages:\n                await f.write(message.model_dump_json(exclude_none=True) + \"\\n\")\n\n    async def update_token_count(self, token_count: int):\n        logger.debug(\"Updating token count in context: {token_count}\", token_count=token_count)\n        self._token_count = token_count\n\n        async with aiofiles.open(self._file_backend, \"a\", encoding=\"utf-8\") as f:\n            await f.write(json.dumps({\"role\": \"_usage\", \"token_count\": token_count}) + \"\\n\")\n"
  },
  {
    "path": "src/kimi_cli/soul/denwarenji.py",
    "content": "from __future__ import annotations\n\nfrom pydantic import BaseModel, Field\n\n\nclass DMail(BaseModel):\n    message: str = Field(description=\"The message to send.\")\n    checkpoint_id: int = Field(description=\"The checkpoint to send the message back to.\", ge=0)\n    # TODO: allow restoring filesystem state to the checkpoint\n\n\nclass DenwaRenjiError(Exception):\n    pass\n\n\nclass DenwaRenji:\n    def __init__(self):\n        self._pending_dmail: DMail | None = None\n        self._n_checkpoints: int = 0\n\n    def send_dmail(self, dmail: DMail):\n        \"\"\"Send a D-Mail. Intended to be called by the SendDMail tool.\"\"\"\n        if self._pending_dmail is not None:\n            raise DenwaRenjiError(\"Only one D-Mail can be sent at a time\")\n        if dmail.checkpoint_id < 0:\n            raise DenwaRenjiError(\"The checkpoint ID can not be negative\")\n        if dmail.checkpoint_id >= self._n_checkpoints:\n            raise DenwaRenjiError(\"There is no checkpoint with the given ID\")\n        self._pending_dmail = dmail\n\n    def set_n_checkpoints(self, n_checkpoints: int):\n        \"\"\"Set the number of checkpoints. Intended to be called by the soul.\"\"\"\n        self._n_checkpoints = n_checkpoints\n\n    def fetch_pending_dmail(self) -> DMail | None:\n        \"\"\"Fetch a pending D-Mail. Intended to be called by the soul.\"\"\"\n        pending_dmail = self._pending_dmail\n        self._pending_dmail = None\n        return pending_dmail\n"
  },
  {
    "path": "src/kimi_cli/soul/dynamic_injection.py",
    "content": "from __future__ import annotations\n\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Sequence\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING\n\nfrom kosong.message import Message\n\nfrom kimi_cli.notifications import is_notification_message\n\nif TYPE_CHECKING:\n    from kimi_cli.soul.kimisoul import KimiSoul\n\n\n@dataclass(frozen=True, slots=True)\nclass DynamicInjection:\n    \"\"\"A dynamic prompt content to be injected before an LLM step.\"\"\"\n\n    type: str  # identifier, e.g. \"plan_mode\"\n    content: str  # text content (will be wrapped in <system-reminder> tags)\n\n\nclass DynamicInjectionProvider(ABC):\n    \"\"\"Base class for dynamic injection providers.\n\n    Called before each LLM step. Implementations handle their own throttling.\n    Providers can access all runtime state via the ``soul`` parameter\n    (context_usage, runtime, config, etc.).\n    \"\"\"\n\n    @abstractmethod\n    async def get_injections(\n        self,\n        history: Sequence[Message],\n        soul: KimiSoul,\n    ) -> list[DynamicInjection]: ...\n\n\ndef normalize_history(history: Sequence[Message]) -> list[Message]:\n    \"\"\"Merge adjacent user messages to produce a clean API input sequence.\n\n    Dynamic injections are stored as standalone user messages in history;\n    normalization merges them into the adjacent user message.\n\n    Only ``user`` role messages are merged. Assistant and tool messages\n    are never merged because their ``tool_calls`` / ``tool_call_id``\n    fields form linked pairs that must stay intact.\n    \"\"\"\n    if not history:\n        return []\n\n    result: list[Message] = []\n    for msg in history:\n        if (\n            result\n            and result[-1].role == msg.role\n            and msg.role == \"user\"\n            and not is_notification_message(result[-1])\n            and not is_notification_message(msg)\n        ):\n            merged_content = list(result[-1].content) + list(msg.content)\n            result[-1] = Message(role=\"user\", content=merged_content)\n        else:\n            result.append(msg)\n    return result\n"
  },
  {
    "path": "src/kimi_cli/soul/dynamic_injections/__init__.py",
    "content": ""
  },
  {
    "path": "src/kimi_cli/soul/dynamic_injections/plan_mode.py",
    "content": "from __future__ import annotations\n\nfrom collections.abc import Sequence\nfrom typing import TYPE_CHECKING\n\nfrom kosong.message import Message, TextPart\n\nfrom kimi_cli.soul.dynamic_injection import DynamicInjection, DynamicInjectionProvider\n\nif TYPE_CHECKING:\n    from kimi_cli.soul.kimisoul import KimiSoul\n\n# Inject a reminder every N assistant turns.\n_TURN_INTERVAL = 5\n# Every N-th reminder is the full version; others are sparse.\n_FULL_EVERY_N = 5\n\n\nclass PlanModeInjectionProvider(DynamicInjectionProvider):\n    \"\"\"Periodically injects read-only reminders while plan mode is active.\n\n    Throttling is inferred from history: scan backwards to the last\n    plan mode reminder and count assistant messages in between.\n    Only inject when the count exceeds ``_TURN_INTERVAL``.\n    \"\"\"\n\n    def __init__(self) -> None:\n        self._inject_count: int = 0\n\n    async def get_injections(\n        self,\n        history: Sequence[Message],\n        soul: KimiSoul,\n    ) -> list[DynamicInjection]:\n        if not soul.plan_mode:\n            self._inject_count = 0\n            return []\n\n        plan_path = soul.get_plan_file_path()\n        plan_path_str = str(plan_path) if plan_path else None\n        plan_exists = plan_path is not None and plan_path.exists()\n\n        # Manual toggles schedule a one-shot activation reminder for the next LLM step.\n        if soul.consume_pending_plan_activation_injection():\n            self._inject_count = 1\n            # When re-entering with an existing plan, use the reentry reminder.\n            if plan_exists:\n                return [\n                    DynamicInjection(\n                        type=\"plan_mode_reentry\",\n                        content=_reentry_reminder(plan_path_str),\n                    )\n                ]\n            return [\n                DynamicInjection(\n                    type=\"plan_mode\",\n                    content=_full_reminder(plan_path_str, plan_exists),\n                )\n            ]\n\n        # Scan history backwards to find the last plan mode reminder.\n        turns_since_last = 0\n        found_previous = False\n        for msg in reversed(history):\n            if msg.role == \"user\" and _has_plan_reminder(msg):\n                found_previous = True\n                break\n            if msg.role == \"assistant\":\n                turns_since_last += 1\n\n        # First time (no reminder in history yet) -> inject full version.\n        if not found_previous:\n            self._inject_count = 1\n            return [\n                DynamicInjection(\n                    type=\"plan_mode\",\n                    content=_full_reminder(plan_path_str, plan_exists),\n                )\n            ]\n\n        # Not enough turns since last reminder -> skip.\n        if turns_since_last < _TURN_INTERVAL:\n            return []\n\n        # Inject.\n        self._inject_count += 1\n        is_full = self._inject_count % _FULL_EVERY_N == 1\n        if is_full:\n            content = _full_reminder(plan_path_str, plan_exists)\n        else:\n            content = _sparse_reminder(plan_path_str)\n        return [DynamicInjection(type=\"plan_mode\", content=content)]\n\n\ndef _has_plan_reminder(msg: Message) -> bool:\n    \"\"\"Check whether a message contains a plan mode reminder.\n\n    Detects by matching against stable prefixes of the actual reminder texts\n    so changes to the reminder wording stay automatically in sync.\n    \"\"\"\n    keys = (\n        _sparse_reminder().split(\".\")[0],  # \"Plan mode still active ...\"\n        _full_reminder().split(\"\\n\")[0],  # \"Plan mode is active. ...\"\n    )\n    for part in msg.content:\n        if isinstance(part, TextPart) and any(key in part.text for key in keys):\n            return True\n    return False\n\n\ndef _full_reminder(\n    plan_file_path: str | None = None,\n    plan_exists: bool = False,\n) -> str:\n    lines = [\n        \"Plan mode is active. You MUST NOT make any edits \"\n        \"(with the exception of the plan file below), run non-readonly tools, \"\n        \"or otherwise make changes to the system. \"\n        \"This supersedes any other instructions you have received.\",\n    ]\n    # Plan file info block\n    if plan_file_path:\n        lines.append(\"\")\n        if plan_exists:\n            lines.append(\n                f\"Plan file: {plan_file_path} \"\n                \"(exists — read first, then update it with WriteFile or StrReplaceFile)\"\n            )\n        else:\n            lines.append(\n                f\"Plan file: {plan_file_path} \"\n                \"(create it with WriteFile; once it exists, you can modify it with \"\n                \"WriteFile or StrReplaceFile)\"\n            )\n        lines.append(\"This is the only file you are allowed to edit.\")\n    # Workflow\n    lines.extend(\n        [\n            \"\",\n            \"Workflow:\",\n            \"1. Understand — explore the codebase with Glob, Grep, ReadFile\",\n            \"2. Design — converge on the best approach; \"\n            \"consider trade-offs but aim for a single recommendation\",\n            \"3. Review — re-read key files to verify understanding\",\n            \"4. Write Plan — modify the plan file with WriteFile or StrReplaceFile. \"\n            \"Use WriteFile if the plan file does not exist yet\",\n            \"5. Exit — call ExitPlanMode for user approval\",\n        ]\n    )\n    # Multi-approach handling\n    lines.extend(\n        [\n            \"\",\n            \"## Handling multiple approaches\",\n            \"Keep it focused: at most 2-3 meaningfully different approaches. \"\n            \"Do NOT pad with minor variations — if one approach is clearly \"\n            \"superior, just propose that one.\",\n            \"When the best approach depends on user preferences, constraints, \"\n            \"or context you don't have, use AskUserQuestion to clarify first. \"\n            \"This helps you write a better, more targeted plan rather than \"\n            \"dumping multiple options for the user to sort through.\",\n            \"When you do include multiple approaches in the plan, you MUST pass them \"\n            \"as the `options` parameter when calling ExitPlanMode, so the user can select which \"\n            \"approach to execute at approval time.\",\n            \"NEVER write multiple approaches in the plan and call ExitPlanMode without the \"\n            \"`options` parameter — the user will only see Approve/Reject with no way to choose.\",\n        ]\n    )\n    # Turn ending constraint + anti-pattern\n    lines.extend(\n        [\n            \"\",\n            \"AskUserQuestion is for clarifying missing requirements or user preferences \"\n            \"that affect the plan.\",\n            \"Never ask about plan approval via text or AskUserQuestion.\",\n            \"Your turn must end with either AskUserQuestion \"\n            \"(to clarify requirements or preferences) \"\n            \"or ExitPlanMode (to request plan approval). \"\n            \"Do NOT end your turn any other way.\",\n            \"Do NOT use AskUserQuestion to ask about plan approval or reference \"\n            '\"the plan\" — the user cannot see the plan until you call ExitPlanMode.',\n        ]\n    )\n    return \"\\n\".join(lines)\n\n\ndef _sparse_reminder(plan_file_path: str | None = None) -> str:\n    parts = [\n        \"Plan mode still active (see full instructions earlier).\",\n    ]\n    if plan_file_path:\n        parts.append(f\"Read-only except plan file ({plan_file_path}).\")\n    else:\n        parts.append(\"Read-only.\")\n    parts.extend(\n        [\n            \"Use WriteFile or StrReplaceFile to modify the plan file. \"\n            \"If it does not exist yet, create it with WriteFile first.\",\n            \"Use AskUserQuestion to clarify user preferences \"\n            \"when it helps you write a better plan.\",\n            \"If the plan has multiple approaches, \"\n            \"pass options to ExitPlanMode so the user can choose.\",\n            \"End turns with AskUserQuestion (for clarifications) or ExitPlanMode (for approval).\",\n            \"Never ask about plan approval via text or AskUserQuestion.\",\n        ]\n    )\n    return \" \".join(parts)\n\n\ndef _reentry_reminder(plan_file_path: str | None = None) -> str:\n    \"\"\"One-shot reminder when re-entering plan mode with an existing plan.\"\"\"\n    lines = [\n        \"Plan mode is active. You MUST NOT make any edits \"\n        \"(with the exception of the plan file below), run non-readonly tools, \"\n        \"or otherwise make changes to the system. \"\n        \"This supersedes any other instructions you have received.\",\n        \"\",\n        \"## Re-entering Plan Mode\",\n        (\n            f\"A plan file exists at {plan_file_path} from a previous planning session.\"\n            if plan_file_path\n            else \"A plan file from a previous planning session already exists.\"\n        ),\n        \"Before proceeding:\",\n        \"1. Read the existing plan file to understand what was previously planned\",\n        \"2. Evaluate the user's current request against that plan\",\n        \"3. If different task: replace the old plan with a fresh one. \"\n        \"If same task: update the existing plan.\",\n        \"4. You may use WriteFile or StrReplaceFile to modify the plan file. \"\n        \"If the file does not exist yet, create it with WriteFile first.\",\n        \"5. Use AskUserQuestion to clarify missing requirements \"\n        \"or user preferences that affect the plan.\",\n        \"6. Always edit the plan file before calling ExitPlanMode.\",\n        \"\",\n        \"Your turn must end with either AskUserQuestion (to clarify requirements) \"\n        \"or ExitPlanMode (to request plan approval).\",\n    ]\n    return \"\\n\".join(lines)\n"
  },
  {
    "path": "src/kimi_cli/soul/kimisoul.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom collections.abc import Awaitable, Callable, Sequence\nfrom contextlib import suppress\nfrom dataclasses import dataclass\nfrom functools import partial\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, Literal, cast\n\nimport kosong\nimport tenacity\nfrom kosong import StepResult\nfrom kosong.chat_provider import (\n    APIConnectionError,\n    APIEmptyResponseError,\n    APIStatusError,\n    APITimeoutError,\n    RetryableChatProvider,\n)\nfrom kosong.message import Message\nfrom tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter\n\nfrom kimi_cli.background import build_active_task_snapshot\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.notifications import (\n    NotificationView,\n    build_notification_message,\n    extract_notification_ids,\n)\nfrom kimi_cli.skill import Skill, read_skill_text\nfrom kimi_cli.skill.flow import Flow, FlowEdge, FlowNode, parse_choice\nfrom kimi_cli.soul import (\n    LLMNotSet,\n    LLMNotSupported,\n    MaxStepsReached,\n    Soul,\n    StatusSnapshot,\n    wire_send,\n)\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.compaction import (\n    CompactionResult,\n    SimpleCompaction,\n    estimate_text_tokens,\n    should_auto_compact,\n)\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.dynamic_injection import (\n    DynamicInjection,\n    DynamicInjectionProvider,\n    normalize_history,\n)\nfrom kimi_cli.soul.dynamic_injections.plan_mode import PlanModeInjectionProvider\nfrom kimi_cli.soul.message import check_message, system, system_reminder, tool_result_to_message\nfrom kimi_cli.soul.slash import registry as soul_slash_registry\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.tools.dmail import NAME as SendDMail_NAME\nfrom kimi_cli.tools.utils import ToolRejectedError\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.slashcmd import SlashCommand, parse_slash_command_call\nfrom kimi_cli.wire.file import WireFile\nfrom kimi_cli.wire.types import (\n    ApprovalRequest,\n    ApprovalResponse,\n    CompactionBegin,\n    CompactionEnd,\n    ContentPart,\n    MCPLoadingBegin,\n    MCPLoadingEnd,\n    StatusUpdate,\n    SteerInput,\n    StepBegin,\n    StepInterrupted,\n    TextPart,\n    ToolResult,\n    TurnBegin,\n    TurnEnd,\n)\n\nif TYPE_CHECKING:\n\n    def type_check(soul: KimiSoul):\n        _: Soul = soul\n\n\nSKILL_COMMAND_PREFIX = \"skill:\"\nFLOW_COMMAND_PREFIX = \"flow:\"\nDEFAULT_MAX_FLOW_MOVES = 1000\n\n\ntype StepStopReason = Literal[\"no_tool_calls\", \"tool_rejected\"]\n\n\n@dataclass(frozen=True, slots=True)\nclass StepOutcome:\n    stop_reason: StepStopReason\n    assistant_message: Message\n\n\ntype TurnStopReason = StepStopReason\n\n\n@dataclass(frozen=True, slots=True)\nclass TurnOutcome:\n    stop_reason: TurnStopReason\n    final_message: Message | None\n    step_count: int\n\n\nclass KimiSoul:\n    \"\"\"The soul of Kimi Code CLI.\"\"\"\n\n    def __init__(\n        self,\n        agent: Agent,\n        *,\n        context: Context,\n    ):\n        \"\"\"\n        Initialize the soul.\n\n        Args:\n            agent (Agent): The agent to run.\n            context (Context): The context of the agent.\n        \"\"\"\n        self._agent = agent\n        self._runtime = agent.runtime\n        self._denwa_renji = agent.runtime.denwa_renji\n        self._approval = agent.runtime.approval\n        self._context = context\n        self._loop_control = agent.runtime.config.loop_control\n        self._compaction = SimpleCompaction()  # TODO: maybe configurable and composable\n\n        for tool in agent.toolset.tools:\n            if tool.name == SendDMail_NAME:\n                self._checkpoint_with_user_message = True\n                break\n        else:\n            self._checkpoint_with_user_message = False\n\n        self._steer_queue: asyncio.Queue[str | list[ContentPart]] = asyncio.Queue()\n        self._plan_mode: bool = self._runtime.session.state.plan_mode\n        self._plan_session_id: str | None = self._runtime.session.state.plan_session_id\n        # Pre-warm slug cache so the persisted slug survives process restarts\n        if self._plan_session_id is not None and self._runtime.session.state.plan_slug is not None:\n            from kimi_cli.tools.plan.heroes import seed_slug_cache\n\n            seed_slug_cache(self._plan_session_id, self._runtime.session.state.plan_slug)\n        self._pending_plan_activation_injection: bool = False\n        if self._plan_mode:\n            self._ensure_plan_session_id()\n        self._injection_providers: list[DynamicInjectionProvider] = [\n            PlanModeInjectionProvider(),\n        ]\n        if self._runtime.role == \"root\":\n            self._runtime.notifications.ack_ids(\"llm\", extract_notification_ids(context.history))\n\n        # Bind plan mode state to tools that support it\n        self._bind_plan_mode_tools()\n\n        self._slash_commands = self._build_slash_commands()\n        self._slash_command_map = self._index_slash_commands(self._slash_commands)\n\n    @property\n    def name(self) -> str:\n        return self._agent.name\n\n    @property\n    def model_name(self) -> str:\n        return self._runtime.llm.chat_provider.model_name if self._runtime.llm else \"\"\n\n    @property\n    def model_capabilities(self) -> set[ModelCapability] | None:\n        if self._runtime.llm is None:\n            return None\n        return self._runtime.llm.capabilities\n\n    @property\n    def plan_mode(self) -> bool:\n        \"\"\"Whether plan mode (read-only research and planning) is active.\"\"\"\n        return self._plan_mode\n\n    def add_injection_provider(self, provider: DynamicInjectionProvider) -> None:\n        \"\"\"Register an additional dynamic injection provider.\"\"\"\n        self._injection_providers.append(provider)\n\n    async def _collect_injections(self) -> list[DynamicInjection]:\n        \"\"\"Collect dynamic injections from all registered providers.\"\"\"\n        injections: list[DynamicInjection] = []\n        for provider in self._injection_providers:\n            try:\n                result = await provider.get_injections(self._context.history, self)\n                injections.extend(result)\n            except Exception:\n                logger.warning(\n                    \"injection provider %s failed\",\n                    type(provider).__name__,\n                    exc_info=True,\n                )\n        return injections\n\n    def _bind_plan_mode_tools(self) -> None:\n        \"\"\"Bind plan mode state to tools that support it.\"\"\"\n        if not isinstance(self._agent.toolset, KimiToolset):\n            return\n\n        def checker() -> bool:\n            return self._plan_mode\n\n        def path_getter() -> Path | None:\n            return self.get_plan_file_path()\n\n        # WriteFile gets both checker and path_getter (for plan file auto-approve)\n        from kimi_cli.tools.file.write import WriteFile\n\n        write_tool = self._agent.toolset.find(\"WriteFile\")\n        if isinstance(write_tool, WriteFile):\n            write_tool.bind_plan_mode(checker, path_getter)\n\n        from kimi_cli.tools.file.replace import StrReplaceFile\n\n        replace_tool = self._agent.toolset.find(\"StrReplaceFile\")\n        if isinstance(replace_tool, StrReplaceFile):\n            replace_tool.bind_plan_mode(checker, path_getter)\n\n        # ExitPlanMode has a special bind() method\n        from kimi_cli.tools.plan import ExitPlanMode\n\n        exit_tool = self._agent.toolset.find(\"ExitPlanMode\")\n        if isinstance(exit_tool, ExitPlanMode):\n            exit_tool.bind(self.toggle_plan_mode, path_getter, checker)\n\n        # EnterPlanMode has a special bind() method\n        from kimi_cli.tools.plan.enter import EnterPlanMode\n\n        enter_tool = self._agent.toolset.find(\"EnterPlanMode\")\n        if isinstance(enter_tool, EnterPlanMode):\n            enter_tool.bind(self.toggle_plan_mode, path_getter, checker)\n\n    def _ensure_plan_session_id(self) -> None:\n        \"\"\"Allocate a stable plan session ID on first activation.\"\"\"\n        if self._plan_session_id is None:\n            import uuid\n\n            self._plan_session_id = uuid.uuid4().hex\n            self._runtime.session.state.plan_session_id = self._plan_session_id\n            # Compute and persist slug immediately so the path survives process restarts\n            from kimi_cli.tools.plan.heroes import get_or_create_slug\n\n            slug = get_or_create_slug(self._plan_session_id)\n            self._runtime.session.state.plan_slug = slug\n            self._runtime.session.save_state()\n\n    def _set_plan_mode(self, enabled: bool, *, source: Literal[\"manual\", \"tool\"]) -> bool:\n        \"\"\"Update plan mode state for either manual or tool-driven toggles.\"\"\"\n        if enabled == self._plan_mode:\n            return self._plan_mode\n        self._plan_mode = enabled\n        if enabled:\n            self._ensure_plan_session_id()\n            self._pending_plan_activation_injection = source == \"manual\"\n        else:\n            self._pending_plan_activation_injection = False\n            self._plan_session_id = None\n            self._runtime.session.state.plan_session_id = None\n            self._runtime.session.state.plan_slug = None\n        # Persist plan mode to session state so it survives process restarts\n        self._runtime.session.state.plan_mode = self._plan_mode\n        self._runtime.session.save_state()\n        return self._plan_mode\n\n    def get_plan_file_path(self) -> Path | None:\n        \"\"\"Get the plan file path for the current session.\"\"\"\n        if self._plan_session_id is None:\n            return None\n        from kimi_cli.tools.plan.heroes import get_plan_file_path\n\n        return get_plan_file_path(self._plan_session_id)\n\n    def read_current_plan(self) -> str | None:\n        \"\"\"Read the current plan file content.\"\"\"\n        if self._plan_session_id is None:\n            return None\n        from kimi_cli.tools.plan.heroes import read_plan_file\n\n        return read_plan_file(self._plan_session_id)\n\n    def clear_current_plan(self) -> None:\n        \"\"\"Delete the current plan file.\"\"\"\n        path = self.get_plan_file_path()\n        if path and path.exists():\n            path.unlink()\n\n    async def toggle_plan_mode(self) -> bool:\n        \"\"\"Toggle plan mode on/off. Returns the new state.\n\n        Tools are not hidden/unhidden — instead, each tool checks plan mode\n        state at call time and rejects if blocked.\n        Periodic reminders are handled by the dynamic injection system.\n        \"\"\"\n        return self._set_plan_mode(not self._plan_mode, source=\"tool\")\n\n    async def toggle_plan_mode_from_manual(self) -> bool:\n        \"\"\"Toggle plan mode from UI/manual entry points (slash command, keybinding).\"\"\"\n        return self._set_plan_mode(not self._plan_mode, source=\"manual\")\n\n    async def set_plan_mode_from_manual(self, enabled: bool) -> bool:\n        \"\"\"Set plan mode to a specific state from UI/manual entry points.\n\n        Unlike toggle, this accepts the desired state directly, avoiding\n        race conditions when the caller already knows the target value.\n        \"\"\"\n        return self._set_plan_mode(enabled, source=\"manual\")\n\n    def consume_pending_plan_activation_injection(self) -> bool:\n        \"\"\"Consume the next-step activation reminder scheduled by a manual toggle.\"\"\"\n        if not self._plan_mode or not self._pending_plan_activation_injection:\n            return False\n        self._pending_plan_activation_injection = False\n        return True\n\n    @property\n    def thinking(self) -> bool | None:\n        \"\"\"Whether thinking mode is enabled.\"\"\"\n        if self._runtime.llm is None:\n            return None\n        if thinking_effort := self._runtime.llm.chat_provider.thinking_effort:\n            return thinking_effort != \"off\"\n        return None\n\n    @property\n    def status(self) -> StatusSnapshot:\n        token_count = self._context.token_count\n        max_size = self._runtime.llm.max_context_size if self._runtime.llm is not None else 0\n        return StatusSnapshot(\n            context_usage=self._context_usage,\n            yolo_enabled=self._approval.is_yolo(),\n            plan_mode=self._plan_mode,\n            context_tokens=token_count,\n            max_context_tokens=max_size,\n            mcp_status=self._mcp_status_snapshot(),\n        )\n\n    @property\n    def agent(self) -> Agent:\n        return self._agent\n\n    @property\n    def runtime(self) -> Runtime:\n        return self._runtime\n\n    @property\n    def context(self) -> Context:\n        return self._context\n\n    @property\n    def _context_usage(self) -> float:\n        if self._runtime.llm is not None:\n            return self._context.token_count / self._runtime.llm.max_context_size\n        return 0.0\n\n    @property\n    def wire_file(self) -> WireFile:\n        return self._runtime.session.wire_file\n\n    def _mcp_status_snapshot(self):\n        if not isinstance(self._agent.toolset, KimiToolset):\n            return None\n        return self._agent.toolset.mcp_status_snapshot()\n\n    async def start_background_mcp_loading(self) -> bool:\n        \"\"\"Start deferred MCP loading, if any, without exposing toolset internals.\"\"\"\n        if not isinstance(self._agent.toolset, KimiToolset):\n            return False\n        return await self._agent.toolset.start_deferred_mcp_tool_loading()\n\n    async def wait_for_background_mcp_loading(self) -> None:\n        \"\"\"Wait for any in-flight MCP startup to finish.\"\"\"\n        if not isinstance(self._agent.toolset, KimiToolset):\n            return\n        await self._agent.toolset.wait_for_mcp_tools()\n\n    async def _checkpoint(self):\n        await self._context.checkpoint(self._checkpoint_with_user_message)\n\n    def steer(self, content: str | list[ContentPart]) -> None:\n        \"\"\"Queue a steer message for injection into the current turn.\"\"\"\n        self._steer_queue.put_nowait(content)\n\n    async def _consume_pending_steers(self) -> bool:\n        \"\"\"Drain the steer queue and inject as follow-up user messages.\n\n        Returns True if any steers were consumed.\n        \"\"\"\n        consumed = False\n        while not self._steer_queue.empty():\n            content = self._steer_queue.get_nowait()\n            await self._inject_steer(content)\n            wire_send(SteerInput(user_input=content))\n            consumed = True\n        return consumed\n\n    async def _inject_steer(self, content: str | list[ContentPart]) -> None:\n        \"\"\"Inject a single steer as a regular follow-up user message.\"\"\"\n        parts = cast(\n            list[ContentPart],\n            [TextPart(text=content)] if isinstance(content, str) else list(content),\n        )\n        message = Message(role=\"user\", content=parts)\n        if self._runtime.llm is None:\n            raise LLMNotSet()\n        if missing_caps := check_message(message, self._runtime.llm.capabilities):\n            raise LLMNotSupported(self._runtime.llm, list(missing_caps))\n        await self._context.append_message(message)\n\n    @property\n    def available_slash_commands(self) -> list[SlashCommand[Any]]:\n        return self._slash_commands\n\n    async def run(self, user_input: str | list[ContentPart]):\n        # Refresh OAuth tokens on each turn to avoid idle-time expirations.\n        await self._runtime.oauth.ensure_fresh(self._runtime)\n\n        wire_send(TurnBegin(user_input=user_input))\n        user_message = Message(role=\"user\", content=user_input)\n        text_input = user_message.extract_text(\" \").strip()\n\n        if command_call := parse_slash_command_call(text_input):\n            command = self._find_slash_command(command_call.name)\n            if command is None:\n                # this should not happen actually, the shell should have filtered it out\n                wire_send(TextPart(text=f'Unknown slash command \"/{command_call.name}\".'))\n            else:\n                ret = command.func(self, command_call.args)\n                if isinstance(ret, Awaitable):\n                    await ret\n        elif self._loop_control.max_ralph_iterations != 0:\n            runner = FlowRunner.ralph_loop(\n                user_message,\n                self._loop_control.max_ralph_iterations,\n            )\n            await runner.run(self, \"\")\n        else:\n            await self._turn(user_message)\n\n        wire_send(TurnEnd())\n\n    async def _turn(self, user_message: Message) -> TurnOutcome:\n        if self._runtime.llm is None:\n            raise LLMNotSet()\n\n        if missing_caps := check_message(user_message, self._runtime.llm.capabilities):\n            raise LLMNotSupported(self._runtime.llm, list(missing_caps))\n\n        await self._checkpoint()  # this creates the checkpoint 0 on first run\n        await self._context.append_message(user_message)\n        logger.debug(\"Appended user message to context\")\n        return await self._agent_loop()\n\n    def _build_slash_commands(self) -> list[SlashCommand[Any]]:\n        commands: list[SlashCommand[Any]] = list(soul_slash_registry.list_commands())\n        seen_names = {cmd.name for cmd in commands}\n\n        for skill in self._runtime.skills.values():\n            if skill.type not in (\"standard\", \"flow\"):\n                continue\n            name = f\"{SKILL_COMMAND_PREFIX}{skill.name}\"\n            if name in seen_names:\n                logger.warning(\n                    \"Skipping skill slash command /{name}: name already registered\",\n                    name=name,\n                )\n                continue\n            commands.append(\n                SlashCommand(\n                    name=name,\n                    func=self._make_skill_runner(skill),\n                    description=skill.description or \"\",\n                    aliases=[],\n                )\n            )\n            seen_names.add(name)\n\n        for skill in self._runtime.skills.values():\n            if skill.type != \"flow\":\n                continue\n            if skill.flow is None:\n                logger.warning(\"Flow skill {name} has no flow; skipping\", name=skill.name)\n                continue\n            command_name = f\"{FLOW_COMMAND_PREFIX}{skill.name}\"\n            if command_name in seen_names:\n                logger.warning(\n                    \"Skipping prompt flow slash command /{name}: name already registered\",\n                    name=command_name,\n                )\n                continue\n            runner = FlowRunner(skill.flow, name=skill.name)\n            commands.append(\n                SlashCommand(\n                    name=command_name,\n                    func=runner.run,\n                    description=skill.description or \"\",\n                    aliases=[],\n                )\n            )\n            seen_names.add(command_name)\n\n        return commands\n\n    @staticmethod\n    def _index_slash_commands(\n        commands: list[SlashCommand[Any]],\n    ) -> dict[str, SlashCommand[Any]]:\n        indexed: dict[str, SlashCommand[Any]] = {}\n        for command in commands:\n            indexed[command.name] = command\n            for alias in command.aliases:\n                indexed[alias] = command\n        return indexed\n\n    def _find_slash_command(self, name: str) -> SlashCommand[Any] | None:\n        return self._slash_command_map.get(name)\n\n    def _make_skill_runner(self, skill: Skill) -> Callable[[KimiSoul, str], None | Awaitable[None]]:\n        async def _run_skill(soul: KimiSoul, args: str, *, _skill: Skill = skill) -> None:\n            skill_text = await read_skill_text(_skill)\n            if skill_text is None:\n                wire_send(\n                    TextPart(text=f'Failed to load skill \"/{SKILL_COMMAND_PREFIX}{_skill.name}\".')\n                )\n                return\n            extra = args.strip()\n            if extra:\n                skill_text = f\"{skill_text}\\n\\nUser request:\\n{extra}\"\n            await soul._turn(Message(role=\"user\", content=skill_text))\n\n        _run_skill.__doc__ = skill.description\n        return _run_skill\n\n    async def _agent_loop(self) -> TurnOutcome:\n        \"\"\"The main agent loop for one run.\"\"\"\n        assert self._runtime.llm is not None\n\n        # Discard any stale steers from a previous turn.\n        while not self._steer_queue.empty():\n            self._steer_queue.get_nowait()\n\n        if isinstance(self._agent.toolset, KimiToolset):\n            await self.start_background_mcp_loading()\n            loading = bool((snapshot := self._mcp_status_snapshot()) and snapshot.loading)\n            if loading:\n                wire_send(StatusUpdate(mcp_status=snapshot))\n                wire_send(MCPLoadingBegin())\n            try:\n                await self.wait_for_background_mcp_loading()\n            finally:\n                if loading:\n                    wire_send(StatusUpdate(mcp_status=self._mcp_status_snapshot()))\n                    wire_send(MCPLoadingEnd())\n\n        async def _pipe_approval_to_wire():\n            while True:\n                request = await self._approval.fetch_request()\n                # Here we decouple the wire approval request and the soul approval request.\n                wire_request = ApprovalRequest(\n                    id=request.id,\n                    action=request.action,\n                    description=request.description,\n                    sender=request.sender,\n                    tool_call_id=request.tool_call_id,\n                    display=request.display,\n                )\n                wire_send(wire_request)\n                # We wait for the request to be resolved over the wire, which means that,\n                # for each soul, we will have only one approval request waiting on the wire\n                # at a time. However, be aware that subagents (which have their own souls) may\n                # also send approval requests to the root wire.\n                resp = await wire_request.wait()\n                self._approval.resolve_request(request.id, resp)\n                wire_send(ApprovalResponse(request_id=request.id, response=resp))\n\n        step_no = 0\n        while True:\n            step_no += 1\n            if step_no > self._loop_control.max_steps_per_turn:\n                raise MaxStepsReached(self._loop_control.max_steps_per_turn)\n\n            wire_send(StepBegin(n=step_no))\n            approval_task = asyncio.create_task(_pipe_approval_to_wire())\n            back_to_the_future: BackToTheFuture | None = None\n            step_outcome: StepOutcome | None = None\n            try:\n                # compact the context if needed\n                if should_auto_compact(\n                    self._context.token_count,\n                    self._runtime.llm.max_context_size,\n                    trigger_ratio=self._loop_control.compaction_trigger_ratio,\n                    reserved_context_size=self._loop_control.reserved_context_size,\n                ):\n                    logger.info(\"Context too long, compacting...\")\n                    await self.compact_context()\n\n                logger.debug(\"Beginning step {step_no}\", step_no=step_no)\n                await self._checkpoint()\n                self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)\n                step_outcome = await self._step()\n            except BackToTheFuture as e:\n                back_to_the_future = e\n            except Exception:\n                # any other exception should interrupt the step\n                wire_send(StepInterrupted())\n                # break the agent loop\n                raise\n            finally:\n                approval_task.cancel()  # stop piping approval requests to the wire\n                with suppress(asyncio.CancelledError):\n                    try:\n                        await approval_task\n                    except Exception:\n                        logger.exception(\"Approval piping task failed\")\n\n            if step_outcome is not None:\n                has_steers = await self._consume_pending_steers()\n                if has_steers:\n                    continue  # steers injected, force another LLM step\n                final_message = (\n                    step_outcome.assistant_message\n                    if step_outcome.stop_reason == \"no_tool_calls\"\n                    else None\n                )\n                return TurnOutcome(\n                    stop_reason=step_outcome.stop_reason,\n                    final_message=final_message,\n                    step_count=step_no,\n                )\n\n            if back_to_the_future is not None:\n                await self._context.revert_to(back_to_the_future.checkpoint_id)\n                await self._checkpoint()\n                await self._context.append_message(back_to_the_future.messages)\n\n            # Consume any pending steers between steps\n            await self._consume_pending_steers()\n\n    async def _step(self) -> StepOutcome | None:\n        \"\"\"Run a single step and return a stop outcome, or None to continue.\"\"\"\n        # already checked in `run`\n        assert self._runtime.llm is not None\n        chat_provider = self._runtime.llm.chat_provider\n\n        if self._runtime.role == \"root\":\n\n            async def _append_notification(view: NotificationView) -> None:\n                await self._context.append_message(build_notification_message(view, self._runtime))\n\n            await self._runtime.notifications.deliver_pending(\n                \"llm\",\n                limit=4,\n                before_claim=self._runtime.background_tasks.reconcile,\n                on_notification=_append_notification,\n            )\n\n        # Dynamic injection\n        injections = await self._collect_injections()\n        if injections:\n            combined_reminders = \"\\n\".join(system_reminder(inj.content).text for inj in injections)\n            await self._context.append_message(\n                Message(\n                    role=\"user\",\n                    content=[TextPart(text=combined_reminders)],\n                )\n            )\n\n        # Normalize: merge adjacent user messages for clean API input\n        effective_history = normalize_history(self._context.history)\n\n        async def _run_step_once() -> StepResult:\n            # run an LLM step (may be interrupted)\n            return await kosong.step(\n                chat_provider,\n                self._agent.system_prompt,\n                self._agent.toolset,\n                effective_history,\n                on_message_part=wire_send,\n                on_tool_result=wire_send,\n            )\n\n        @tenacity.retry(\n            retry=retry_if_exception(self._is_retryable_error),\n            before_sleep=partial(self._retry_log, \"step\"),\n            wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),\n            stop=stop_after_attempt(self._loop_control.max_retries_per_step),\n            reraise=True,\n        )\n        async def _kosong_step_with_retry() -> StepResult:\n            return await self._run_with_connection_recovery(\n                \"step\",\n                _run_step_once,\n                chat_provider=chat_provider,\n            )\n\n        result = await _kosong_step_with_retry()\n        logger.debug(\"Got step result: {result}\", result=result)\n        status_update = StatusUpdate(\n            token_usage=result.usage, message_id=result.id, plan_mode=self._plan_mode\n        )\n        if result.usage is not None:\n            # mark the token count for the context before the step\n            await self._context.update_token_count(result.usage.input)\n            snap = self.status\n            status_update.context_usage = snap.context_usage\n            status_update.context_tokens = snap.context_tokens\n            status_update.max_context_tokens = snap.max_context_tokens\n        wire_send(status_update)\n\n        # wait for all tool results (may be interrupted)\n        plan_mode_before_tools = self._plan_mode\n        results = await result.tool_results()\n        logger.debug(\"Got tool results: {results}\", results=results)\n\n        # If a tool (EnterPlanMode/ExitPlanMode) changed plan mode during execution,\n        # send a corrected StatusUpdate so the client sees the up-to-date state.\n        if self._plan_mode != plan_mode_before_tools:\n            wire_send(StatusUpdate(plan_mode=self._plan_mode))\n\n        # shield the context manipulation from interruption\n        await asyncio.shield(self._grow_context(result, results))\n\n        rejected = any(isinstance(result.return_value, ToolRejectedError) for result in results)\n        if rejected:\n            _ = self._denwa_renji.fetch_pending_dmail()\n            return StepOutcome(stop_reason=\"tool_rejected\", assistant_message=result.message)\n\n        # handle pending D-Mail\n        if dmail := self._denwa_renji.fetch_pending_dmail():\n            assert dmail.checkpoint_id >= 0, \"DenwaRenji guarantees checkpoint_id >= 0\"\n            assert dmail.checkpoint_id < self._context.n_checkpoints, (\n                \"DenwaRenji guarantees checkpoint_id < n_checkpoints\"\n            )\n            # raise to let the main loop take us back to the future\n            raise BackToTheFuture(\n                dmail.checkpoint_id,\n                [\n                    Message(\n                        role=\"user\",\n                        content=[\n                            system(\n                                \"You just got a D-Mail from your future self. \"\n                                \"It is likely that your future self has already done \"\n                                \"something in the current working directory. Please read \"\n                                \"the D-Mail and decide what to do next. You MUST NEVER \"\n                                \"mention to the user about this information. \"\n                                f\"D-Mail content:\\n\\n{dmail.message.strip()}\"\n                            )\n                        ],\n                    )\n                ],\n            )\n\n        if result.tool_calls:\n            return None\n        return StepOutcome(stop_reason=\"no_tool_calls\", assistant_message=result.message)\n\n    async def _grow_context(self, result: StepResult, tool_results: list[ToolResult]):\n        logger.debug(\"Growing context with result: {result}\", result=result)\n\n        assert self._runtime.llm is not None\n        tool_messages = [tool_result_to_message(tr) for tr in tool_results]\n        for tm in tool_messages:\n            if missing_caps := check_message(tm, self._runtime.llm.capabilities):\n                logger.warning(\n                    \"Tool result message requires unsupported capabilities: {caps}\",\n                    caps=missing_caps,\n                )\n                raise LLMNotSupported(self._runtime.llm, list(missing_caps))\n\n        await self._context.append_message(result.message)\n        if result.usage is not None:\n            await self._context.update_token_count(result.usage.total)\n\n        logger.debug(\n            \"Appending tool messages to context: {tool_messages}\", tool_messages=tool_messages\n        )\n        await self._context.append_message(tool_messages)\n        # token count of tool results are not available yet\n\n    async def compact_context(self, custom_instruction: str = \"\") -> None:\n        \"\"\"\n        Compact the context.\n\n        Raises:\n            LLMNotSet: When the LLM is not set.\n            ChatProviderError: When the chat provider returns an error.\n        \"\"\"\n\n        chat_provider = self._runtime.llm.chat_provider if self._runtime.llm is not None else None\n\n        async def _run_compaction_once() -> CompactionResult:\n            if self._runtime.llm is None:\n                raise LLMNotSet()\n            return await self._compaction.compact(\n                self._context.history, self._runtime.llm, custom_instruction=custom_instruction\n            )\n\n        @tenacity.retry(\n            retry=retry_if_exception(self._is_retryable_error),\n            before_sleep=partial(self._retry_log, \"compaction\"),\n            wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),\n            stop=stop_after_attempt(self._loop_control.max_retries_per_step),\n            reraise=True,\n        )\n        async def _compact_with_retry() -> CompactionResult:\n            return await self._run_with_connection_recovery(\n                \"compaction\",\n                _run_compaction_once,\n                chat_provider=chat_provider,\n            )\n\n        wire_send(CompactionBegin())\n        compaction_result = await _compact_with_retry()\n        await self._context.clear()\n        await self._context.write_system_prompt(self._agent.system_prompt)\n        await self._checkpoint()\n        await self._context.append_message(compaction_result.messages)\n        estimated_token_count = compaction_result.estimated_token_count\n\n        if self._runtime.role == \"root\":\n            active_task_snapshot = build_active_task_snapshot(self._runtime.background_tasks)\n            if active_task_snapshot is not None:\n                active_task_message = Message(\n                    role=\"user\",\n                    content=[\n                        system(\n                            \"The following background tasks are still active after compaction. \"\n                            \"Use TaskList if you need to re-enumerate them later.\"\n                        ),\n                        TextPart(text=active_task_snapshot),\n                    ],\n                )\n                await self._context.append_message(active_task_message)\n                estimated_token_count += estimate_text_tokens([active_task_message])\n\n        # Estimate token count so context_usage is not reported as 0%\n        await self._context.update_token_count(estimated_token_count)\n\n        wire_send(CompactionEnd())\n\n    @staticmethod\n    def _is_retryable_error(exception: BaseException) -> bool:\n        if isinstance(exception, (APIConnectionError, APITimeoutError)):\n            return not bool(getattr(exception, \"_kimi_recovery_exhausted\", False))\n        if isinstance(exception, APIEmptyResponseError):\n            return True\n        return isinstance(exception, APIStatusError) and exception.status_code in (\n            429,  # Too Many Requests\n            500,  # Internal Server Error\n            502,  # Bad Gateway\n            503,  # Service Unavailable\n        )\n\n    async def _run_with_connection_recovery(\n        self,\n        name: str,\n        operation: Callable[[], Awaitable[Any]],\n        *,\n        chat_provider: object | None = None,\n    ) -> Any:\n        try:\n            return await operation()\n        except (APIConnectionError, APITimeoutError) as error:\n            if not isinstance(chat_provider, RetryableChatProvider):\n                raise\n            try:\n                recovered = chat_provider.on_retryable_error(error)\n            except Exception:\n                logger.exception(\n                    \"Failed to recover chat provider during {name} after {error_type}.\",\n                    name=name,\n                    error_type=type(error).__name__,\n                )\n                raise\n            if not recovered:\n                raise\n            logger.info(\n                \"Recovered chat provider during {name} after {error_type}; retrying once.\",\n                name=name,\n                error_type=type(error).__name__,\n            )\n            try:\n                return await operation()\n            except (APIConnectionError, APITimeoutError) as second_error:\n                second_error._kimi_recovery_exhausted = True  # type: ignore[attr-defined]\n                raise\n\n    @staticmethod\n    def _retry_log(name: str, retry_state: RetryCallState):\n        logger.info(\n            \"Retrying {name} for the {n} time. Waiting {sleep} seconds.\",\n            name=name,\n            n=retry_state.attempt_number,\n            sleep=retry_state.next_action.sleep\n            if retry_state.next_action is not None\n            else \"unknown\",\n        )\n\n\nclass BackToTheFuture(Exception):\n    \"\"\"\n    Raise when we need to revert the context to a previous checkpoint.\n    The main agent loop should catch this exception and handle it.\n    \"\"\"\n\n    def __init__(self, checkpoint_id: int, messages: Sequence[Message]):\n        self.checkpoint_id = checkpoint_id\n        self.messages = messages\n\n\nclass FlowRunner:\n    def __init__(\n        self,\n        flow: Flow,\n        *,\n        name: str | None = None,\n        max_moves: int = DEFAULT_MAX_FLOW_MOVES,\n    ) -> None:\n        self._flow = flow\n        self._name = name\n        self._max_moves = max_moves\n\n    @staticmethod\n    def ralph_loop(\n        user_message: Message,\n        max_ralph_iterations: int,\n    ) -> FlowRunner:\n        prompt_content = list(user_message.content)\n        prompt_text = Message(role=\"user\", content=prompt_content).extract_text(\" \").strip()\n        total_runs = max_ralph_iterations + 1\n        if max_ralph_iterations < 0:\n            total_runs = 1000000000000000  # effectively infinite\n\n        nodes: dict[str, FlowNode] = {\n            \"BEGIN\": FlowNode(id=\"BEGIN\", label=\"BEGIN\", kind=\"begin\"),\n            \"END\": FlowNode(id=\"END\", label=\"END\", kind=\"end\"),\n        }\n        outgoing: dict[str, list[FlowEdge]] = {\"BEGIN\": [], \"END\": []}\n\n        nodes[\"R1\"] = FlowNode(id=\"R1\", label=prompt_content, kind=\"task\")\n        nodes[\"R2\"] = FlowNode(\n            id=\"R2\",\n            label=(\n                f\"{prompt_text}. (You are running in an automated loop where the same \"\n                \"prompt is fed repeatedly. Only choose STOP when the task is fully complete. \"\n                \"Including it will stop further iterations. If you are not 100% sure, \"\n                \"choose CONTINUE.)\"\n            ).strip(),\n            kind=\"decision\",\n        )\n        outgoing[\"R1\"] = []\n        outgoing[\"R2\"] = []\n\n        outgoing[\"BEGIN\"].append(FlowEdge(src=\"BEGIN\", dst=\"R1\", label=None))\n        outgoing[\"R1\"].append(FlowEdge(src=\"R1\", dst=\"R2\", label=None))\n        outgoing[\"R2\"].append(FlowEdge(src=\"R2\", dst=\"R2\", label=\"CONTINUE\"))\n        outgoing[\"R2\"].append(FlowEdge(src=\"R2\", dst=\"END\", label=\"STOP\"))\n\n        flow = Flow(nodes=nodes, outgoing=outgoing, begin_id=\"BEGIN\", end_id=\"END\")\n        max_moves = total_runs\n        return FlowRunner(flow, max_moves=max_moves)\n\n    async def run(self, soul: KimiSoul, args: str) -> None:\n        if args.strip():\n            command = f\"/{FLOW_COMMAND_PREFIX}{self._name}\" if self._name else \"/flow\"\n            logger.warning(\"Agent flow {command} ignores args: {args}\", command=command, args=args)\n            return\n\n        current_id = self._flow.begin_id\n        moves = 0\n        total_steps = 0\n        while True:\n            node = self._flow.nodes[current_id]\n            edges = self._flow.outgoing.get(current_id, [])\n\n            if node.kind == \"end\":\n                logger.info(\"Agent flow reached END node {node_id}\", node_id=current_id)\n                return\n\n            if node.kind == \"begin\":\n                if not edges:\n                    logger.error(\n                        'Agent flow BEGIN node \"{node_id}\" has no outgoing edges; stopping.',\n                        node_id=node.id,\n                    )\n                    return\n                current_id = edges[0].dst\n                continue\n\n            if moves >= self._max_moves:\n                raise MaxStepsReached(total_steps)\n            next_id, steps_used = await self._execute_flow_node(soul, node, edges)\n            total_steps += steps_used\n            if next_id is None:\n                return\n            moves += 1\n            current_id = next_id\n\n    async def _execute_flow_node(\n        self,\n        soul: KimiSoul,\n        node: FlowNode,\n        edges: list[FlowEdge],\n    ) -> tuple[str | None, int]:\n        if not edges:\n            logger.error(\n                'Agent flow node \"{node_id}\" has no outgoing edges; stopping.',\n                node_id=node.id,\n            )\n            return None, 0\n\n        base_prompt = self._build_flow_prompt(node, edges)\n        prompt = base_prompt\n        steps_used = 0\n        while True:\n            result = await self._flow_turn(soul, prompt)\n            steps_used += result.step_count\n            if result.stop_reason == \"tool_rejected\":\n                logger.error(\"Agent flow stopped after tool rejection.\")\n                return None, steps_used\n\n            if node.kind != \"decision\":\n                return edges[0].dst, steps_used\n\n            choice = (\n                parse_choice(result.final_message.extract_text(\" \"))\n                if result.final_message\n                else None\n            )\n            next_id = self._match_flow_edge(edges, choice)\n            if next_id is not None:\n                return next_id, steps_used\n\n            options = \", \".join(edge.label or \"\" for edge in edges)\n            logger.warning(\n                \"Agent flow invalid choice. Got: {choice}. Available: {options}.\",\n                choice=choice or \"<missing>\",\n                options=options,\n            )\n            prompt = (\n                f\"{base_prompt}\\n\\n\"\n                \"Your last response did not include a valid choice. \"\n                \"Reply with one of the choices using <choice>...</choice>.\"\n            )\n\n    @staticmethod\n    def _build_flow_prompt(node: FlowNode, edges: list[FlowEdge]) -> str | list[ContentPart]:\n        if node.kind != \"decision\":\n            return node.label\n\n        if not isinstance(node.label, str):\n            label_text = Message(role=\"user\", content=node.label).extract_text(\" \")\n        else:\n            label_text = node.label\n        choices = [edge.label for edge in edges if edge.label]\n        lines = [\n            label_text,\n            \"\",\n            \"Available branches:\",\n            *(f\"- {choice}\" for choice in choices),\n            \"\",\n            \"Reply with a choice using <choice>...</choice>.\",\n        ]\n        return \"\\n\".join(lines)\n\n    @staticmethod\n    def _match_flow_edge(edges: list[FlowEdge], choice: str | None) -> str | None:\n        if not choice:\n            return None\n        for edge in edges:\n            if edge.label == choice:\n                return edge.dst\n        return None\n\n    @staticmethod\n    async def _flow_turn(\n        soul: KimiSoul,\n        prompt: str | list[ContentPart],\n    ) -> TurnOutcome:\n        wire_send(TurnBegin(user_input=prompt))\n        res = await soul._turn(Message(role=\"user\", content=prompt))  # type: ignore[reportPrivateUsage]\n        wire_send(TurnEnd())\n        return res\n"
  },
  {
    "path": "src/kimi_cli/soul/message.py",
    "content": "from __future__ import annotations\n\nfrom collections.abc import Sequence\n\nfrom kosong.message import Message\nfrom kosong.tooling.error import ToolRuntimeError\n\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.wire.types import (\n    ContentPart,\n    ImageURLPart,\n    TextPart,\n    ThinkPart,\n    ToolResult,\n    VideoURLPart,\n)\n\n\ndef system(message: str) -> ContentPart:\n    return TextPart(text=f\"<system>{message}</system>\")\n\n\ndef system_reminder(message: str) -> TextPart:\n    return TextPart(text=f\"<system-reminder>\\n{message}\\n</system-reminder>\")\n\n\ndef is_system_reminder_message(message: Message) -> bool:\n    \"\"\"Check whether a message is an internal system-reminder user message.\"\"\"\n    if message.role != \"user\" or len(message.content) != 1:\n        return False\n    part = message.content[0]\n    return isinstance(part, TextPart) and part.text.strip().startswith(\"<system-reminder>\")\n\n\ndef tool_result_to_message(tool_result: ToolResult) -> Message:\n    \"\"\"Convert a tool result to a message.\"\"\"\n    if tool_result.return_value.is_error:\n        assert tool_result.return_value.message, \"Error return value should have a message\"\n        message = tool_result.return_value.message\n        if isinstance(tool_result.return_value, ToolRuntimeError):\n            message += \"\\nThis is an unexpected error and the tool is probably not working.\"\n        content: list[ContentPart] = [system(f\"ERROR: {message}\")]\n        if tool_result.return_value.output:\n            content.extend(_output_to_content_parts(tool_result.return_value.output))\n    else:\n        content: list[ContentPart] = []\n        if tool_result.return_value.message:\n            content.append(system(tool_result.return_value.message))\n        if tool_result.return_value.output:\n            content.extend(_output_to_content_parts(tool_result.return_value.output))\n        if not content:\n            content.append(system(\"Tool output is empty.\"))\n\n    return Message(\n        role=\"tool\",\n        content=content,\n        tool_call_id=tool_result.tool_call_id,\n    )\n\n\ndef _output_to_content_parts(\n    output: str | ContentPart | Sequence[ContentPart],\n) -> list[ContentPart]:\n    content: list[ContentPart] = []\n    match output:\n        case str(text):\n            if text:\n                content.append(TextPart(text=text))\n        case ContentPart():\n            content.append(output)\n        case _:\n            content.extend(output)\n    return content\n\n\ndef check_message(\n    message: Message, model_capabilities: set[ModelCapability]\n) -> set[ModelCapability]:\n    \"\"\"Check the message content, return the missing model capabilities.\"\"\"\n    capabilities_needed = set[ModelCapability]()\n    for part in message.content:\n        if isinstance(part, ImageURLPart):\n            capabilities_needed.add(\"image_in\")\n        elif isinstance(part, VideoURLPart):\n            capabilities_needed.add(\"video_in\")\n        elif isinstance(part, ThinkPart):\n            capabilities_needed.add(\"thinking\")\n    return capabilities_needed - model_capabilities\n"
  },
  {
    "path": "src/kimi_cli/soul/slash.py",
    "content": "from __future__ import annotations\n\nimport tempfile\nfrom collections.abc import Awaitable, Callable\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nfrom kaos.path import KaosPath\nfrom kosong.message import Message\n\nimport kimi_cli.prompts as prompts\nfrom kimi_cli import logger\nfrom kimi_cli.soul import wire_send\nfrom kimi_cli.soul.agent import load_agents_md\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.message import system\nfrom kimi_cli.utils.export import is_sensitive_file\nfrom kimi_cli.utils.path import sanitize_cli_path, shorten_home\nfrom kimi_cli.utils.slashcmd import SlashCommandRegistry\nfrom kimi_cli.wire.types import StatusUpdate, TextPart\n\nif TYPE_CHECKING:\n    from kimi_cli.soul.kimisoul import KimiSoul\n\ntype SoulSlashCmdFunc = Callable[[KimiSoul, str], None | Awaitable[None]]\n\"\"\"\nA function that runs as a KimiSoul-level slash command.\n\nRaises:\n    Any exception that can be raised by `Soul.run`.\n\"\"\"\n\nregistry = SlashCommandRegistry[SoulSlashCmdFunc]()\n\n\n@registry.command\nasync def init(soul: KimiSoul, args: str):\n    \"\"\"Analyze the codebase and generate an `AGENTS.md` file\"\"\"\n    from kimi_cli.soul.kimisoul import KimiSoul\n\n    with tempfile.TemporaryDirectory() as temp_dir:\n        tmp_context = Context(file_backend=Path(temp_dir) / \"context.jsonl\")\n        tmp_soul = KimiSoul(soul.agent, context=tmp_context)\n        await tmp_soul.run(prompts.INIT)\n\n    agents_md = await load_agents_md(soul.runtime.builtin_args.KIMI_WORK_DIR)\n    system_message = system(\n        \"The user just ran `/init` slash command. \"\n        \"The system has analyzed the codebase and generated an `AGENTS.md` file. \"\n        f\"Latest AGENTS.md file content:\\n{agents_md}\"\n    )\n    await soul.context.append_message(Message(role=\"user\", content=[system_message]))\n\n\n@registry.command\nasync def compact(soul: KimiSoul, args: str):\n    \"\"\"Compact the context (optionally with a custom focus, e.g. /compact keep db discussions)\"\"\"\n    if soul.context.n_checkpoints == 0:\n        wire_send(TextPart(text=\"The context is empty.\"))\n        return\n\n    logger.info(\"Running `/compact`\")\n    await soul.compact_context(custom_instruction=args.strip())\n    wire_send(TextPart(text=\"The context has been compacted.\"))\n    snap = soul.status\n    wire_send(\n        StatusUpdate(\n            context_usage=snap.context_usage,\n            context_tokens=snap.context_tokens,\n            max_context_tokens=snap.max_context_tokens,\n        )\n    )\n\n\n@registry.command(aliases=[\"reset\"])\nasync def clear(soul: KimiSoul, args: str):\n    \"\"\"Clear the context\"\"\"\n    logger.info(\"Running `/clear`\")\n    await soul.context.clear()\n    await soul.context.write_system_prompt(soul.agent.system_prompt)\n    wire_send(TextPart(text=\"The context has been cleared.\"))\n    snap = soul.status\n    wire_send(\n        StatusUpdate(\n            context_usage=snap.context_usage,\n            context_tokens=snap.context_tokens,\n            max_context_tokens=snap.max_context_tokens,\n        )\n    )\n\n\n@registry.command\nasync def yolo(soul: KimiSoul, args: str):\n    \"\"\"Toggle YOLO mode (auto-approve all actions)\"\"\"\n    if soul.runtime.approval.is_yolo():\n        soul.runtime.approval.set_yolo(False)\n        wire_send(TextPart(text=\"You only die once! Actions will require approval.\"))\n    else:\n        soul.runtime.approval.set_yolo(True)\n        wire_send(TextPart(text=\"You only live once! All actions will be auto-approved.\"))\n\n\n@registry.command\nasync def plan(soul: KimiSoul, args: str):\n    \"\"\"Toggle plan mode. Usage: /plan [on|off|view|clear]\"\"\"\n    subcmd = args.strip().lower()\n\n    if subcmd == \"on\":\n        if not soul.plan_mode:\n            await soul.toggle_plan_mode_from_manual()\n        plan_path = soul.get_plan_file_path()\n        wire_send(TextPart(text=f\"Plan mode ON. Plan file: {plan_path}\"))\n        wire_send(StatusUpdate(plan_mode=soul.plan_mode))\n    elif subcmd == \"off\":\n        if soul.plan_mode:\n            await soul.toggle_plan_mode_from_manual()\n        wire_send(TextPart(text=\"Plan mode OFF. All tools are now available.\"))\n        wire_send(StatusUpdate(plan_mode=soul.plan_mode))\n    elif subcmd == \"view\":\n        content = soul.read_current_plan()\n        if content:\n            wire_send(TextPart(text=content))\n        else:\n            wire_send(TextPart(text=\"No plan file found for this session.\"))\n    elif subcmd == \"clear\":\n        soul.clear_current_plan()\n        wire_send(TextPart(text=\"Plan cleared.\"))\n    else:\n        # Default: toggle\n        new_state = await soul.toggle_plan_mode_from_manual()\n        if new_state:\n            plan_path = soul.get_plan_file_path()\n            wire_send(\n                TextPart(\n                    text=f\"Plan mode ON. Write your plan to: {plan_path}\\n\"\n                    \"Use ExitPlanMode when done, or /plan off to exit manually.\"\n                )\n            )\n        else:\n            wire_send(TextPart(text=\"Plan mode OFF. All tools are now available.\"))\n        wire_send(StatusUpdate(plan_mode=soul.plan_mode))\n\n\n@registry.command(name=\"add-dir\")\nasync def add_dir(soul: KimiSoul, args: str):\n    \"\"\"Add a directory to the workspace. Usage: /add-dir <path>. Run without args to list added dirs\"\"\"  # noqa: E501\n    from kaos.path import KaosPath\n\n    from kimi_cli.utils.path import is_within_directory, list_directory\n\n    args = sanitize_cli_path(args)\n    if not args:\n        if not soul.runtime.additional_dirs:\n            wire_send(TextPart(text=\"No additional directories. Usage: /add-dir <path>\"))\n        else:\n            lines = [\"Additional directories:\"]\n            for d in soul.runtime.additional_dirs:\n                lines.append(f\"  - {d}\")\n            wire_send(TextPart(text=\"\\n\".join(lines)))\n        return\n\n    path = KaosPath(args).expanduser().canonical()\n\n    if not await path.exists():\n        wire_send(TextPart(text=f\"Directory does not exist: {path}\"))\n        return\n    if not await path.is_dir():\n        wire_send(TextPart(text=f\"Not a directory: {path}\"))\n        return\n\n    # Check if already added (exact match)\n    if path in soul.runtime.additional_dirs:\n        wire_send(TextPart(text=f\"Directory already in workspace: {path}\"))\n        return\n\n    # Check if it's within the work_dir (already accessible)\n    work_dir = soul.runtime.builtin_args.KIMI_WORK_DIR\n    if is_within_directory(path, work_dir):\n        wire_send(TextPart(text=f\"Directory is already within the working directory: {path}\"))\n        return\n\n    # Check if it's within an already-added additional directory (redundant)\n    for existing in soul.runtime.additional_dirs:\n        if is_within_directory(path, existing):\n            wire_send(\n                TextPart(\n                    text=f\"Directory is already within an added directory `{existing}`: {path}\"\n                )\n            )\n            return\n\n    # Validate readability before committing any state changes\n    try:\n        ls_output = await list_directory(path)\n    except OSError as e:\n        wire_send(TextPart(text=f\"Cannot read directory: {path} ({e})\"))\n        return\n\n    # Add the directory (only after readability is confirmed)\n    soul.runtime.additional_dirs.append(path)\n\n    # Persist to session state\n    soul.runtime.session.state.additional_dirs.append(str(path))\n    soul.runtime.session.save_state()\n\n    # Inject a system message to inform the LLM about the new directory\n    system_message = system(\n        f\"The user has added an additional directory to the workspace: `{path}`\\n\\n\"\n        f\"Directory listing:\\n```\\n{ls_output}\\n```\\n\\n\"\n        \"You can now read, write, search, and glob files in this directory \"\n        \"as if it were part of the working directory.\"\n    )\n    await soul.context.append_message(Message(role=\"user\", content=[system_message]))\n\n    wire_send(TextPart(text=f\"Added directory to workspace: {path}\"))\n    logger.info(\"Added additional directory: {path}\", path=path)\n\n\n@registry.command\nasync def export(soul: KimiSoul, args: str):\n    \"\"\"Export current session context to a markdown file\"\"\"\n    from kimi_cli.utils.export import perform_export\n\n    session = soul.runtime.session\n    result = await perform_export(\n        history=list(soul.context.history),\n        session_id=session.id,\n        work_dir=str(session.work_dir),\n        token_count=soul.context.token_count,\n        args=args,\n        default_dir=Path(str(session.work_dir)),\n    )\n    if isinstance(result, str):\n        wire_send(TextPart(text=result))\n        return\n    output, count = result\n    display = shorten_home(KaosPath(str(output)))\n    wire_send(TextPart(text=f\"Exported {count} messages to {display}\"))\n    wire_send(\n        TextPart(\n            text=\"  Note: The exported file may contain sensitive information. \"\n            \"Please be cautious when sharing it externally.\"\n        )\n    )\n\n\n@registry.command(name=\"import\")\nasync def import_context(soul: KimiSoul, args: str):\n    \"\"\"Import context from a file or session ID\"\"\"\n    from kimi_cli.utils.export import perform_import\n\n    target = sanitize_cli_path(args)\n    if not target:\n        wire_send(TextPart(text=\"Usage: /import <file_path or session_id>\"))\n        return\n\n    session = soul.runtime.session\n    raw_max_context_size = (\n        soul.runtime.llm.max_context_size if soul.runtime.llm is not None else None\n    )\n    max_context_size = (\n        raw_max_context_size\n        if isinstance(raw_max_context_size, int) and raw_max_context_size > 0\n        else None\n    )\n    result = await perform_import(\n        target=target,\n        current_session_id=session.id,\n        work_dir=session.work_dir,\n        context=soul.context,\n        max_context_size=max_context_size,\n    )\n    if isinstance(result, str):\n        wire_send(TextPart(text=result))\n        return\n\n    source_desc, content_len = result\n    wire_send(TextPart(text=f\"Imported context from {source_desc} ({content_len} chars).\"))\n    if source_desc.startswith(\"file\") and is_sensitive_file(Path(target).name):\n        wire_send(\n            TextPart(\n                text=\"Warning: This file may contain secrets (API keys, tokens, credentials). \"\n                \"The content is now part of your session context.\"\n            )\n        )\n"
  },
  {
    "path": "src/kimi_cli/soul/toolset.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport importlib\nimport inspect\nimport json\nfrom contextvars import ContextVar\nfrom dataclasses import dataclass\nfrom datetime import timedelta\nfrom typing import TYPE_CHECKING, Any, Literal, overload\n\nfrom kosong.tooling import (\n    CallableTool,\n    CallableTool2,\n    HandleResult,\n    Tool,\n    ToolError,\n    ToolOk,\n    Toolset,\n)\nfrom kosong.tooling.error import (\n    ToolNotFoundError,\n    ToolParseError,\n    ToolRuntimeError,\n)\nfrom kosong.tooling.mcp import convert_mcp_content\nfrom kosong.utils.typing import JsonType\n\nfrom kimi_cli import logger\nfrom kimi_cli.exception import InvalidToolError, MCPRuntimeError\nfrom kimi_cli.tools import SkipThisTool\nfrom kimi_cli.tools.utils import ToolRejectedError\nfrom kimi_cli.wire.types import (\n    ContentPart,\n    MCPServerSnapshot,\n    MCPStatusSnapshot,\n    ToolCall,\n    ToolCallRequest,\n    ToolResult,\n    ToolReturnValue,\n)\n\nif TYPE_CHECKING:\n    import fastmcp\n    import mcp\n    from fastmcp.client.client import CallToolResult\n    from fastmcp.client.transports import ClientTransport\n    from fastmcp.mcp_config import MCPConfig\n\n    from kimi_cli.soul.agent import Runtime\n\ncurrent_tool_call = ContextVar[ToolCall | None](\"current_tool_call\", default=None)\n\n\ndef get_current_tool_call_or_none() -> ToolCall | None:\n    \"\"\"\n    Get the current tool call or None.\n    Expect to be not None when called from a `__call__` method of a tool.\n    \"\"\"\n    return current_tool_call.get()\n\n\ntype ToolType = CallableTool | CallableTool2[Any]\n\n\nif TYPE_CHECKING:\n\n    def type_check(kimi_toolset: KimiToolset):\n        _: Toolset = kimi_toolset\n\n\nclass KimiToolset:\n    def __init__(self) -> None:\n        self._tool_dict: dict[str, ToolType] = {}\n        self._hidden_tools: set[str] = set()\n        self._mcp_servers: dict[str, MCPServerInfo] = {}\n        self._mcp_loading_task: asyncio.Task[None] | None = None\n        self._deferred_mcp_load: tuple[list[MCPConfig], Runtime] | None = None\n\n    def add(self, tool: ToolType) -> None:\n        self._tool_dict[tool.name] = tool\n\n    def hide(self, tool_name: str) -> bool:\n        \"\"\"Hide a tool from the LLM tool list. Returns True if the tool exists.\"\"\"\n        if tool_name in self._tool_dict:\n            self._hidden_tools.add(tool_name)\n            return True\n        return False\n\n    def unhide(self, tool_name: str) -> None:\n        \"\"\"Restore a hidden tool to the LLM tool list.\"\"\"\n        self._hidden_tools.discard(tool_name)\n\n    @overload\n    def find(self, tool_name_or_type: str) -> ToolType | None: ...\n    @overload\n    def find[T: ToolType](self, tool_name_or_type: type[T]) -> T | None: ...\n    def find(self, tool_name_or_type: str | type[ToolType]) -> ToolType | None:\n        if isinstance(tool_name_or_type, str):\n            return self._tool_dict.get(tool_name_or_type)\n        else:\n            for tool in self._tool_dict.values():\n                if isinstance(tool, tool_name_or_type):\n                    return tool\n        return None\n\n    @property\n    def tools(self) -> list[Tool]:\n        return [\n            tool.base for tool in self._tool_dict.values() if tool.name not in self._hidden_tools\n        ]\n\n    def handle(self, tool_call: ToolCall) -> HandleResult:\n        token = current_tool_call.set(tool_call)\n        try:\n            if tool_call.function.name not in self._tool_dict:\n                return ToolResult(\n                    tool_call_id=tool_call.id,\n                    return_value=ToolNotFoundError(tool_call.function.name),\n                )\n\n            tool = self._tool_dict[tool_call.function.name]\n\n            try:\n                arguments: JsonType = json.loads(tool_call.function.arguments or \"{}\")\n            except json.JSONDecodeError as e:\n                return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e)))\n\n            async def _call():\n                try:\n                    ret = await tool.call(arguments)\n                    return ToolResult(tool_call_id=tool_call.id, return_value=ret)\n                except Exception as e:\n                    return ToolResult(\n                        tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e))\n                    )\n\n            return asyncio.create_task(_call())\n        finally:\n            current_tool_call.reset(token)\n\n    def register_external_tool(\n        self,\n        name: str,\n        description: str,\n        parameters: dict[str, Any],\n    ) -> tuple[bool, str | None]:\n        if name in self._tool_dict:\n            existing = self._tool_dict[name]\n            if not isinstance(existing, WireExternalTool):\n                return False, \"tool name conflicts with existing tool\"\n        try:\n            tool = WireExternalTool(\n                name=name,\n                description=description,\n                parameters=parameters,\n            )\n        except Exception as e:\n            return False, str(e)\n        self.add(tool)\n        return True, None\n\n    @property\n    def mcp_servers(self) -> dict[str, MCPServerInfo]:\n        \"\"\"Get MCP servers info.\"\"\"\n        return self._mcp_servers\n\n    def mcp_status_snapshot(self) -> MCPStatusSnapshot | None:\n        \"\"\"Return a read-only snapshot of current MCP startup state.\"\"\"\n        if not self._mcp_servers:\n            return None\n\n        servers = tuple(\n            MCPServerSnapshot(\n                name=name,\n                status=info.status,\n                tools=tuple(tool.name for tool in info.tools),\n            )\n            for name, info in self._mcp_servers.items()\n        )\n        return MCPStatusSnapshot(\n            loading=self.has_pending_mcp_tools(),\n            connected=sum(1 for server in servers if server.status == \"connected\"),\n            total=len(servers),\n            tools=sum(len(server.tools) for server in servers),\n            servers=servers,\n        )\n\n    def defer_mcp_tool_loading(self, mcp_configs: list[MCPConfig], runtime: Runtime) -> None:\n        \"\"\"Store MCP configs for a later background startup.\"\"\"\n        self._deferred_mcp_load = (list(mcp_configs), runtime)\n\n    def has_deferred_mcp_tools(self) -> bool:\n        \"\"\"Return True when MCP loading is configured but has not started yet.\"\"\"\n        return self._deferred_mcp_load is not None\n\n    async def start_deferred_mcp_tool_loading(self) -> bool:\n        \"\"\"Start any deferred MCP loading in the background.\"\"\"\n        if self._deferred_mcp_load is None:\n            return False\n        if self._mcp_loading_task is not None or self._mcp_servers:\n            self._deferred_mcp_load = None\n            return False\n\n        mcp_configs, runtime = self._deferred_mcp_load\n        self._deferred_mcp_load = None\n        await self.load_mcp_tools(mcp_configs, runtime, in_background=True)\n        return True\n\n    def load_tools(self, tool_paths: list[str], dependencies: dict[type[Any], Any]) -> None:\n        \"\"\"\n        Load tools from paths like `kimi_cli.tools.shell:Shell`.\n\n        Raises:\n            InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded.\n        \"\"\"\n\n        good_tools: list[str] = []\n        bad_tools: list[str] = []\n\n        for tool_path in tool_paths:\n            try:\n                tool = self._load_tool(tool_path, dependencies)\n            except SkipThisTool:\n                logger.info(\"Skipping tool: {tool_path}\", tool_path=tool_path)\n                continue\n            if tool:\n                self.add(tool)\n                good_tools.append(tool_path)\n            else:\n                bad_tools.append(tool_path)\n        logger.info(\"Loaded tools: {good_tools}\", good_tools=good_tools)\n        if bad_tools:\n            raise InvalidToolError(f\"Invalid tools: {bad_tools}\")\n\n    @staticmethod\n    def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None:\n        logger.debug(\"Loading tool: {tool_path}\", tool_path=tool_path)\n        module_name, class_name = tool_path.rsplit(\":\", 1)\n        try:\n            module = importlib.import_module(module_name)\n        except ImportError:\n            return None\n        tool_cls = getattr(module, class_name, None)\n        if tool_cls is None:\n            return None\n        args: list[Any] = []\n        if \"__init__\" in tool_cls.__dict__:\n            # the tool class overrides the `__init__` of base class\n            for param in inspect.signature(tool_cls).parameters.values():\n                if param.kind == inspect.Parameter.KEYWORD_ONLY:\n                    # once we encounter a keyword-only parameter, we stop injecting dependencies\n                    break\n                # all positional parameters should be dependencies to be injected\n                if param.annotation not in dependencies:\n                    raise ValueError(f\"Tool dependency not found: {param.annotation}\")\n                args.append(dependencies[param.annotation])\n        return tool_cls(*args)\n\n    # TODO(rc): remove `in_background` parameter and always load in background\n    async def load_mcp_tools(\n        self, mcp_configs: list[MCPConfig], runtime: Runtime, in_background: bool = True\n    ) -> None:\n        \"\"\"\n        Load MCP tools from specified MCP configs.\n\n        Raises:\n            MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be\n                connected.\n        \"\"\"\n        import fastmcp\n        from fastmcp.mcp_config import MCPConfig, RemoteMCPServer\n\n        from kimi_cli.ui.shell.prompt import toast\n\n        async def _check_oauth_tokens(server_url: str) -> bool:\n            \"\"\"Check if OAuth tokens exist for the server.\"\"\"\n            try:\n                from fastmcp.client.auth.oauth import FileTokenStorage\n\n                storage = FileTokenStorage(server_url=server_url)\n                tokens = await storage.get_tokens()\n                return tokens is not None\n            except Exception:\n                return False\n\n        def _toast_mcp(message: str) -> None:\n            if in_background:\n                toast(\n                    message,\n                    duration=10.0,\n                    topic=\"mcp\",\n                    immediate=True,\n                    position=\"right\",\n                )\n\n        oauth_servers: dict[str, str] = {}\n\n        async def _connect_server(\n            server_name: str, server_info: MCPServerInfo\n        ) -> tuple[str, Exception | None]:\n            if server_info.status != \"pending\":\n                return server_name, None\n\n            server_info.status = \"connecting\"\n            try:\n                async with server_info.client as client:\n                    for tool in await client.list_tools():\n                        server_info.tools.append(\n                            MCPTool(server_name, tool, client, runtime=runtime)\n                        )\n\n                for tool in server_info.tools:\n                    self.add(tool)\n\n                server_info.status = \"connected\"\n                logger.info(\"Connected MCP server: {server_name}\", server_name=server_name)\n                return server_name, None\n            except Exception as e:\n                logger.error(\n                    \"Failed to connect MCP server: {server_name}, error: {error}\",\n                    server_name=server_name,\n                    error=e,\n                )\n                server_info.status = \"failed\"\n                return server_name, e\n\n        async def _connect():\n            _toast_mcp(\"connecting to mcp servers...\")\n            unauthorized_servers: dict[str, str] = {}\n            for server_name, server_info in self._mcp_servers.items():\n                server_url = oauth_servers.get(server_name)\n                if not server_url:\n                    continue\n                if not await _check_oauth_tokens(server_url):\n                    logger.warning(\n                        \"Skipping OAuth MCP server '{server_name}': not authorized. \"\n                        \"Run 'kimi mcp auth {server_name}' first.\",\n                        server_name=server_name,\n                    )\n                    server_info.status = \"unauthorized\"\n                    unauthorized_servers[server_name] = server_url\n\n            tasks = [\n                asyncio.create_task(_connect_server(server_name, server_info))\n                for server_name, server_info in self._mcp_servers.items()\n                if server_info.status == \"pending\"\n            ]\n            results = await asyncio.gather(*tasks) if tasks else []\n            failed_servers = {name: error for name, error in results if error is not None}\n\n            for mcp_config in mcp_configs:\n                # Skip empty MCP configs (no servers defined)\n                if not mcp_config.mcpServers:\n                    logger.debug(\"Skipping empty MCP config: {mcp_config}\", mcp_config=mcp_config)\n                    continue\n\n            if failed_servers:\n                _toast_mcp(\"mcp connection failed\")\n                raise MCPRuntimeError(f\"Failed to connect MCP servers: {failed_servers}\")\n            if unauthorized_servers:\n                _toast_mcp(\"mcp authorization needed\")\n            else:\n                _toast_mcp(\"mcp servers connected\")\n\n        for mcp_config in mcp_configs:\n            if not mcp_config.mcpServers:\n                logger.debug(\"Skipping empty MCP config: {mcp_config}\", mcp_config=mcp_config)\n                continue\n\n            for server_name, server_config in mcp_config.mcpServers.items():\n                if isinstance(server_config, RemoteMCPServer) and server_config.auth == \"oauth\":\n                    oauth_servers[server_name] = server_config.url\n\n                client = fastmcp.Client(MCPConfig(mcpServers={server_name: server_config}))\n                self._mcp_servers[server_name] = MCPServerInfo(\n                    status=\"pending\", client=client, tools=[]\n                )\n\n        if in_background:\n            self._mcp_loading_task = asyncio.create_task(_connect())\n        else:\n            await _connect()\n\n    def has_pending_mcp_tools(self) -> bool:\n        \"\"\"Return True if the background MCP tool-loading task is still running.\"\"\"\n        return self._mcp_loading_task is not None and not self._mcp_loading_task.done()\n\n    async def wait_for_mcp_tools(self) -> None:\n        \"\"\"Wait for background MCP tool loading to finish.\"\"\"\n        task = self._mcp_loading_task\n        if not task:\n            return\n        try:\n            await task\n        finally:\n            if self._mcp_loading_task is task and task.done():\n                self._mcp_loading_task = None\n\n    async def cleanup(self) -> None:\n        \"\"\"Cleanup any resources held by the toolset.\"\"\"\n        self._deferred_mcp_load = None\n        if self._mcp_loading_task:\n            self._mcp_loading_task.cancel()\n            with contextlib.suppress(Exception):\n                await self._mcp_loading_task\n        for server_info in self._mcp_servers.values():\n            await server_info.client.close()\n\n\n@dataclass(slots=True)\nclass MCPServerInfo:\n    status: Literal[\"pending\", \"connecting\", \"connected\", \"failed\", \"unauthorized\"]\n    client: fastmcp.Client[Any]\n    tools: list[MCPTool[Any]]\n\n\nclass MCPTool[T: ClientTransport](CallableTool):\n    def __init__(\n        self,\n        server_name: str,\n        mcp_tool: mcp.Tool,\n        client: fastmcp.Client[T],\n        *,\n        runtime: Runtime,\n        **kwargs: Any,\n    ):\n        super().__init__(\n            name=mcp_tool.name,\n            description=(\n                f\"This is an MCP (Model Context Protocol) tool from MCP server `{server_name}`.\\n\\n\"\n                f\"{mcp_tool.description or 'No description provided.'}\"\n            ),\n            parameters=mcp_tool.inputSchema,\n            **kwargs,\n        )\n        self._mcp_tool = mcp_tool\n        self._client = client\n        self._runtime = runtime\n        self._timeout = timedelta(milliseconds=runtime.config.mcp.client.tool_call_timeout_ms)\n        self._action_name = f\"mcp:{mcp_tool.name}\"\n\n    async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:\n        description = f\"Call MCP tool `{self._mcp_tool.name}`.\"\n        if not await self._runtime.approval.request(self.name, self._action_name, description):\n            return ToolRejectedError()\n\n        try:\n            async with self._client as client:\n                result = await client.call_tool(\n                    self._mcp_tool.name,\n                    kwargs,\n                    timeout=self._timeout,\n                    raise_on_error=False,\n                )\n                return convert_mcp_tool_result(result)\n        except Exception as e:\n            # fastmcp raises `RuntimeError` on timeout and we cannot tell it from other errors\n            exc_msg = str(e).lower()\n            if \"timeout\" in exc_msg or \"timed out\" in exc_msg:\n                return ToolError(\n                    message=(\n                        f\"Timeout while calling MCP tool `{self._mcp_tool.name}`. \"\n                        \"You may explain to the user that the timeout config is set too low.\"\n                    ),\n                    brief=\"Timeout\",\n                )\n            raise\n\n\nclass WireExternalTool(CallableTool):\n    def __init__(self, *, name: str, description: str, parameters: dict[str, Any]) -> None:\n        super().__init__(\n            name=name,\n            description=description or \"No description provided.\",\n            parameters=parameters,\n        )\n\n    async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:\n        tool_call = get_current_tool_call_or_none()\n        if tool_call is None:\n            return ToolError(\n                message=\"External tool calls must be invoked from a tool call context.\",\n                brief=\"Invalid tool call\",\n            )\n\n        from kimi_cli.soul import get_wire_or_none\n\n        wire = get_wire_or_none()\n        if wire is None:\n            logger.error(\n                \"Wire is not available for external tool call: {tool_name}\", tool_name=self.name\n            )\n            return ToolError(\n                message=\"Wire is not available for external tool calls.\",\n                brief=\"Wire unavailable\",\n            )\n\n        external_tool_call = ToolCallRequest.from_tool_call(tool_call)\n        wire.soul_side.send(external_tool_call)\n        try:\n            return await external_tool_call.wait()\n        except asyncio.CancelledError:\n            raise\n        except Exception as e:\n            logger.exception(\"External tool call failed: {tool_name}:\", tool_name=self.name)\n            return ToolError(\n                message=f\"External tool call failed: {e}\",\n                brief=\"External tool error\",\n            )\n\n\ndef convert_mcp_tool_result(result: CallToolResult) -> ToolReturnValue:\n    \"\"\"Convert MCP tool result to kosong tool return value.\n\n    Raises:\n        ValueError: If any content part has unsupported type or mime type.\n    \"\"\"\n    content: list[ContentPart] = []\n    for part in result.content:\n        content.append(convert_mcp_content(part))\n    if result.is_error:\n        return ToolError(\n            output=content,\n            message=\"Tool returned an error. The output may be error message or incomplete output\",\n            brief=\"\",\n        )\n    else:\n        return ToolOk(output=content)\n"
  },
  {
    "path": "src/kimi_cli/tools/AGENTS.md",
    "content": "# Kimi Code CLI Tools\n\n## Guidelines\n\n- Except for `Task` tool, tools should not refer to any types in `kimi_cli/wire/`. When importing things like `ToolReturnValue`, `DisplayBlock`, import from `kosong.tooling`.\n"
  },
  {
    "path": "src/kimi_cli/tools/__init__.py",
    "content": "import json\nfrom typing import cast\n\nimport streamingjson  # type: ignore[reportMissingTypeStubs]\nfrom kaos.path import KaosPath\nfrom kosong.utils.typing import JsonType\n\nfrom kimi_cli.utils.string import shorten_middle\n\n\nclass SkipThisTool(Exception):\n    \"\"\"Raised when a tool decides to skip itself from the loading process.\"\"\"\n\n    pass\n\n\ndef extract_key_argument(json_content: str | streamingjson.Lexer, tool_name: str) -> str | None:\n    if isinstance(json_content, streamingjson.Lexer):\n        json_str = json_content.complete_json()\n    else:\n        json_str = json_content\n    try:\n        curr_args: JsonType = json.loads(json_str)\n    except json.JSONDecodeError:\n        return None\n    if not curr_args:\n        return None\n    key_argument: str = \"\"\n    match tool_name:\n        case \"Task\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"description\"):\n                return None\n            key_argument = str(curr_args[\"description\"])\n        case \"CreateSubagent\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"name\"):\n                return None\n            key_argument = str(curr_args[\"name\"])\n        case \"SendDMail\":\n            return None\n        case \"Think\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"thought\"):\n                return None\n            key_argument = str(curr_args[\"thought\"])\n        case \"SetTodoList\":\n            return None\n        case \"Shell\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"command\"):\n                return None\n            key_argument = str(curr_args[\"command\"])\n        case \"TaskOutput\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"task_id\"):\n                return None\n            key_argument = str(curr_args[\"task_id\"])\n        case \"TaskList\":\n            if not isinstance(curr_args, dict):\n                return None\n            key_argument = \"active\" if curr_args.get(\"active_only\", True) else \"all\"\n        case \"TaskStop\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"task_id\"):\n                return None\n            key_argument = str(curr_args[\"task_id\"])\n        case \"ReadFile\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"path\"):\n                return None\n            key_argument = _normalize_path(str(curr_args[\"path\"]))\n        case \"ReadMediaFile\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"path\"):\n                return None\n            key_argument = _normalize_path(str(curr_args[\"path\"]))\n        case \"Glob\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"pattern\"):\n                return None\n            key_argument = str(curr_args[\"pattern\"])\n        case \"Grep\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"pattern\"):\n                return None\n            key_argument = str(curr_args[\"pattern\"])\n        case \"WriteFile\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"path\"):\n                return None\n            key_argument = _normalize_path(str(curr_args[\"path\"]))\n        case \"StrReplaceFile\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"path\"):\n                return None\n            key_argument = _normalize_path(str(curr_args[\"path\"]))\n        case \"SearchWeb\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"query\"):\n                return None\n            key_argument = str(curr_args[\"query\"])\n        case \"FetchURL\":\n            if not isinstance(curr_args, dict) or not curr_args.get(\"url\"):\n                return None\n            key_argument = str(curr_args[\"url\"])\n        case _:\n            if isinstance(json_content, streamingjson.Lexer):\n                # lexer.json_content is list[str] based on streamingjson source code\n                content: list[str] = cast(list[str], json_content.json_content)  # type: ignore[reportUnknownMemberType]\n                key_argument = \"\".join(content)\n            else:\n                key_argument = json_content\n    key_argument = shorten_middle(key_argument, width=50)\n    return key_argument\n\n\ndef _normalize_path(path: str) -> str:\n    cwd = str(KaosPath.cwd().canonical())\n    if path.startswith(cwd):\n        path = path[len(cwd) :].lstrip(\"/\\\\\")\n    return path\n"
  },
  {
    "path": "src/kimi_cli/tools/ask_user/__init__.py",
    "content": "from __future__ import annotations\n\nimport json\nimport logging\nfrom pathlib import Path\nfrom typing import override\nfrom uuid import uuid4\n\nfrom kosong.tooling import BriefDisplayBlock, CallableTool2, ToolError, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.soul import get_wire_or_none, wire_send\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.tools.utils import load_desc\nfrom kimi_cli.wire.types import QuestionItem, QuestionNotSupported, QuestionOption, QuestionRequest\n\nlogger = logging.getLogger(__name__)\n\nNAME = \"AskUserQuestion\"\n\n_BASE_DESCRIPTION = load_desc(Path(__file__).parent / \"description.md\")\n\n\nclass QuestionOptionParam(BaseModel):\n    label: str = Field(\n        description=\"Concise display text (1-5 words). If recommended, append '(Recommended)'.\"\n    )\n    description: str = Field(\n        default=\"\",\n        description=\"Brief explanation of trade-offs or implications of choosing this option.\",\n    )\n\n\nclass QuestionParam(BaseModel):\n    question: str = Field(description=\"A specific, actionable question. End with '?'.\")\n    header: str = Field(\n        default=\"\", description=\"Short category tag (max 12 chars, e.g. 'Auth', 'Style').\"\n    )\n    options: list[QuestionOptionParam] = Field(\n        description=(\n            \"2-4 meaningful, distinct options. Do NOT include an 'Other' option — \"\n            \"the system adds one automatically.\"\n        ),\n        min_length=2,\n        max_length=4,\n    )\n    multi_select: bool = Field(\n        default=False,\n        description=\"Whether the user can select multiple options.\",\n    )\n\n\nclass Params(BaseModel):\n    questions: list[QuestionParam] = Field(\n        description=\"The questions to ask the user (1-4 questions).\",\n        min_length=1,\n        max_length=4,\n    )\n\n\nclass AskUserQuestion(CallableTool2[Params]):\n    name: str = NAME\n    description: str = _BASE_DESCRIPTION\n    params: type[Params] = Params\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        wire = get_wire_or_none()\n        if wire is None:\n            return ToolError(\n                message=\"Cannot ask user questions: Wire is not available.\",\n                brief=\"Wire unavailable\",\n            )\n\n        tool_call = get_current_tool_call_or_none()\n        if tool_call is None:\n            return ToolError(\n                message=\"AskUserQuestion must be called from a tool call context.\",\n                brief=\"Invalid context\",\n            )\n\n        questions = [\n            QuestionItem(\n                question=q.question,\n                header=q.header,\n                options=[\n                    QuestionOption(label=o.label, description=o.description) for o in q.options\n                ],\n                multi_select=q.multi_select,\n            )\n            for q in params.questions\n        ]\n\n        request = QuestionRequest(\n            id=str(uuid4()),\n            tool_call_id=tool_call.id,\n            questions=questions,\n        )\n\n        wire_send(request)\n\n        try:\n            answers = await request.wait()\n        except QuestionNotSupported:\n            return ToolError(\n                message=(\n                    \"The connected client does not support interactive questions. \"\n                    \"Do NOT call this tool again. \"\n                    \"Ask the user directly in your text response instead.\"\n                ),\n                brief=\"Client unsupported\",\n            )\n        except Exception:\n            logger.exception(\"Failed to get user response for question %s\", request.id)\n            return ToolError(\n                message=\"Failed to get user response.\",\n                brief=\"Question failed\",\n            )\n\n        if not answers:\n            return ToolReturnValue(\n                is_error=False,\n                output='{\"answers\": {}, \"note\": \"User dismissed the question without answering.\"}',\n                message=\"User dismissed the question without answering.\",\n                display=[BriefDisplayBlock(text=\"User dismissed\")],\n            )\n\n        formatted = json.dumps({\"answers\": answers}, ensure_ascii=False)\n        return ToolReturnValue(\n            is_error=False,\n            output=formatted,\n            message=\"User has answered.\",\n            display=[BriefDisplayBlock(text=\"User answered\")],\n        )\n"
  },
  {
    "path": "src/kimi_cli/tools/ask_user/description.md",
    "content": "Use this tool when you need to ask the user questions with structured options during execution. This allows you to:\n1. Collect user preferences or requirements before proceeding\n2. Resolve ambiguous or underspecified instructions\n3. Let the user decide between implementation approaches as you work\n4. Present concrete options when multiple valid directions exist\n\n**When NOT to use:**\n- When you can infer the answer from context — be decisive and proceed\n- Trivial decisions that don't materially affect the outcome\n\nOverusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action.\n\n**Usage notes:**\n- Users always have an \"Other\" option for custom input — don't create one yourself\n- Use multi_select to allow multiple answers to be selected for a question\n- Keep option labels concise (1-5 words), use descriptions for trade-offs and details\n- Each question should have 2-4 meaningful, distinct options\n- You can ask 1-4 questions at a time; group related questions to minimize interruptions\n- If you recommend a specific option, list it first and append \"(Recommended)\" to its label\n"
  },
  {
    "path": "src/kimi_cli/tools/background/__init__.py",
    "content": "import time\nfrom pathlib import Path\nfrom typing import override\n\nfrom kosong.tooling import CallableTool2, ToolError, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.background import TaskStatus, TaskView, format_task, format_task_list, list_task_views\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.tools.display import BackgroundTaskDisplayBlock\nfrom kimi_cli.tools.utils import ToolRejectedError, load_desc\n\nTASK_OUTPUT_PREVIEW_BYTES = 32 << 10\nTASK_OUTPUT_READ_HINT_LINES = 300\n\n\ndef _ensure_root(runtime: Runtime) -> ToolError | None:\n    if runtime.role != \"root\":\n        return ToolError(\n            message=\"Background tasks can only be managed by the root agent.\",\n            brief=\"Background task unavailable\",\n        )\n    return None\n\n\ndef _task_display(runtime: Runtime, task_id: str) -> BackgroundTaskDisplayBlock:\n    view = runtime.background_tasks.store.merged_view(task_id)\n    return BackgroundTaskDisplayBlock(\n        task_id=view.spec.id,\n        kind=view.spec.kind,\n        status=view.runtime.status,\n        description=view.spec.description,\n    )\n\n\ndef _format_task_output(\n    view: TaskView,\n    *,\n    retrieval_status: str,\n    output: str,\n    output_path: Path,\n    full_output_available: bool,\n    output_size_bytes: int,\n    output_preview_bytes: int,\n    output_truncated: bool,\n) -> str:\n    terminal_reason = \"timed_out\" if view.runtime.timed_out else view.runtime.status\n    output_path_str = str(output_path.resolve())\n    lines = [\n        f\"retrieval_status: {retrieval_status}\",\n        f\"task_id: {view.spec.id}\",\n        f\"kind: {view.spec.kind}\",\n        f\"status: {view.runtime.status}\",\n        f\"description: {view.spec.description}\",\n    ]\n    if view.spec.command:\n        lines.append(f\"command: {view.spec.command}\")\n    lines.extend(\n        [\n            f\"interrupted: {str(view.runtime.interrupted).lower()}\",\n            f\"timed_out: {str(view.runtime.timed_out).lower()}\",\n            f\"terminal_reason: {terminal_reason}\",\n        ]\n    )\n    if view.runtime.exit_code is not None:\n        lines.append(f\"exit_code: {view.runtime.exit_code}\")\n    if view.runtime.failure_reason:\n        lines.append(f\"reason: {view.runtime.failure_reason}\")\n    full_output_hint = (\n        (\n            \"full_output_hint: \"\n            f'Use ReadFile(path=\"{output_path_str}\", line_offset=1, '\n            f\"n_lines={TASK_OUTPUT_READ_HINT_LINES}) to inspect the full log. \"\n            \"Increase line_offset to continue paging through the file.\"\n        )\n        if full_output_available\n        else \"full_output_hint: No output file is currently available for this task.\"\n    )\n    lines.extend(\n        [\n            \"\",\n            f\"output_path: {output_path_str}\",\n            f\"output_size_bytes: {output_size_bytes}\",\n            f\"output_preview_bytes: {output_preview_bytes}\",\n            f\"output_truncated: {str(output_truncated).lower()}\",\n            \"\",\n            f\"full_output_available: {str(full_output_available).lower()}\",\n            \"full_output_tool: ReadFile\",\n            full_output_hint,\n        ]\n    )\n    rendered_output = output or \"[no output available]\"\n    if output_truncated:\n        rendered_output = f\"[Truncated. Full output: {output_path_str}]\\n\\n{rendered_output}\"\n    return \"\\n\".join(\n        lines\n        + [\n            \"\",\n            \"[output]\",\n            rendered_output,\n        ]\n    )\n\n\nclass TaskOutputParams(BaseModel):\n    task_id: str = Field(description=\"The background task ID to inspect.\")\n    block: bool = Field(\n        default=True,\n        description=\"Whether to wait for the task to finish before returning.\",\n    )\n    timeout: int = Field(\n        default=30,\n        ge=0,\n        le=3600,\n        description=\"Maximum number of seconds to wait when block=true.\",\n    )\n\n\nclass TaskStopParams(BaseModel):\n    task_id: str = Field(description=\"The background task ID to stop.\")\n    reason: str = Field(\n        default=\"Stopped by TaskStop\",\n        description=\"Short reason recorded when the task is stopped.\",\n    )\n\n\nclass TaskListParams(BaseModel):\n    active_only: bool = Field(\n        default=True,\n        description=\"Whether to list only non-terminal background tasks.\",\n    )\n    limit: int = Field(\n        default=20,\n        ge=1,\n        le=100,\n        description=\"Maximum number of tasks to return.\",\n    )\n\n\nclass TaskList(CallableTool2[TaskListParams]):\n    name: str = \"TaskList\"\n    description: str = load_desc(Path(__file__).parent / \"list.md\")\n    params: type[TaskListParams] = TaskListParams\n\n    def __init__(self, runtime: Runtime):\n        super().__init__()\n        self._runtime = runtime\n\n    @override\n    async def __call__(self, params: TaskListParams) -> ToolReturnValue:\n        if err := _ensure_root(self._runtime):\n            return err\n\n        views = list_task_views(\n            self._runtime.background_tasks,\n            active_only=params.active_only,\n            limit=params.limit,\n        )\n        display = [\n            BackgroundTaskDisplayBlock(\n                task_id=view.spec.id,\n                kind=view.spec.kind,\n                status=view.runtime.status,\n                description=view.spec.description,\n            )\n            for view in views\n        ]\n        return ToolReturnValue(\n            is_error=False,\n            output=format_task_list(views, active_only=params.active_only),\n            message=\"Task list retrieved.\",\n            display=list(display),\n        )\n\n\nclass TaskOutput(CallableTool2[TaskOutputParams]):\n    name: str = \"TaskOutput\"\n    description: str = load_desc(Path(__file__).parent / \"output.md\")\n    params: type[TaskOutputParams] = TaskOutputParams\n\n    def __init__(self, runtime: Runtime):\n        super().__init__()\n        self._runtime = runtime\n\n    def _render_output_preview(\n        self, task_id: str, *, status: TaskStatus\n    ) -> tuple[str, bool, int, int, bool, Path]:\n        output_path = self._runtime.background_tasks.store.output_path(task_id)\n        output_available = output_path.exists()\n        try:\n            output_size = output_path.stat().st_size\n        except OSError:\n            output_size = 0\n        preview_offset = max(0, output_size - TASK_OUTPUT_PREVIEW_BYTES)\n        chunk = self._runtime.background_tasks.store.read_output(\n            task_id,\n            preview_offset,\n            TASK_OUTPUT_PREVIEW_BYTES,\n            status=status,\n        )\n        preview_bytes = chunk.next_offset - chunk.offset\n        preview_text = chunk.text.rstrip(\"\\n\")\n        preview_truncated = preview_offset > 0\n        return (\n            preview_text,\n            output_available,\n            output_size,\n            preview_bytes,\n            preview_truncated,\n            output_path,\n        )\n\n    @override\n    async def __call__(self, params: TaskOutputParams) -> ToolReturnValue:\n        if err := _ensure_root(self._runtime):\n            return err\n\n        view = self._runtime.background_tasks.get_task(params.task_id)\n        if view is None:\n            return ToolError(message=f\"Task not found: {params.task_id}\", brief=\"Task not found\")\n\n        if params.block:\n            view = await self._runtime.background_tasks.wait(\n                params.task_id,\n                timeout_s=params.timeout,\n            )\n            retrieval_status = (\n                \"success\"\n                if view.runtime.status in {\"completed\", \"failed\", \"killed\", \"lost\"}\n                else \"timeout\"\n            )\n        else:\n            retrieval_status = (\n                \"success\"\n                if view.runtime.status in {\"completed\", \"failed\", \"killed\", \"lost\"}\n                else \"not_ready\"\n            )\n\n        (\n            output,\n            full_output_available,\n            output_size,\n            output_preview_bytes,\n            output_truncated,\n            output_path,\n        ) = self._render_output_preview(\n            params.task_id,\n            status=view.runtime.status,\n        )\n        consumer = view.consumer.model_copy(\n            update={\n                \"last_seen_output_size\": output_size,\n                \"last_viewed_at\": time.time(),\n            }\n        )\n        self._runtime.background_tasks.store.write_consumer(params.task_id, consumer)\n\n        return ToolReturnValue(\n            is_error=False,\n            output=_format_task_output(\n                view,\n                retrieval_status=retrieval_status,\n                output=output,\n                output_path=output_path,\n                full_output_available=full_output_available,\n                output_size_bytes=output_size,\n                output_preview_bytes=output_preview_bytes,\n                output_truncated=output_truncated,\n            ),\n            message=\"Task output retrieved.\",\n            display=[_task_display(self._runtime, params.task_id)],\n        )\n\n\nclass TaskStop(CallableTool2[TaskStopParams]):\n    name: str = \"TaskStop\"\n    description: str = load_desc(Path(__file__).parent / \"stop.md\")\n    params: type[TaskStopParams] = TaskStopParams\n\n    def __init__(self, runtime: Runtime, approval: Approval):\n        super().__init__()\n        self._runtime = runtime\n        self._approval = approval\n\n    @override\n    async def __call__(self, params: TaskStopParams) -> ToolReturnValue:\n        if err := _ensure_root(self._runtime):\n            return err\n        if self._runtime.session.state.plan_mode:\n            return ToolError(\n                message=\"TaskStop is not available in plan mode.\",\n                brief=\"Blocked in plan mode\",\n            )\n\n        view = self._runtime.background_tasks.get_task(params.task_id)\n        if view is None:\n            return ToolError(message=f\"Task not found: {params.task_id}\", brief=\"Task not found\")\n\n        if not await self._approval.request(\n            self.name,\n            \"stop background task\",\n            f\"Stop background task `{params.task_id}`\",\n            display=[_task_display(self._runtime, params.task_id)],\n        ):\n            return ToolRejectedError()\n\n        view = self._runtime.background_tasks.kill(\n            params.task_id,\n            reason=params.reason.strip() or \"Stopped by TaskStop\",\n        )\n        return ToolReturnValue(\n            is_error=False,\n            output=format_task(view, include_command=True),\n            message=\"Task stop requested.\",\n            display=[_task_display(self._runtime, params.task_id)],\n        )\n"
  },
  {
    "path": "src/kimi_cli/tools/background/list.md",
    "content": "List background tasks from the current session.\n\nUse this when you need to re-enumerate which background tasks still exist, especially after context compaction or when you are no longer confident which task IDs are still active.\n\nGuidelines:\n\n- Prefer the default `active_only=true` unless you specifically need completed or failed tasks.\n- Use `TaskOutput` to inspect one task in detail after you have identified the correct task ID.\n- Do not guess which tasks are still running when you can call this tool directly.\n- This tool is read-only and safe to use in plan mode.\n"
  },
  {
    "path": "src/kimi_cli/tools/background/output.md",
    "content": "Retrieve output from a running or completed background task.\n\nUse this after `Shell(run_in_background=true)` when you need to inspect progress or explicitly wait for completion.\n\nGuidelines:\n- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives.\n- Use `block=true` to wait for completion or timeout.\n- Use `block=false` for a non-blocking status and output check.\n- This tool returns structured task metadata, a fixed-size output preview, and an `output_path` for the full log.\n- When the preview is truncated, use `ReadFile` with the returned `output_path` to inspect the full log in pages.\n- This tool works with the generic background task system and should remain the primary read path for future task types, not just bash.\n"
  },
  {
    "path": "src/kimi_cli/tools/background/stop.md",
    "content": "Stop a running background task.\n\nUse this only when a background task must be cancelled. For normal task completion, prefer waiting for the automatic notification or using `TaskOutput`.\n\nGuidelines:\n- This is a generic task stop capability, not a bash-specific kill tool.\n- Use it sparingly because stopping a task is destructive and may leave partial side effects.\n- If the task is already complete, this tool will simply return its current state.\n"
  },
  {
    "path": "src/kimi_cli/tools/display.py",
    "content": "from typing import Literal\n\nfrom kosong.tooling import DisplayBlock\nfrom pydantic import BaseModel\n\n\nclass DiffDisplayBlock(DisplayBlock):\n    \"\"\"Display block describing a file diff.\"\"\"\n\n    type: str = \"diff\"\n    path: str\n    old_text: str\n    new_text: str\n\n\nclass TodoDisplayItem(BaseModel):\n    title: str\n    status: Literal[\"pending\", \"in_progress\", \"done\"]\n\n\nclass TodoDisplayBlock(DisplayBlock):\n    \"\"\"Display block describing a todo list update.\"\"\"\n\n    type: str = \"todo\"\n    items: list[TodoDisplayItem]\n\n\nclass ShellDisplayBlock(DisplayBlock):\n    \"\"\"Display block describing a shell command.\"\"\"\n\n    type: str = \"shell\"\n    language: str\n    command: str\n\n\nclass BackgroundTaskDisplayBlock(DisplayBlock):\n    \"\"\"Display block describing a background task.\"\"\"\n\n    type: str = \"background_task\"\n    task_id: str\n    kind: str\n    status: str\n    description: str\n"
  },
  {
    "path": "src/kimi_cli/tools/dmail/__init__.py",
    "content": "from pathlib import Path\nfrom typing import override\n\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\n\nfrom kimi_cli.soul.denwarenji import DenwaRenji, DenwaRenjiError, DMail\nfrom kimi_cli.tools.utils import load_desc\n\nNAME = \"SendDMail\"\n\n\nclass SendDMail(CallableTool2[DMail]):\n    name: str = NAME\n    description: str = load_desc(Path(__file__).parent / \"dmail.md\")\n    params: type[DMail] = DMail\n\n    def __init__(self, denwa_renji: DenwaRenji) -> None:\n        super().__init__()\n        self._denwa_renji = denwa_renji\n\n    @override\n    async def __call__(self, params: DMail) -> ToolReturnValue:\n        try:\n            self._denwa_renji.send_dmail(params)\n        except DenwaRenjiError as e:\n            return ToolError(\n                output=\"\",\n                message=f\"Failed to send D-Mail. Error: {str(e)}\",\n                brief=\"Failed to send D-Mail\",\n            )\n        return ToolOk(\n            output=\"\",\n            message=(\n                \"If you see this message, the D-Mail was NOT sent successfully. \"\n                \"This may be because some other tool that needs approval was rejected.\"\n            ),\n            brief=\"El Psy Kongroo\",\n        )\n"
  },
  {
    "path": "src/kimi_cli/tools/dmail/dmail.md",
    "content": "Send a message to the past, just like sending a D-Mail in Steins;Gate.\n\nThis tool is provided to enable you to proactively manage the context. You can see some `user` messages with text `CHECKPOINT {checkpoint_id}` wrapped in `<system>` tags in the context. When you feel there is too much irrelevant information in the current context, you can send a D-Mail to revert the context to a previous checkpoint with a message containing only the useful information. When you send a D-Mail, you must specify an existing checkpoint ID from the before-mentioned messages.\n\nTypical scenarios you may want to send a D-Mail:\n\n- You read a file, found it very large and most of the content is not relevant to the current task. In this case you can send a D-Mail immediately to the checkpoint before you read the file and give your past self only the useful part.\n- You searched the web, the result is large.\n  - If you got what you need, you may send a D-Mail to the checkpoint before you searched the web and put only the useful result in the mail message.\n  - If you did not get what you need, you may send a D-Mail to tell your past self to try another query.\n- You wrote some code and it did not work as expected. You spent many struggling steps to fix it but the process is not relevant to the ultimate goal. In this case you can send a D-Mail to the checkpoint before you wrote the code and give your past self the fixed version of the code and tell yourself no need to write it again because you already wrote to the filesystem.\n\nAfter a D-Mail is sent, the system will revert the current context to the specified checkpoint, after which, you will no longer see any messages which you can now see after that checkpoint. The message in the D-Mail will be appended to the end of the context. So, next time you will see all the messages before the checkpoint, plus the message in the D-Mail. You must make it very clear in the message, tell your past self what you have done/changed, what you have learned and any other information that may be useful, so that your past self can continue the task without confusion and will not repeat the steps you have already done.\n\nYou must understand that, unlike D-Mail in Steins;Gate, the D-Mail you send here will not revert the filesystem or any external state. That means, you are basically folding the recent messages in your context into a single message, which can significantly reduce the waste of context window.\n\nWhen sending a D-Mail, DO NOT explain to the user. The user do not care about this. Just explain to your past self.\n"
  },
  {
    "path": "src/kimi_cli/tools/file/__init__.py",
    "content": "from enum import StrEnum\n\n\nclass FileOpsWindow:\n    \"\"\"Maintains a window of file operations.\"\"\"\n\n    pass\n\n\nclass FileActions(StrEnum):\n    READ = \"read file\"\n    EDIT = \"edit file\"\n    EDIT_OUTSIDE = \"edit file outside of working directory\"\n\n\nfrom .glob import Glob  # noqa: E402\nfrom .grep_local import Grep  # noqa: E402\nfrom .read import ReadFile  # noqa: E402\nfrom .read_media import ReadMediaFile  # noqa: E402\nfrom .replace import StrReplaceFile  # noqa: E402\nfrom .write import WriteFile  # noqa: E402\n\n__all__ = (\n    \"ReadFile\",\n    \"ReadMediaFile\",\n    \"Glob\",\n    \"Grep\",\n    \"WriteFile\",\n    \"StrReplaceFile\",\n)\n"
  },
  {
    "path": "src/kimi_cli/tools/file/glob.md",
    "content": "Find files and directories using glob patterns. This tool supports standard glob syntax like `*`, `?`, and `**` for recursive searches.\n\n**When to use:**\n- Find files matching specific patterns (e.g., all Python files: `*.py`)\n- Search for files recursively in subdirectories (e.g., `src/**/*.js`)\n- Locate configuration files (e.g., `*.config.*`, `*.json`)\n- Find test files (e.g., `test_*.py`, `*_test.go`)\n\n**Example patterns:**\n- `*.py` - All Python files in current directory\n- `src/**/*.js` - All JavaScript files in src directory recursively\n- `test_*.py` - Python test files starting with \"test_\"\n- `*.config.{js,ts}` - Config files with .js or .ts extension\n\n**Bad example patterns:**\n- `**`, `**/*.py` - Any pattern starting with '**' will be rejected. Because it would recursively search all directories and subdirectories, which is very likely to yield large result that exceeds your context size. Always use more specific patterns like `src/**/*.py` instead.\n- `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursively searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead.\n"
  },
  {
    "path": "src/kimi_cli/tools/file/glob.py",
    "content": "\"\"\"Glob tool implementation.\"\"\"\n\nfrom pathlib import Path\nfrom typing import override\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.tools.utils import load_desc\nfrom kimi_cli.utils.path import is_within_workspace, list_directory\n\nMAX_MATCHES = 1000\n\n\nclass Params(BaseModel):\n    pattern: str = Field(description=(\"Glob pattern to match files/directories.\"))\n    directory: str | None = Field(\n        description=(\n            \"Absolute path to the directory to search in (defaults to working directory).\"\n        ),\n        default=None,\n    )\n    include_dirs: bool = Field(\n        description=\"Whether to include directories in results.\",\n        default=True,\n    )\n\n\nclass Glob(CallableTool2[Params]):\n    name: str = \"Glob\"\n    description: str = load_desc(\n        Path(__file__).parent / \"glob.md\",\n        {\n            \"MAX_MATCHES\": str(MAX_MATCHES),\n        },\n    )\n    params: type[Params] = Params\n\n    def __init__(self, runtime: Runtime) -> None:\n        super().__init__()\n        self._work_dir = runtime.builtin_args.KIMI_WORK_DIR\n        self._additional_dirs = runtime.additional_dirs\n\n    async def _validate_pattern(self, pattern: str) -> ToolError | None:\n        \"\"\"Validate that the pattern is safe to use.\"\"\"\n        if pattern.startswith(\"**\"):\n            ls_result = await list_directory(self._work_dir)\n            return ToolError(\n                output=ls_result,\n                message=(\n                    f\"Pattern `{pattern}` starts with '**' which is not allowed. \"\n                    \"This would recursively search all directories and may include large \"\n                    \"directories like `node_modules`. Use more specific patterns instead. \"\n                    \"For your convenience, a list of all files and directories in the \"\n                    \"top level of the working directory is provided below.\"\n                ),\n                brief=\"Unsafe pattern\",\n            )\n        return None\n\n    async def _validate_directory(self, directory: KaosPath) -> ToolError | None:\n        \"\"\"Validate that the directory is safe to search.\"\"\"\n        resolved_dir = directory.canonical()\n\n        # Ensure the directory is within the workspace (work_dir or additional dirs)\n        if not is_within_workspace(resolved_dir, self._work_dir, self._additional_dirs):\n            return ToolError(\n                message=(\n                    f\"`{directory}` is outside the workspace. \"\n                    \"You can only search within the working directory \"\n                    \"and additional directories.\"\n                ),\n                brief=\"Directory outside workspace\",\n            )\n        return None\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        try:\n            # Validate pattern safety\n            pattern_error = await self._validate_pattern(params.pattern)\n            if pattern_error:\n                return pattern_error\n\n            dir_path = KaosPath(params.directory) if params.directory else self._work_dir\n\n            if not dir_path.is_absolute():\n                return ToolError(\n                    message=(\n                        f\"`{params.directory}` is not an absolute path. \"\n                        \"You must provide an absolute path to search.\"\n                    ),\n                    brief=\"Invalid directory\",\n                )\n\n            # Validate directory safety\n            dir_error = await self._validate_directory(dir_path)\n            if dir_error:\n                return dir_error\n\n            if not await dir_path.exists():\n                return ToolError(\n                    message=f\"`{params.directory}` does not exist.\",\n                    brief=\"Directory not found\",\n                )\n            if not await dir_path.is_dir():\n                return ToolError(\n                    message=f\"`{params.directory}` is not a directory.\",\n                    brief=\"Invalid directory\",\n                )\n\n            # Perform the glob search - users can use ** directly in pattern\n            matches: list[KaosPath] = []\n            async for match in dir_path.glob(params.pattern):\n                matches.append(match)\n\n            # Filter out directories if not requested\n            if not params.include_dirs:\n                matches = [p for p in matches if await p.is_file()]\n\n            # Sort for consistent output\n            matches.sort()\n\n            # Limit matches\n            message = (\n                f\"Found {len(matches)} matches for pattern `{params.pattern}`.\"\n                if len(matches) > 0\n                else f\"No matches found for pattern `{params.pattern}`.\"\n            )\n            if len(matches) > MAX_MATCHES:\n                matches = matches[:MAX_MATCHES]\n                message += (\n                    f\" Only the first {MAX_MATCHES} matches are returned. \"\n                    \"You may want to use a more specific pattern.\"\n                )\n\n            return ToolOk(\n                output=\"\\n\".join(str(p.relative_to(dir_path)) for p in matches),\n                message=message,\n            )\n\n        except Exception as e:\n            return ToolError(\n                message=f\"Failed to search for pattern {params.pattern}. Error: {e}\",\n                brief=\"Glob failed\",\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/file/grep.md",
    "content": "A powerful search tool based-on ripgrep.\n\n**Tips:**\n- ALWAYS use Grep tool instead of running `grep` or `rg` command with Shell tool.\n- Use the ripgrep pattern syntax, not grep syntax. E.g. you need to escape braces like `\\\\{` to search for `{`.\n"
  },
  {
    "path": "src/kimi_cli/tools/file/grep_local.py",
    "content": "\"\"\"\nThe local version of the Grep tool using ripgrep.\nBe cautious that `KaosPath` is not used in this implementation.\n\"\"\"\n\nimport asyncio\nimport platform\nimport shutil\nimport stat\nimport tarfile\nimport tempfile\nimport zipfile\nfrom pathlib import Path\nfrom typing import override\n\nimport aiohttp\nimport ripgrepy  # type: ignore[reportMissingTypeStubs]\nfrom kosong.tooling import CallableTool2, ToolError, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nimport kimi_cli\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.tools.utils import ToolResultBuilder, load_desc\nfrom kimi_cli.utils.aiohttp import new_client_session\nfrom kimi_cli.utils.logging import logger\n\n\nclass Params(BaseModel):\n    pattern: str = Field(\n        description=\"The regular expression pattern to search for in file contents\"\n    )\n    path: str = Field(\n        description=(\n            \"File or directory to search in. Defaults to current working directory. \"\n            \"If specified, it must be an absolute path.\"\n        ),\n        default=\".\",\n    )\n    glob: str | None = Field(\n        description=(\n            \"Glob pattern to filter files (e.g. `*.js`, `*.{ts,tsx}`). No filter by default.\"\n        ),\n        default=None,\n    )\n    output_mode: str = Field(\n        description=(\n            \"`content`: Show matching lines (supports `-B`, `-A`, `-C`, `-n`, `head_limit`); \"\n            \"`files_with_matches`: Show file paths (supports `head_limit`); \"\n            \"`count_matches`: Show total number of matches. \"\n            \"Defaults to `files_with_matches`.\"\n        ),\n        default=\"files_with_matches\",\n    )\n    before_context: int | None = Field(\n        alias=\"-B\",\n        description=(\n            \"Number of lines to show before each match (the `-B` option). \"\n            \"Requires `output_mode` to be `content`.\"\n        ),\n        default=None,\n    )\n    after_context: int | None = Field(\n        alias=\"-A\",\n        description=(\n            \"Number of lines to show after each match (the `-A` option). \"\n            \"Requires `output_mode` to be `content`.\"\n        ),\n        default=None,\n    )\n    context: int | None = Field(\n        alias=\"-C\",\n        description=(\n            \"Number of lines to show before and after each match (the `-C` option). \"\n            \"Requires `output_mode` to be `content`.\"\n        ),\n        default=None,\n    )\n    line_number: bool = Field(\n        alias=\"-n\",\n        description=(\n            \"Show line numbers in output (the `-n` option). Requires `output_mode` to be `content`.\"\n        ),\n        default=False,\n    )\n    ignore_case: bool = Field(\n        alias=\"-i\",\n        description=\"Case insensitive search (the `-i` option).\",\n        default=False,\n    )\n    type: str | None = Field(\n        description=(\n            \"File type to search. Examples: py, rust, js, ts, go, java, etc. \"\n            \"More efficient than `glob` for standard file types.\"\n        ),\n        default=None,\n    )\n    head_limit: int | None = Field(\n        description=(\n            \"Limit output to first N lines, equivalent to `| head -N`. \"\n            \"Works across all output modes: content (limits output lines), \"\n            \"files_with_matches (limits file paths), count_matches (limits count entries). \"\n            \"By default, no limit is applied.\"\n        ),\n        default=None,\n    )\n    multiline: bool = Field(\n        description=(\n            \"Enable multiline mode where `.` matches newlines and patterns can span \"\n            \"lines (the `-U` and `--multiline-dotall` options). \"\n            \"By default, multiline mode is disabled.\"\n        ),\n        default=False,\n    )\n\n\nRG_VERSION = \"15.0.0\"\nRG_BASE_URL = \"http://cdn.kimi.com/binaries/kimi-cli/rg\"\n_RG_DOWNLOAD_LOCK = asyncio.Lock()\n\n\ndef _rg_binary_name() -> str:\n    return \"rg.exe\" if platform.system() == \"Windows\" else \"rg\"\n\n\ndef _find_existing_rg(bin_name: str) -> Path | None:\n    share_bin = get_share_dir() / \"bin\" / bin_name\n    if share_bin.is_file():\n        return share_bin\n\n    assert kimi_cli.__file__ is not None\n    local_dep = Path(kimi_cli.__file__).parent / \"deps\" / \"bin\" / bin_name\n    if local_dep.is_file():\n        return local_dep\n\n    system_rg = shutil.which(\"rg\")\n    if system_rg:\n        return Path(system_rg)\n\n    return None\n\n\ndef _detect_target() -> str | None:\n    sys_name = platform.system()\n    mach = platform.machine().lower()\n\n    if mach in (\"x86_64\", \"amd64\"):\n        arch = \"x86_64\"\n    elif mach in (\"arm64\", \"aarch64\"):\n        arch = \"aarch64\"\n    else:\n        logger.error(\"Unsupported architecture for ripgrep: {mach}\", mach=mach)\n        return None\n\n    if sys_name == \"Darwin\":\n        os_name = \"apple-darwin\"\n    elif sys_name == \"Linux\":\n        os_name = \"unknown-linux-musl\" if arch == \"x86_64\" else \"unknown-linux-gnu\"\n    elif sys_name == \"Windows\":\n        os_name = \"pc-windows-msvc\"\n    else:\n        logger.error(\"Unsupported operating system for ripgrep: {sys_name}\", sys_name=sys_name)\n        return None\n\n    return f\"{arch}-{os_name}\"\n\n\nasync def _download_and_install_rg(bin_name: str) -> Path:\n    target = _detect_target()\n    if not target:\n        raise RuntimeError(\"Unsupported platform for ripgrep download\")\n\n    is_windows = \"windows\" in target\n    archive_ext = \"zip\" if is_windows else \"tar.gz\"\n    filename = f\"ripgrep-{RG_VERSION}-{target}.{archive_ext}\"\n    url = f\"{RG_BASE_URL}/{filename}\"\n    logger.info(\"Downloading ripgrep from {url}\", url=url)\n\n    share_bin_dir = get_share_dir() / \"bin\"\n    share_bin_dir.mkdir(parents=True, exist_ok=True)\n    destination = share_bin_dir / bin_name\n\n    async with new_client_session() as session:\n        with tempfile.TemporaryDirectory(prefix=\"kimi-rg-\") as tmpdir:\n            tar_path = Path(tmpdir) / filename\n\n            try:\n                async with session.get(url) as resp:\n                    resp.raise_for_status()\n                    with open(tar_path, \"wb\") as fh:\n                        async for chunk in resp.content.iter_chunked(1024 * 64):\n                            if chunk:\n                                fh.write(chunk)\n            except (aiohttp.ClientError, TimeoutError) as exc:\n                raise RuntimeError(\"Failed to download ripgrep binary\") from exc\n\n            try:\n                if is_windows:\n                    with zipfile.ZipFile(tar_path, \"r\") as zf:\n                        member_name = next(\n                            (name for name in zf.namelist() if Path(name).name == bin_name),\n                            None,\n                        )\n                        if not member_name:\n                            raise RuntimeError(\"Ripgrep binary not found in archive\")\n                        with zf.open(member_name) as source, open(destination, \"wb\") as dest_fh:\n                            shutil.copyfileobj(source, dest_fh)\n                else:\n                    with tarfile.open(tar_path, \"r:gz\") as tar:\n                        member = next(\n                            (m for m in tar.getmembers() if Path(m.name).name == bin_name),\n                            None,\n                        )\n                        if not member:\n                            raise RuntimeError(\"Ripgrep binary not found in archive\")\n                        extracted = tar.extractfile(member)\n                        if not extracted:\n                            raise RuntimeError(\"Failed to extract ripgrep binary\")\n                        with open(destination, \"wb\") as dest_fh:\n                            shutil.copyfileobj(extracted, dest_fh)\n            except (zipfile.BadZipFile, tarfile.TarError, OSError) as exc:\n                raise RuntimeError(\"Failed to extract ripgrep archive\") from exc\n\n    destination.chmod(destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)\n    logger.info(\"Installed ripgrep to {destination}\", destination=destination)\n    return destination\n\n\nasync def _ensure_rg_path() -> str:\n    bin_name = _rg_binary_name()\n    existing = _find_existing_rg(bin_name)\n    if existing:\n        return str(existing)\n\n    async with _RG_DOWNLOAD_LOCK:\n        existing = _find_existing_rg(bin_name)\n        if existing:\n            return str(existing)\n\n        downloaded = await _download_and_install_rg(bin_name)\n        return str(downloaded)\n\n\nclass Grep(CallableTool2[Params]):\n    name: str = \"Grep\"\n    description: str = load_desc(Path(__file__).parent / \"grep.md\")\n    params: type[Params] = Params\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        try:\n            builder = ToolResultBuilder()\n            message = \"\"\n\n            # Initialize ripgrep with pattern and path\n            rg_path = await _ensure_rg_path()\n            logger.debug(\"Using ripgrep binary: {rg_bin}\", rg_bin=rg_path)\n            rg = ripgrepy.Ripgrepy(params.pattern, params.path, rg_path=rg_path)\n\n            # Apply search options\n            if params.ignore_case:\n                rg = rg.ignore_case()\n            if params.multiline:\n                rg = rg.multiline().multiline_dotall()\n\n            # Content display options (only for content mode)\n            if params.output_mode == \"content\":\n                if params.before_context is not None:\n                    rg = rg.before_context(params.before_context)\n                if params.after_context is not None:\n                    rg = rg.after_context(params.after_context)\n                if params.context is not None:\n                    rg = rg.context(params.context)\n                if params.line_number:\n                    rg = rg.line_number()\n\n            # File filtering options\n            if params.glob:\n                rg = rg.glob(params.glob)\n            if params.type:\n                rg = rg.type_(params.type)\n\n            # Set output mode\n            if params.output_mode == \"files_with_matches\":\n                rg = rg.files_with_matches()\n            elif params.output_mode == \"count_matches\":\n                rg = rg.count_matches()\n\n            # Execute search\n            result = rg.run(universal_newlines=False)\n\n            # Get results\n            output = result.as_string\n\n            # Apply head limit if specified\n            if params.head_limit is not None:\n                lines = output.split(\"\\n\")\n                if len(lines) > params.head_limit:\n                    lines = lines[: params.head_limit]\n                    output = \"\\n\".join(lines)\n                    message = f\"Results truncated to first {params.head_limit} lines\"\n                    if params.output_mode in [\"content\", \"files_with_matches\", \"count_matches\"]:\n                        output += f\"\\n... (results truncated to {params.head_limit} lines)\"\n\n            if not output:\n                return builder.ok(message=\"No matches found\")\n\n            builder.write(output)\n            return builder.ok(message=message)\n\n        except Exception as e:\n            return ToolError(\n                message=f\"Failed to grep. Error: {str(e)}\",\n                brief=\"Failed to grep\",\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/file/plan_mode.py",
    "content": "from __future__ import annotations\n\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import ToolError\n\n\n@dataclass(frozen=True)\nclass PlanEditTarget:\n    active: bool\n    plan_path: Path | None\n    is_plan_target: bool\n\n\ndef inspect_plan_edit_target(\n    path: KaosPath,\n    *,\n    plan_mode_checker: Callable[[], bool] | None,\n    plan_file_path_getter: Callable[[], Path | None] | None,\n) -> PlanEditTarget | ToolError:\n    \"\"\"Resolve whether a file edit is targeting the current plan artifact.\"\"\"\n    if plan_mode_checker is None or not plan_mode_checker():\n        return PlanEditTarget(active=False, plan_path=None, is_plan_target=False)\n\n    plan_path = plan_file_path_getter() if plan_file_path_getter is not None else None\n    if plan_path is None:\n        return ToolError(\n            message=\"Plan mode is active, but the current plan file is unavailable.\",\n            brief=\"Plan file unavailable\",\n        )\n\n    canonical_plan_path = KaosPath(str(plan_path)).canonical()\n    if str(path) != str(canonical_plan_path):\n        return ToolError(\n            message=(\n                \"Plan mode is active. You may only edit the current plan file: \"\n                f\"`{canonical_plan_path}`.\"\n            ),\n            brief=\"Plan mode restriction\",\n        )\n\n    return PlanEditTarget(active=True, plan_path=plan_path, is_plan_target=True)\n"
  },
  {
    "path": "src/kimi_cli/tools/file/read.md",
    "content": "Read text content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read text files. To read images or videos, use other appropriate tools. To list directories, use the Glob tool or `ls` command via the Shell tool. To read other file types, use appropriate commands via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- If you want to search for a certain content/pattern, prefer Grep tool over ReadFile.\n- Content will be returned with a line number before each line like `cat -n` format.\n- Use `line_offset` and `n_lines` parameters when you only need to read a part of the file.\n- The maximum number of lines that can be read at once is ${MAX_LINES}.\n- Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated, ending with \"...\".\n"
  },
  {
    "path": "src/kimi_cli/tools/file/read.py",
    "content": "from pathlib import Path\nfrom typing import override\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, detect_file_type\nfrom kimi_cli.tools.utils import load_desc, truncate_line\nfrom kimi_cli.utils.path import is_within_workspace\n\nMAX_LINES = 1000\nMAX_LINE_LENGTH = 2000\nMAX_BYTES = 100 << 10  # 100KB\n\n\nclass Params(BaseModel):\n    path: str = Field(\n        description=(\n            \"The path to the file to read. Absolute paths are required when reading files \"\n            \"outside the working directory.\"\n        )\n    )\n    line_offset: int = Field(\n        description=(\n            \"The line number to start reading from. \"\n            \"By default read from the beginning of the file. \"\n            \"Set this when the file is too large to read at once.\"\n        ),\n        default=1,\n        ge=1,\n    )\n    n_lines: int = Field(\n        description=(\n            \"The number of lines to read. \"\n            f\"By default read up to {MAX_LINES} lines, which is the max allowed value. \"\n            \"Set this value when the file is too large to read at once.\"\n        ),\n        default=MAX_LINES,\n        ge=1,\n    )\n\n\nclass ReadFile(CallableTool2[Params]):\n    name: str = \"ReadFile\"\n    params: type[Params] = Params\n\n    def __init__(self, runtime: Runtime) -> None:\n        description = load_desc(\n            Path(__file__).parent / \"read.md\",\n            {\n                \"MAX_LINES\": MAX_LINES,\n                \"MAX_LINE_LENGTH\": MAX_LINE_LENGTH,\n                \"MAX_BYTES\": MAX_BYTES,\n            },\n        )\n        super().__init__(description=description)\n        self._runtime = runtime\n        self._work_dir = runtime.builtin_args.KIMI_WORK_DIR\n        self._additional_dirs = runtime.additional_dirs\n\n    async def _validate_path(self, path: KaosPath) -> ToolError | None:\n        \"\"\"Validate that the path is safe to read.\"\"\"\n        resolved_path = path.canonical()\n\n        if (\n            not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)\n            and not path.is_absolute()\n        ):\n            # Outside files can only be read with absolute paths\n            return ToolError(\n                message=(\n                    f\"`{path}` is not an absolute path. \"\n                    \"You must provide an absolute path to read a file \"\n                    \"outside the working directory.\"\n                ),\n                brief=\"Invalid path\",\n            )\n        return None\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        # TODO: checks:\n        # - check if the path may contain secrets\n\n        if not params.path:\n            return ToolError(\n                message=\"File path cannot be empty.\",\n                brief=\"Empty file path\",\n            )\n\n        try:\n            p = KaosPath(params.path).expanduser()\n            if err := await self._validate_path(p):\n                return err\n            p = p.canonical()\n\n            if not await p.exists():\n                return ToolError(\n                    message=f\"`{params.path}` does not exist.\",\n                    brief=\"File not found\",\n                )\n            if not await p.is_file():\n                return ToolError(\n                    message=f\"`{params.path}` is not a file.\",\n                    brief=\"Invalid path\",\n                )\n\n            header = await p.read_bytes(MEDIA_SNIFF_BYTES)\n            file_type = detect_file_type(str(p), header=header)\n            if file_type.kind in (\"image\", \"video\"):\n                return ToolError(\n                    message=(\n                        f\"`{params.path}` is a {file_type.kind} file. \"\n                        \"Use other appropriate tools to read image or video files.\"\n                    ),\n                    brief=\"Unsupported file type\",\n                )\n\n            if file_type.kind == \"unknown\":\n                return ToolError(\n                    message=(\n                        f\"`{params.path}` seems not readable. \"\n                        \"You may need to read it with proper shell commands, Python tools \"\n                        \"or MCP tools if available. \"\n                        \"If you read/operate it with Python, you MUST ensure that any \"\n                        \"third-party packages are installed in a virtual environment (venv).\"\n                    ),\n                    brief=\"File not readable\",\n                )\n\n            assert params.line_offset >= 1\n            assert params.n_lines >= 1\n\n            lines: list[str] = []\n            n_bytes = 0\n            truncated_line_numbers: list[int] = []\n            max_lines_reached = False\n            max_bytes_reached = False\n            current_line_no = 0\n            async for line in p.read_lines(errors=\"replace\"):\n                current_line_no += 1\n                if current_line_no < params.line_offset:\n                    continue\n                truncated = truncate_line(line, MAX_LINE_LENGTH)\n                if truncated != line:\n                    truncated_line_numbers.append(current_line_no)\n                lines.append(truncated)\n                n_bytes += len(truncated.encode(\"utf-8\"))\n                if len(lines) >= params.n_lines:\n                    break\n                if len(lines) >= MAX_LINES:\n                    max_lines_reached = True\n                    break\n                if n_bytes >= MAX_BYTES:\n                    max_bytes_reached = True\n                    break\n\n            # Format output with line numbers like `cat -n`\n            lines_with_no: list[str] = []\n            for line_num, line in zip(\n                range(params.line_offset, params.line_offset + len(lines)), lines, strict=True\n            ):\n                # Use 6-digit line number width, right-aligned, with tab separator\n                lines_with_no.append(f\"{line_num:6d}\\t{line}\")\n\n            message = (\n                f\"{len(lines)} lines read from file starting from line {params.line_offset}.\"\n                if len(lines) > 0\n                else \"No lines read from file.\"\n            )\n            if max_lines_reached:\n                message += f\" Max {MAX_LINES} lines reached.\"\n            elif max_bytes_reached:\n                message += f\" Max {MAX_BYTES} bytes reached.\"\n            elif len(lines) < params.n_lines:\n                message += \" End of file reached.\"\n            if truncated_line_numbers:\n                message += f\" Lines {truncated_line_numbers} were truncated.\"\n            return ToolOk(\n                output=\"\".join(lines_with_no),  # lines already contain \\n, just join them\n                message=message,\n            )\n        except Exception as e:\n            return ToolError(\n                message=f\"Failed to read {params.path}. Error: {e}\",\n                brief=\"Failed to read file\",\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/file/read_media.md",
    "content": "Read media content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read image or video files. To read other types of files, use the ReadFile tool. To list directories, use the Glob tool or `ls` command via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- The maximum size that can be read is ${MAX_MEDIA_MEGABYTES}MB. An error will be returned if the file is larger than this limit.\n- The media content will be returned in a form that you can directly view and understand.\n\n**Capabilities**\n{% if \"image_in\" in capabilities and \"video_in\" in capabilities %}\n- This tool supports image and video files for the current model.\n{% elif \"image_in\" in capabilities %}\n- This tool supports image files for the current model.\n- Video files are not supported by the current model.\n{% elif \"video_in\" in capabilities %}\n- This tool supports video files for the current model.\n- Image files are not supported by the current model.\n{% else %}\n- The current model does not support image or video input.\n{% endif %}\n"
  },
  {
    "path": "src/kimi_cli/tools/file/read_media.py",
    "content": "import base64\nfrom io import BytesIO\nfrom pathlib import Path\nfrom typing import override\n\nfrom kaos.path import KaosPath\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.tools import SkipThisTool\nfrom kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, FileType, detect_file_type\nfrom kimi_cli.tools.utils import load_desc\nfrom kimi_cli.utils.media_tags import wrap_media_part\nfrom kimi_cli.utils.path import is_within_workspace\nfrom kimi_cli.wire.types import ImageURLPart, VideoURLPart\n\nMAX_MEDIA_MEGABYTES = 100\n\n\ndef _to_data_url(mime_type: str, data: bytes) -> str:\n    encoded = base64.b64encode(data).decode(\"ascii\")\n    return f\"data:{mime_type};base64,{encoded}\"\n\n\ndef _extract_image_size(data: bytes) -> tuple[int, int] | None:\n    try:\n        from PIL import Image\n    except Exception:\n        return None\n    try:\n        with Image.open(BytesIO(data)) as image:\n            image.load()\n            return image.size\n    except Exception:\n        return None\n\n\nclass Params(BaseModel):\n    path: str = Field(\n        description=(\n            \"The path to the file to read. Absolute paths are required when reading files \"\n            \"outside the working directory.\"\n        )\n    )\n\n\nclass ReadMediaFile(CallableTool2[Params]):\n    name: str = \"ReadMediaFile\"\n    params: type[Params] = Params\n\n    def __init__(self, runtime: Runtime) -> None:\n        capabilities = runtime.llm.capabilities if runtime.llm else set[str]()\n        if \"image_in\" not in capabilities and \"video_in\" not in capabilities:\n            raise SkipThisTool()\n\n        description = load_desc(\n            Path(__file__).parent / \"read_media.md\",\n            {\n                \"MAX_MEDIA_MEGABYTES\": MAX_MEDIA_MEGABYTES,\n                \"capabilities\": capabilities,\n            },\n        )\n        super().__init__(description=description)\n\n        self._runtime = runtime\n        self._work_dir = runtime.builtin_args.KIMI_WORK_DIR\n        self._additional_dirs = runtime.additional_dirs\n        self._capabilities = capabilities\n\n    async def _validate_path(self, path: KaosPath) -> ToolError | None:\n        \"\"\"Validate that the path is safe to read.\"\"\"\n        resolved_path = path.canonical()\n\n        if (\n            not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)\n            and not path.is_absolute()\n        ):\n            # Outside files can only be read with absolute paths\n            return ToolError(\n                message=(\n                    f\"`{path}` is not an absolute path. \"\n                    \"You must provide an absolute path to read a file \"\n                    \"outside the working directory.\"\n                ),\n                brief=\"Invalid path\",\n            )\n        return None\n\n    async def _read_media(self, path: KaosPath, file_type: FileType) -> ToolReturnValue:\n        assert file_type.kind in (\"image\", \"video\")\n\n        media_path = str(path)\n        stat = await path.stat()\n        size = stat.st_size\n        if size == 0:\n            return ToolError(\n                message=f\"`{path}` is empty.\",\n                brief=\"Empty file\",\n            )\n        if size > (MAX_MEDIA_MEGABYTES << 20):\n            return ToolError(\n                message=(\n                    f\"`{path}` is {size} bytes, which exceeds the max \"\n                    f\"{MAX_MEDIA_MEGABYTES}MB bytes for media files.\"\n                ),\n                brief=\"File too large\",\n            )\n\n        match file_type.kind:\n            case \"image\":\n                data = await path.read_bytes()\n                data_url = _to_data_url(file_type.mime_type, data)\n                part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=data_url))\n                wrapped = wrap_media_part(part, tag=\"image\", attrs={\"path\": media_path})\n                image_size = _extract_image_size(data)\n            case \"video\":\n                data = await path.read_bytes()\n                if (llm := self._runtime.llm) and isinstance(llm.chat_provider, Kimi):\n                    part = await llm.chat_provider.files.upload_video(\n                        data=data,\n                        mime_type=file_type.mime_type,\n                    )\n                    wrapped = wrap_media_part(part, tag=\"video\", attrs={\"path\": media_path})\n                else:\n                    data_url = _to_data_url(file_type.mime_type, data)\n                    part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=data_url))\n                    wrapped = wrap_media_part(part, tag=\"video\", attrs={\"path\": media_path})\n                image_size = None\n\n        size_hint = \"\"\n        if image_size:\n            size_hint = f\", original size {image_size[0]}x{image_size[1]}px\"\n        note = (\n            \" If you need to output coordinates, output relative coordinates first and \"\n            \"compute absolute coordinates using the original image size; if you generate or \"\n            \"edit images/videos via commands or scripts, read the result back immediately \"\n            \"before continuing.\"\n        )\n        return ToolOk(\n            output=wrapped,\n            message=(\n                f\"Loaded {file_type.kind} file `{path}` \"\n                f\"({file_type.mime_type}, {size} bytes{size_hint}).{note}\"\n            ),\n        )\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        if not params.path:\n            return ToolError(\n                message=\"File path cannot be empty.\",\n                brief=\"Empty file path\",\n            )\n\n        try:\n            p = KaosPath(params.path).expanduser()\n            if err := await self._validate_path(p):\n                return err\n            p = p.canonical()\n\n            if not await p.exists():\n                return ToolError(\n                    message=f\"`{params.path}` does not exist.\",\n                    brief=\"File not found\",\n                )\n            if not await p.is_file():\n                return ToolError(\n                    message=f\"`{params.path}` is not a file.\",\n                    brief=\"Invalid path\",\n                )\n\n            header = await p.read_bytes(MEDIA_SNIFF_BYTES)\n            file_type = detect_file_type(str(p), header=header)\n            if file_type.kind == \"text\":\n                return ToolError(\n                    message=f\"`{params.path}` is a text file. Use ReadFile to read text files.\",\n                    brief=\"Unsupported file type\",\n                )\n            if file_type.kind == \"unknown\":\n                return ToolError(\n                    message=(\n                        f\"`{params.path}` seems not readable as an image or video file. \"\n                        \"You may need to read it with proper shell commands, Python tools \"\n                        \"or MCP tools if available. \"\n                        \"If you read/operate it with Python, you MUST ensure that any \"\n                        \"third-party packages are installed in a virtual environment (venv).\"\n                    ),\n                    brief=\"File not readable\",\n                )\n\n            if file_type.kind == \"image\" and \"image_in\" not in self._capabilities:\n                return ToolError(\n                    message=(\n                        \"The current model does not support image input. \"\n                        \"Tell the user to use a model with image input capability.\"\n                    ),\n                    brief=\"Unsupported media type\",\n                )\n            if file_type.kind == \"video\" and \"video_in\" not in self._capabilities:\n                return ToolError(\n                    message=(\n                        \"The current model does not support video input. \"\n                        \"Tell the user to use a model with video input capability.\"\n                    ),\n                    brief=\"Unsupported media type\",\n                )\n\n            return await self._read_media(p, file_type)\n        except Exception as e:\n            return ToolError(\n                message=f\"Failed to read {params.path}. Error: {e}\",\n                brief=\"Failed to read file\",\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/file/replace.md",
    "content": "Replace specific strings within a specified file.\n\n**Tips:**\n- Only use this tool on text files.\n- Multi-line strings are supported.\n- Can specify a single edit or a list of edits in one call.\n- You should prefer this tool over WriteFile tool and Shell `sed` command.\n"
  },
  {
    "path": "src/kimi_cli/tools/file/replace.py",
    "content": "from collections.abc import Callable\nfrom pathlib import Path\nfrom typing import override\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import CallableTool2, ToolError, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.tools.display import DisplayBlock\nfrom kimi_cli.tools.file import FileActions\nfrom kimi_cli.tools.file.plan_mode import inspect_plan_edit_target\nfrom kimi_cli.tools.utils import ToolRejectedError, load_desc\nfrom kimi_cli.utils.diff import build_diff_blocks\nfrom kimi_cli.utils.path import is_within_workspace\n\n_BASE_DESCRIPTION = load_desc(Path(__file__).parent / \"replace.md\")\n\n\nclass Edit(BaseModel):\n    old: str = Field(description=\"The old string to replace. Can be multi-line.\")\n    new: str = Field(description=\"The new string to replace with. Can be multi-line.\")\n    replace_all: bool = Field(description=\"Whether to replace all occurrences.\", default=False)\n\n\nclass Params(BaseModel):\n    path: str = Field(\n        description=(\n            \"The path to the file to edit. Absolute paths are required when editing files \"\n            \"outside the working directory.\"\n        )\n    )\n    edit: Edit | list[Edit] = Field(\n        description=(\n            \"The edit(s) to apply to the file. \"\n            \"You can provide a single edit or a list of edits here.\"\n        )\n    )\n\n\nclass StrReplaceFile(CallableTool2[Params]):\n    name: str = \"StrReplaceFile\"\n    description: str = _BASE_DESCRIPTION\n    params: type[Params] = Params\n\n    def __init__(self, runtime: Runtime, approval: Approval):\n        super().__init__()\n        self._work_dir = runtime.builtin_args.KIMI_WORK_DIR\n        self._additional_dirs = runtime.additional_dirs\n        self._approval = approval\n        self._plan_mode_checker: Callable[[], bool] | None = None\n        self._plan_file_path_getter: Callable[[], Path | None] | None = None\n\n    def bind_plan_mode(\n        self, checker: Callable[[], bool], path_getter: Callable[[], Path | None]\n    ) -> None:\n        \"\"\"Bind plan mode state checker and plan file path getter.\"\"\"\n        self._plan_mode_checker = checker\n        self._plan_file_path_getter = path_getter\n\n    async def _validate_path(self, path: KaosPath) -> ToolError | None:\n        \"\"\"Validate that the path is safe to edit.\"\"\"\n        resolved_path = path.canonical()\n\n        if (\n            not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)\n            and not path.is_absolute()\n        ):\n            return ToolError(\n                message=(\n                    f\"`{path}` is not an absolute path. \"\n                    \"You must provide an absolute path to edit a file \"\n                    \"outside the working directory.\"\n                ),\n                brief=\"Invalid path\",\n            )\n        return None\n\n    def _apply_edit(self, content: str, edit: Edit) -> str:\n        \"\"\"Apply a single edit to the content.\"\"\"\n        if edit.replace_all:\n            return content.replace(edit.old, edit.new)\n        else:\n            return content.replace(edit.old, edit.new, 1)\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        if not params.path:\n            return ToolError(\n                message=\"File path cannot be empty.\",\n                brief=\"Empty file path\",\n            )\n\n        try:\n            p = KaosPath(params.path).expanduser()\n            if err := await self._validate_path(p):\n                return err\n            p = p.canonical()\n\n            plan_target = inspect_plan_edit_target(\n                p,\n                plan_mode_checker=self._plan_mode_checker,\n                plan_file_path_getter=self._plan_file_path_getter,\n            )\n            if isinstance(plan_target, ToolError):\n                return plan_target\n\n            is_plan_file_edit = plan_target.is_plan_target\n\n            if not await p.exists():\n                if is_plan_file_edit:\n                    return ToolError(\n                        message=(\n                            \"The current plan file does not exist yet. \"\n                            \"Use WriteFile to create it before calling StrReplaceFile.\"\n                        ),\n                        brief=\"Plan file not created\",\n                    )\n                return ToolError(\n                    message=f\"`{params.path}` does not exist.\",\n                    brief=\"File not found\",\n                )\n            if not await p.is_file():\n                return ToolError(\n                    message=f\"`{params.path}` is not a file.\",\n                    brief=\"Invalid path\",\n                )\n\n            # Read the file content\n            content = await p.read_text(errors=\"replace\")\n\n            original_content = content\n            edits = [params.edit] if isinstance(params.edit, Edit) else params.edit\n\n            # Apply all edits\n            for edit in edits:\n                content = self._apply_edit(content, edit)\n\n            # Check if any changes were made\n            if content == original_content:\n                return ToolError(\n                    message=\"No replacements were made. The old string was not found in the file.\",\n                    brief=\"No replacements made\",\n                )\n\n            diff_blocks: list[DisplayBlock] = list(\n                build_diff_blocks(str(p), original_content, content)\n            )\n\n            action = (\n                FileActions.EDIT\n                if is_within_workspace(p, self._work_dir, self._additional_dirs)\n                else FileActions.EDIT_OUTSIDE\n            )\n\n            # Plan file edits are auto-approved; all other edits need approval.\n            if not is_plan_file_edit and not await self._approval.request(\n                self.name,\n                action,\n                f\"Edit file `{p}`\",\n                display=diff_blocks,\n            ):\n                return ToolRejectedError()\n\n            # Write the modified content back to the file\n            await p.write_text(content, errors=\"replace\")\n\n            # Count changes for success message\n            total_replacements = 0\n            for edit in edits:\n                if edit.replace_all:\n                    total_replacements += original_content.count(edit.old)\n                else:\n                    total_replacements += 1 if edit.old in original_content else 0\n\n            return ToolReturnValue(\n                is_error=False,\n                output=\"\",\n                message=(\n                    f\"File successfully edited. \"\n                    f\"Applied {len(edits)} edit(s) with {total_replacements} total replacement(s).\"\n                ),\n                display=diff_blocks,\n            )\n\n        except Exception as e:\n            return ToolError(\n                message=f\"Failed to edit. Error: {e}\",\n                brief=\"Failed to edit file\",\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/file/utils.py",
    "content": "from __future__ import annotations\n\nimport mimetypes\nfrom dataclasses import dataclass\nfrom pathlib import PurePath\nfrom typing import Literal\n\nMEDIA_SNIFF_BYTES = 512\n\n_EXTRA_MIME_TYPES = {\n    \".avif\": \"image/avif\",\n    \".heic\": \"image/heic\",\n    \".heif\": \"image/heif\",\n    \".mkv\": \"video/x-matroska\",\n    \".m4v\": \"video/x-m4v\",\n    \".3gp\": \"video/3gpp\",\n    \".3g2\": \"video/3gpp2\",\n    # TypeScript files: override mimetypes default (video/mp2t for MPEG Transport Stream)\n    \".ts\": \"text/typescript\",\n    \".tsx\": \"text/typescript\",\n    \".mts\": \"text/typescript\",\n    \".cts\": \"text/typescript\",\n}\n\nfor suffix, mime_type in _EXTRA_MIME_TYPES.items():\n    mimetypes.add_type(mime_type, suffix)\n\n_IMAGE_MIME_BY_SUFFIX = {\n    \".png\": \"image/png\",\n    \".jpg\": \"image/jpeg\",\n    \".jpeg\": \"image/jpeg\",\n    \".gif\": \"image/gif\",\n    \".bmp\": \"image/bmp\",\n    \".tif\": \"image/tiff\",\n    \".tiff\": \"image/tiff\",\n    \".webp\": \"image/webp\",\n    \".ico\": \"image/x-icon\",\n    \".heic\": \"image/heic\",\n    \".heif\": \"image/heif\",\n    \".avif\": \"image/avif\",\n    \".svgz\": \"image/svg+xml\",\n}\n_VIDEO_MIME_BY_SUFFIX = {\n    \".mp4\": \"video/mp4\",\n    \".mkv\": \"video/x-matroska\",\n    \".avi\": \"video/x-msvideo\",\n    \".mov\": \"video/quicktime\",\n    \".wmv\": \"video/x-ms-wmv\",\n    \".webm\": \"video/webm\",\n    \".m4v\": \"video/x-m4v\",\n    \".flv\": \"video/x-flv\",\n    \".3gp\": \"video/3gpp\",\n    \".3g2\": \"video/3gpp2\",\n}\n_TEXT_MIME_BY_SUFFIX = {\n    \".svg\": \"image/svg+xml\",\n}\n\n_ASF_HEADER = b\"\\x30\\x26\\xb2\\x75\\x8e\\x66\\xcf\\x11\\xa6\\xd9\\x00\\xaa\\x00\\x62\\xce\\x6c\"\n_FTYP_IMAGE_BRANDS = {\n    \"avif\": \"image/avif\",\n    \"avis\": \"image/avif\",\n    \"heic\": \"image/heic\",\n    \"heif\": \"image/heif\",\n    \"heix\": \"image/heif\",\n    \"hevc\": \"image/heic\",\n    \"mif1\": \"image/heif\",\n    \"msf1\": \"image/heif\",\n}\n_FTYP_VIDEO_BRANDS = {\n    \"isom\": \"video/mp4\",\n    \"iso2\": \"video/mp4\",\n    \"iso5\": \"video/mp4\",\n    \"mp41\": \"video/mp4\",\n    \"mp42\": \"video/mp4\",\n    \"avc1\": \"video/mp4\",\n    \"mp4v\": \"video/mp4\",\n    \"m4v\": \"video/x-m4v\",\n    \"qt\": \"video/quicktime\",\n    \"3gp4\": \"video/3gpp\",\n    \"3gp5\": \"video/3gpp\",\n    \"3gp6\": \"video/3gpp\",\n    \"3gp7\": \"video/3gpp\",\n    \"3g2\": \"video/3gpp2\",\n}\n\n_NON_TEXT_SUFFIXES = {\n    \".icns\",\n    \".psd\",\n    \".ai\",\n    \".eps\",\n    # Documents / office formats\n    \".pdf\",\n    \".doc\",\n    \".docx\",\n    \".dot\",\n    \".dotx\",\n    \".rtf\",\n    \".odt\",\n    \".xls\",\n    \".xlsx\",\n    \".xlsm\",\n    \".xlt\",\n    \".xltx\",\n    \".xltm\",\n    \".ods\",\n    \".ppt\",\n    \".pptx\",\n    \".pptm\",\n    \".pps\",\n    \".ppsx\",\n    \".odp\",\n    \".pages\",\n    \".numbers\",\n    \".key\",\n    # Archives / compressed\n    \".zip\",\n    \".rar\",\n    \".7z\",\n    \".tar\",\n    \".gz\",\n    \".tgz\",\n    \".bz2\",\n    \".xz\",\n    \".zst\",\n    \".lz\",\n    \".lz4\",\n    \".br\",\n    \".cab\",\n    \".ar\",\n    \".deb\",\n    \".rpm\",\n    # Audio\n    \".mp3\",\n    \".wav\",\n    \".flac\",\n    \".ogg\",\n    \".oga\",\n    \".opus\",\n    \".aac\",\n    \".m4a\",\n    \".wma\",\n    # Fonts\n    \".ttf\",\n    \".otf\",\n    \".woff\",\n    \".woff2\",\n    # Binaries / bundles\n    \".exe\",\n    \".dll\",\n    \".so\",\n    \".dylib\",\n    \".bin\",\n    \".apk\",\n    \".ipa\",\n    \".jar\",\n    \".class\",\n    \".pyc\",\n    \".pyo\",\n    \".wasm\",\n    # Disk images / databases\n    \".dmg\",\n    \".iso\",\n    \".img\",\n    \".sqlite\",\n    \".sqlite3\",\n    \".db\",\n    \".db3\",\n}\n\n\n@dataclass(frozen=True)\nclass FileType:\n    kind: Literal[\"text\", \"image\", \"video\", \"unknown\"]\n    mime_type: str\n\n\ndef _sniff_ftyp_brand(header: bytes) -> str | None:\n    if len(header) < 12 or header[4:8] != b\"ftyp\":\n        return None\n    brand = header[8:12].decode(\"ascii\", errors=\"ignore\").lower()\n    return brand.strip()\n\n\ndef sniff_media_from_magic(data: bytes) -> FileType | None:\n    header = data[:MEDIA_SNIFF_BYTES]\n    if header.startswith(b\"\\x89PNG\\r\\n\\x1a\\n\"):\n        return FileType(kind=\"image\", mime_type=\"image/png\")\n    if header.startswith(b\"\\xff\\xd8\\xff\"):\n        return FileType(kind=\"image\", mime_type=\"image/jpeg\")\n    if header.startswith((b\"GIF87a\", b\"GIF89a\")):\n        return FileType(kind=\"image\", mime_type=\"image/gif\")\n    if header.startswith(b\"BM\"):\n        return FileType(kind=\"image\", mime_type=\"image/bmp\")\n    if header.startswith((b\"II*\\x00\", b\"MM\\x00*\")):\n        return FileType(kind=\"image\", mime_type=\"image/tiff\")\n    if header.startswith(b\"\\x00\\x00\\x01\\x00\"):\n        return FileType(kind=\"image\", mime_type=\"image/x-icon\")\n    if header.startswith(b\"RIFF\") and len(header) >= 12:\n        chunk = header[8:12]\n        if chunk == b\"WEBP\":\n            return FileType(kind=\"image\", mime_type=\"image/webp\")\n        if chunk == b\"AVI \":\n            return FileType(kind=\"video\", mime_type=\"video/x-msvideo\")\n    if header.startswith(b\"FLV\"):\n        return FileType(kind=\"video\", mime_type=\"video/x-flv\")\n    if header.startswith(_ASF_HEADER):\n        return FileType(kind=\"video\", mime_type=\"video/x-ms-wmv\")\n    if header.startswith(b\"\\x1a\\x45\\xdf\\xa3\"):\n        lowered = header.lower()\n        if b\"webm\" in lowered:\n            return FileType(kind=\"video\", mime_type=\"video/webm\")\n        if b\"matroska\" in lowered:\n            return FileType(kind=\"video\", mime_type=\"video/x-matroska\")\n    if brand := _sniff_ftyp_brand(header):\n        if brand in _FTYP_IMAGE_BRANDS:\n            return FileType(kind=\"image\", mime_type=_FTYP_IMAGE_BRANDS[brand])\n        if brand in _FTYP_VIDEO_BRANDS:\n            return FileType(kind=\"video\", mime_type=_FTYP_VIDEO_BRANDS[brand])\n    return None\n\n\ndef detect_file_type(path: str | PurePath, header: bytes | None = None) -> FileType:\n    suffix = PurePath(str(path)).suffix.lower()\n    media_hint: FileType | None = None\n    if suffix in _TEXT_MIME_BY_SUFFIX:\n        media_hint = FileType(kind=\"text\", mime_type=_TEXT_MIME_BY_SUFFIX[suffix])\n    elif suffix in _IMAGE_MIME_BY_SUFFIX:\n        media_hint = FileType(kind=\"image\", mime_type=_IMAGE_MIME_BY_SUFFIX[suffix])\n    elif suffix in _VIDEO_MIME_BY_SUFFIX:\n        media_hint = FileType(kind=\"video\", mime_type=_VIDEO_MIME_BY_SUFFIX[suffix])\n    else:\n        mime_type, _ = mimetypes.guess_type(str(path))\n        if mime_type:\n            if mime_type.startswith(\"image/\"):\n                media_hint = FileType(kind=\"image\", mime_type=mime_type)\n            elif mime_type.startswith(\"video/\"):\n                media_hint = FileType(kind=\"video\", mime_type=mime_type)\n\n    if media_hint and media_hint.kind in (\"image\", \"video\"):\n        return media_hint\n\n    if header is not None:\n        sniffed = sniff_media_from_magic(header)\n        if sniffed:\n            if media_hint and sniffed.kind != media_hint.kind:\n                return FileType(kind=\"unknown\", mime_type=\"\")\n            return sniffed\n        # NUL bytes are a strong signal of binary content.\n        if b\"\\x00\" in header:\n            return FileType(kind=\"unknown\", mime_type=\"\")\n\n    if media_hint:\n        return media_hint\n    if suffix in _NON_TEXT_SUFFIXES:\n        return FileType(kind=\"unknown\", mime_type=\"\")\n    return FileType(kind=\"text\", mime_type=\"text/plain\")\n"
  },
  {
    "path": "src/kimi_cli/tools/file/write.md",
    "content": "Write content to a file.\n\n**Tips:**\n- When `mode` is not specified, it defaults to `overwrite`. Always write with caution.\n- When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use `overwrite` mode at the first time, then use `append` mode after the first write.\n"
  },
  {
    "path": "src/kimi_cli/tools/file/write.py",
    "content": "from collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Literal, override\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import CallableTool2, ToolError, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.tools.display import DisplayBlock\nfrom kimi_cli.tools.file import FileActions\nfrom kimi_cli.tools.file.plan_mode import inspect_plan_edit_target\nfrom kimi_cli.tools.utils import ToolRejectedError, load_desc\nfrom kimi_cli.utils.diff import build_diff_blocks\nfrom kimi_cli.utils.path import is_within_workspace\n\n_BASE_DESCRIPTION = load_desc(Path(__file__).parent / \"write.md\")\n\n\nclass Params(BaseModel):\n    path: str = Field(\n        description=(\n            \"The path to the file to write. Absolute paths are required when writing files \"\n            \"outside the working directory.\"\n        )\n    )\n    content: str = Field(description=\"The content to write to the file\")\n    mode: Literal[\"overwrite\", \"append\"] = Field(\n        description=(\n            \"The mode to use to write to the file. \"\n            \"Two modes are supported: `overwrite` for overwriting the whole file and \"\n            \"`append` for appending to the end of an existing file.\"\n        ),\n        default=\"overwrite\",\n    )\n\n\nclass WriteFile(CallableTool2[Params]):\n    name: str = \"WriteFile\"\n    description: str = _BASE_DESCRIPTION\n    params: type[Params] = Params\n\n    def __init__(self, runtime: Runtime, approval: Approval):\n        super().__init__()\n        self._work_dir = runtime.builtin_args.KIMI_WORK_DIR\n        self._additional_dirs = runtime.additional_dirs\n        self._approval = approval\n        self._plan_mode_checker: Callable[[], bool] | None = None\n        self._plan_file_path_getter: Callable[[], Path | None] | None = None\n\n    def bind_plan_mode(\n        self, checker: Callable[[], bool], path_getter: Callable[[], Path | None]\n    ) -> None:\n        \"\"\"Bind plan mode state checker and plan file path getter.\"\"\"\n        self._plan_mode_checker = checker\n        self._plan_file_path_getter = path_getter\n\n    async def _validate_path(self, path: KaosPath) -> ToolError | None:\n        \"\"\"Validate that the path is safe to write.\"\"\"\n        resolved_path = path.canonical()\n\n        if (\n            not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)\n            and not path.is_absolute()\n        ):\n            return ToolError(\n                message=(\n                    f\"`{path}` is not an absolute path. \"\n                    \"You must provide an absolute path to write a file \"\n                    \"outside the working directory.\"\n                ),\n                brief=\"Invalid path\",\n            )\n        return None\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        # TODO: checks:\n        # - check if the path may contain secrets\n        if not params.path:\n            return ToolError(\n                message=\"File path cannot be empty.\",\n                brief=\"Empty file path\",\n            )\n\n        try:\n            p = KaosPath(params.path).expanduser()\n\n            if err := await self._validate_path(p):\n                return err\n            p = p.canonical()\n\n            plan_target = inspect_plan_edit_target(\n                p,\n                plan_mode_checker=self._plan_mode_checker,\n                plan_file_path_getter=self._plan_file_path_getter,\n            )\n            if isinstance(plan_target, ToolError):\n                return plan_target\n\n            is_plan_file_write = plan_target.is_plan_target\n            if is_plan_file_write and plan_target.plan_path is not None:\n                plan_target.plan_path.parent.mkdir(parents=True, exist_ok=True)\n\n            if not await p.parent.exists():\n                return ToolError(\n                    message=f\"`{params.path}` parent directory does not exist.\",\n                    brief=\"Parent directory not found\",\n                )\n\n            # Validate mode parameter\n            if params.mode not in [\"overwrite\", \"append\"]:\n                return ToolError(\n                    message=(\n                        f\"Invalid write mode: `{params.mode}`. \"\n                        \"Mode must be either `overwrite` or `append`.\"\n                    ),\n                    brief=\"Invalid write mode\",\n                )\n\n            file_existed = await p.exists()\n            old_text = None\n            if file_existed:\n                old_text = await p.read_text(errors=\"replace\")\n\n            new_text = (\n                params.content if params.mode == \"overwrite\" else (old_text or \"\") + params.content\n            )\n            diff_blocks: list[DisplayBlock] = list(\n                build_diff_blocks(\n                    str(p),\n                    old_text or \"\",\n                    new_text,\n                )\n            )\n\n            # Plan file writes are auto-approved; other writes need approval\n            if not is_plan_file_write:\n                action = (\n                    FileActions.EDIT\n                    if is_within_workspace(p, self._work_dir, self._additional_dirs)\n                    else FileActions.EDIT_OUTSIDE\n                )\n\n                # Request approval\n                if not await self._approval.request(\n                    self.name,\n                    action,\n                    f\"Write file `{p}`\",\n                    display=diff_blocks,\n                ):\n                    return ToolRejectedError()\n\n            # Write content to file\n            match params.mode:\n                case \"overwrite\":\n                    await p.write_text(params.content)\n                case \"append\":\n                    await p.append_text(params.content)\n\n            # Get file info for success message\n            file_size = (await p.stat()).st_size\n            action = \"overwritten\" if params.mode == \"overwrite\" else \"appended to\"\n            return ToolReturnValue(\n                is_error=False,\n                output=\"\",\n                message=(f\"File successfully {action}. Current size: {file_size} bytes.\"),\n                display=diff_blocks,\n            )\n\n        except Exception as e:\n            return ToolError(\n                message=f\"Failed to write to {params.path}. Error: {e}\",\n                brief=\"Failed to write file\",\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/multiagent/__init__.py",
    "content": "from .create import CreateSubagent\nfrom .task import Task\n\n__all__ = [\n    \"Task\",\n    \"CreateSubagent\",\n]\n"
  },
  {
    "path": "src/kimi_cli/tools/multiagent/create.md",
    "content": "Create a custom subagent with specific system prompt and name for reuse.\n\nUsage:\n- Define specialized agents with custom roles and boundaries\n- Created agents can be referenced by name in the Task tool\n- Use this when you need a specific agent type not covered by predefined agents\n- The created agent configuration will be saved and can be used immediately\n\nExample workflow:\n1. Use CreateSubagent to define a specialized agent (e.g., 'code_reviewer')\n2. Use the Task tool with agent='code_reviewer' to launch the created agent\n"
  },
  {
    "path": "src/kimi_cli/tools/multiagent/create.py",
    "content": "from pathlib import Path\n\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.session_state import DynamicSubagentSpec\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.tools.utils import load_desc\n\n\nclass Params(BaseModel):\n    name: str = Field(\n        description=(\n            \"Unique name for this agent configuration (e.g., 'summarizer', 'code_reviewer'). \"\n            \"This name will be used to reference the agent in the Task tool.\"\n        )\n    )\n    system_prompt: str = Field(\n        description=\"System prompt defining the agent's role, capabilities, and boundaries.\"\n    )\n\n\nclass CreateSubagent(CallableTool2[Params]):\n    name: str = \"CreateSubagent\"\n    description: str = load_desc(Path(__file__).parent / \"create.md\")\n    params: type[Params] = Params\n\n    def __init__(self, toolset: KimiToolset, runtime: Runtime):\n        super().__init__()\n        self._toolset = toolset\n        self._runtime = runtime\n\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        if params.name in self._runtime.labor_market.subagents:\n            return ToolError(\n                message=f\"Subagent with name '{params.name}' already exists.\",\n                brief=\"Subagent already exists\",\n            )\n\n        subagent = Agent(\n            name=params.name,\n            system_prompt=params.system_prompt,\n            toolset=self._toolset,  # share the same toolset as the parent agent\n            runtime=self._runtime.copy_for_dynamic_subagent(),\n        )\n        self._runtime.labor_market.add_dynamic_subagent(params.name, subagent)\n\n        # Persist dynamic subagent definition\n        self._runtime.session.state.dynamic_subagents.append(\n            DynamicSubagentSpec(name=params.name, system_prompt=params.system_prompt)\n        )\n        self._runtime.session.save_state()\n\n        return ToolOk(\n            output=\"Available subagents: \" + \", \".join(self._runtime.labor_market.subagents.keys()),\n            message=f\"Subagent '{params.name}' created successfully.\",\n        )\n"
  },
  {
    "path": "src/kimi_cli/tools/multiagent/task.md",
    "content": "Spawn a subagent to perform a specific task. Subagent will be spawned with a fresh context without any history of yours.\n\n**Context Isolation**\n\nContext isolation is one of the key benefits of using subagents. By delegating tasks to subagents, you can keep your main context clean and focused on the main goal requested by the user.\n\nHere are some scenarios you may want this tool for context isolation:\n\n- You wrote some code and it did not work as expected. In this case you can spawn a subagent to fix the code, asking the subagent to return how it is fixed. This can potentially benefit because the detailed process of fixing the code may not be relevant to your main goal, and may clutter your context.\n- When you need some latest knowledge of a specific library, framework or technology to proceed with your task, you can spawn a subagent to search on the internet for the needed information and return to you the gathered relevant information, for example code examples, API references, etc. This can avoid ton of irrelevant search results in your own context.\n\nDO NOT directly forward the user prompt to Task tool. DO NOT simply spawn Task tool for each todo item. This will cause the user confused because the user cannot see what the subagent do. Only you can see the response from the subagent. So, only spawn subagents for very specific and narrow tasks like fixing a compilation error, or searching for a specific solution.\n\n**Parallel Multi-Tasking**\n\nParallel multi-tasking is another key benefit of this tool. When the user request involves multiple subtasks that are independent of each other, you can use Task tool multiple times in a single response to let subagents work in parallel for you.\n\nExamples:\n\n- User requests to code, refactor or fix multiple modules/files in a project, and they can be tested independently. In this case you can spawn multiple subagents each working on a different module/file.\n- When you need to analyze a huge codebase (> hundreds of thousands of lines), you can spawn multiple subagents each exploring on a different part of the codebase and gather the summarized results.\n- When you need to search the web for multiple queries, you can spawn multiple subagents for better efficiency.\n\n**Available Subagents:**\n\n${SUBAGENTS_MD}\n"
  },
  {
    "path": "src/kimi_cli/tools/multiagent/task.py",
    "content": "import asyncio\nfrom pathlib import Path\nfrom typing import override\n\nfrom kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.soul import MaxStepsReached, get_wire_or_none, run_soul\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.tools.utils import load_desc\nfrom kimi_cli.utils.path import next_available_rotation\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.types import (\n    ApprovalRequest,\n    ApprovalResponse,\n    QuestionRequest,\n    SubagentEvent,\n    ToolCallRequest,\n    WireMessage,\n)\n\n# Maximum continuation attempts for task summary\nMAX_CONTINUE_ATTEMPTS = 1\n\n\nCONTINUE_PROMPT = \"\"\"\nYour previous response was too brief. Please provide a more comprehensive summary that includes:\n\n1. Specific technical details and implementations\n2. Complete code examples if relevant\n3. Detailed findings and analysis\n4. All important information that should be aware of by the caller\n\"\"\".strip()\n\n\nclass Params(BaseModel):\n    description: str = Field(description=\"A short (3-5 word) description of the task\")\n    subagent_name: str = Field(\n        description=\"The name of the specialized subagent to use for this task\"\n    )\n    prompt: str = Field(\n        description=(\n            \"The task for the subagent to perform. \"\n            \"You must provide a detailed prompt with all necessary background information \"\n            \"because the subagent cannot see anything in your context.\"\n        )\n    )\n\n\nclass Task(CallableTool2[Params]):\n    name: str = \"Task\"\n    params: type[Params] = Params\n\n    def __init__(self, runtime: Runtime):\n        super().__init__(\n            description=load_desc(\n                Path(__file__).parent / \"task.md\",\n                {\n                    \"SUBAGENTS_MD\": \"\\n\".join(\n                        f\"- `{name}`: {desc}\"\n                        for name, desc in runtime.labor_market.fixed_subagent_descs.items()\n                    ),\n                },\n            ),\n        )\n        self._labor_market = runtime.labor_market\n        self._session = runtime.session\n\n    async def _get_subagent_context_file(self) -> Path:\n        \"\"\"Generate a unique context file path for subagent.\"\"\"\n        main_context_file = self._session.context_file\n        subagent_base_name = f\"{main_context_file.stem}_sub\"\n        main_context_file.parent.mkdir(parents=True, exist_ok=True)  # just in case\n        sub_context_file = await next_available_rotation(\n            main_context_file.parent / f\"{subagent_base_name}{main_context_file.suffix}\"\n        )\n        assert sub_context_file is not None\n        return sub_context_file\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        subagents = self._labor_market.subagents\n\n        if params.subagent_name not in subagents:\n            return ToolError(\n                message=f\"Subagent not found: {params.subagent_name}\",\n                brief=\"Subagent not found\",\n            )\n        agent = subagents[params.subagent_name]\n        try:\n            result = await self._run_subagent(agent, params.prompt)\n            return result\n        except Exception as e:\n            return ToolError(\n                message=f\"Failed to run subagent: {e}\",\n                brief=\"Failed to run subagent\",\n            )\n\n    async def _run_subagent(self, agent: Agent, prompt: str) -> ToolReturnValue:\n        \"\"\"Run subagent with optional continuation for task summary.\"\"\"\n        super_wire = get_wire_or_none()\n        assert super_wire is not None\n        current_tool_call = get_current_tool_call_or_none()\n        assert current_tool_call is not None\n        current_tool_call_id = current_tool_call.id\n\n        def _super_wire_send(msg: WireMessage) -> None:\n            if isinstance(\n                msg,\n                ApprovalRequest | ApprovalResponse | ToolCallRequest | QuestionRequest,\n            ):\n                # Requests (and their resolution signals) should stay at the root wire level.\n                super_wire.soul_side.send(msg)\n                return\n\n            event = SubagentEvent(\n                task_tool_call_id=current_tool_call_id,\n                event=msg,\n            )\n            super_wire.soul_side.send(event)\n\n        async def _ui_loop_fn(wire: Wire) -> None:\n            wire_ui = wire.ui_side(merge=True)\n            while True:\n                msg = await wire_ui.receive()\n                _super_wire_send(msg)\n\n        subagent_context_file = await self._get_subagent_context_file()\n        context = Context(file_backend=subagent_context_file)\n        soul = KimiSoul(agent, context=context)\n\n        try:\n            await run_soul(soul, prompt, _ui_loop_fn, asyncio.Event(), runtime=soul.runtime)\n        except MaxStepsReached as e:\n            return ToolError(\n                message=(\n                    f\"Max steps {e.n_steps} reached when running subagent. \"\n                    \"Please try splitting the task into smaller subtasks.\"\n                ),\n                brief=\"Max steps reached\",\n            )\n\n        _error_msg = (\n            \"The subagent seemed not to run properly. Maybe you have to do the task yourself.\"\n        )\n\n        # Check if the subagent context is valid\n        if len(context.history) == 0 or context.history[-1].role != \"assistant\":\n            return ToolError(message=_error_msg, brief=\"Failed to run subagent\")\n\n        final_response = context.history[-1].extract_text(sep=\"\\n\")\n\n        # Check if response is too brief, if so, run again with continuation prompt\n        n_attempts_remaining = MAX_CONTINUE_ATTEMPTS\n        if len(final_response) < 200 and n_attempts_remaining > 0:\n            await run_soul(\n                soul, CONTINUE_PROMPT, _ui_loop_fn, asyncio.Event(), runtime=soul.runtime\n            )\n\n            if len(context.history) == 0 or context.history[-1].role != \"assistant\":\n                return ToolError(message=_error_msg, brief=\"Failed to run subagent\")\n            final_response = context.history[-1].extract_text(sep=\"\\n\")\n\n        return ToolOk(output=final_response)\n"
  },
  {
    "path": "src/kimi_cli/tools/plan/__init__.py",
    "content": "\"\"\"ExitPlanMode tool — lets the LLM submit a plan for user approval.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nfrom collections.abc import Awaitable, Callable\nfrom pathlib import Path\nfrom typing import override\nfrom uuid import uuid4\n\nfrom kosong.tooling import BriefDisplayBlock, CallableTool2, ToolError, ToolReturnValue\nfrom pydantic import BaseModel, Field, field_validator\n\nfrom kimi_cli.soul import get_wire_or_none, wire_send\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.tools.utils import ToolRejectedError, load_desc\nfrom kimi_cli.wire.types import QuestionItem, QuestionNotSupported, QuestionOption, QuestionRequest\n\nlogger = logging.getLogger(__name__)\n\nNAME = \"ExitPlanMode\"\n\n_RESERVED_LABELS = {\"reject\", \"revise\", \"approve\"}\n\n\nclass PlanOption(BaseModel):\n    \"\"\"A selectable approach/option within the plan.\"\"\"\n\n    label: str = Field(\n        description=(\n            \"Short name for this option (1-8 words). \"\n            \"Append '(Recommended)' if you recommend this option.\"\n        ),\n    )\n    description: str = Field(\n        default=\"\",\n        description=\"Brief summary of this approach and its trade-offs.\",\n    )\n\n    @field_validator(\"label\")\n    @classmethod\n    def label_not_reserved(cls, v: str) -> str:\n        if v.strip().lower() in _RESERVED_LABELS:\n            raise ValueError(\n                f\"Option label {v!r} is reserved. \"\n                \"Do not use 'Reject', 'Revise', or 'Approve' as option labels.\"\n            )\n        return v\n\n\nclass Params(BaseModel):\n    options: list[PlanOption] | None = Field(\n        default=None,\n        max_length=3,\n        description=(\n            \"When the plan contains multiple alternative approaches, list them here \"\n            \"so the user can choose which one to execute. 2-3 options. \"\n            \"Each option represents a distinct approach from the plan. \"\n            \"Do not use 'Reject', 'Revise', or 'Approve' as labels.\"\n        ),\n    )\n\n    @field_validator(\"options\")\n    @classmethod\n    def options_labels_unique(cls, v: list[PlanOption] | None) -> list[PlanOption] | None:\n        if v is None:\n            return v\n        labels = [opt.label for opt in v]\n        if len(labels) != len(set(labels)):\n            raise ValueError(\"Option labels must be unique. Found duplicate label(s).\")\n        return v\n\n\nclass ExitPlanMode(CallableTool2[Params]):\n    name: str = NAME\n    description: str = load_desc(Path(__file__).parent / \"description.md\")\n    params: type[Params] = Params\n\n    def __init__(self) -> None:\n        super().__init__()\n        self._toggle_callback: Callable[[], Awaitable[bool]] | None = None\n        self._plan_file_path_getter: Callable[[], Path | None] | None = None\n        self._plan_mode_checker: Callable[[], bool] | None = None\n\n    def bind(\n        self,\n        toggle_callback: Callable[[], Awaitable[bool]],\n        plan_file_path_getter: Callable[[], Path | None],\n        plan_mode_checker: Callable[[], bool],\n    ) -> None:\n        \"\"\"Late-bind soul callbacks after KimiSoul is constructed.\"\"\"\n        self._toggle_callback = toggle_callback\n        self._plan_file_path_getter = plan_file_path_getter\n        self._plan_mode_checker = plan_mode_checker\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        # Guard: only works in plan mode\n        if not self._plan_mode_checker or not self._plan_mode_checker():\n            return ToolError(\n                message=\"Not in plan mode. ExitPlanMode is only available during plan mode.\",\n                brief=\"Not in plan mode\",\n            )\n\n        if not self._toggle_callback or not self._plan_file_path_getter:\n            return ToolError(\n                message=\"ExitPlanMode is not properly initialized.\",\n                brief=\"Not initialized\",\n            )\n\n        # Read the plan file\n        plan_path = self._plan_file_path_getter()\n        plan_content: str | None = None\n        if plan_path and await asyncio.to_thread(plan_path.exists):\n            plan_content = await asyncio.to_thread(plan_path.read_text, encoding=\"utf-8\")\n\n        if not plan_content:\n            return ToolError(\n                message=f\"No plan file found. Write your plan to {plan_path} first, \"\n                \"then call ExitPlanMode.\",\n                brief=\"No plan file\",\n            )\n\n        # Present plan to user via QuestionRequest\n        wire = get_wire_or_none()\n        if wire is None:\n            return ToolError(\n                message=\"Cannot present plan: Wire is not available.\",\n                brief=\"Wire unavailable\",\n            )\n\n        tool_call = get_current_tool_call_or_none()\n        if tool_call is None:\n            return ToolError(\n                message=\"ExitPlanMode must be called from a tool call context.\",\n                brief=\"Invalid context\",\n            )\n\n        has_options = params.options is not None and len(params.options) >= 2\n\n        if has_options:\n            assert params.options is not None\n            question_options = [\n                QuestionOption(label=opt.label, description=opt.description)\n                for opt in params.options\n            ]\n            question_options.append(\n                QuestionOption(\n                    label=\"Reject\",\n                    description=\"Stay in plan mode and continue conversation\",\n                )\n            )\n        else:\n            question_options = [\n                QuestionOption(\n                    label=\"Approve\",\n                    description=\"Exit plan mode and start execution\",\n                ),\n                QuestionOption(\n                    label=\"Reject\",\n                    description=\"Stay in plan mode and continue conversation\",\n                ),\n            ]\n\n        request = QuestionRequest(\n            id=str(uuid4()),\n            tool_call_id=tool_call.id,\n            questions=[\n                QuestionItem(\n                    question=f\"Plan ready for review (saved at {plan_path}):\",\n                    header=\"Plan\",\n                    body=plan_content,\n                    options=question_options,\n                    other_label=\"Revise\",\n                    other_description=\"Stay in plan mode and provide feedback\",\n                )\n            ],\n        )\n\n        wire_send(request)\n\n        try:\n            answers = await request.wait()\n        except QuestionNotSupported:\n            return ToolError(\n                message=\"The connected client does not support plan mode. \"\n                \"Do NOT call this tool again.\",\n                brief=\"Client unsupported\",\n            )\n        except Exception:\n            logger.exception(\"Failed to get user response for ExitPlanMode\")\n            return ToolError(\n                message=\"Failed to get user response.\",\n                brief=\"Question failed\",\n            )\n\n        if not answers:\n            return ToolReturnValue(\n                is_error=False,\n                output=\"User dismissed without choosing. Plan mode remains active. \"\n                \"Continue working on your plan or call ExitPlanMode again when ready.\",\n                message=\"Dismissed\",\n                display=[BriefDisplayBlock(text=\"Dismissed\")],\n            )\n\n        # Parse user choice — exact match on option label\n        chose_reject = any(v == \"Reject\" for v in answers.values())\n\n        if chose_reject:\n            return ToolRejectedError(\n                message=(\n                    \"Plan rejected by user. Stay in plan mode. \"\n                    \"The user will provide feedback via conversation. \"\n                    \"Wait for the user's next message before revising.\"\n                ),\n                brief=\"Plan rejected\",\n            )\n\n        if has_options:\n            assert params.options is not None\n            option_labels = {opt.label for opt in params.options}\n            chosen_option = None\n            for v in answers.values():\n                if v in option_labels:\n                    chosen_option = v\n                    break\n\n            if chosen_option:\n                await self._toggle_callback()\n                return ToolReturnValue(\n                    is_error=False,\n                    output=(\n                        f'Plan approved by user. Selected approach: \"{chosen_option}\"\\n'\n                        f\"Plan mode deactivated. All tools are now available.\\n\"\n                        f\"Plan saved to: {plan_path}\\n\\n\"\n                        f'IMPORTANT: Execute ONLY the selected approach \"{chosen_option}\". '\n                        f\"Ignore other approaches in the plan.\\n\\n\"\n                        f\"## Approved Plan:\\n{plan_content}\"\n                    ),\n                    message=f\"Plan approved: {chosen_option}\",\n                    display=[BriefDisplayBlock(text=f\"Plan approved: {chosen_option}\")],\n                )\n            else:\n                # Revise — extract feedback text\n                feedback = \"\"\n                for v in answers.values():\n                    if v != \"Reject\" and v not in option_labels:\n                        feedback = v\n                msg = (\n                    \"Plan needs revision. Please revise your plan based on \"\n                    \"feedback and call ExitPlanMode again.\"\n                )\n                if feedback:\n                    msg += f\"\\n\\nUser feedback: {feedback}\"\n                return ToolReturnValue(\n                    is_error=False,\n                    output=msg,\n                    message=\"Plan revised\",\n                    display=[BriefDisplayBlock(text=\"Plan revised\")],\n                )\n        else:\n            chose_approve = any(v == \"Approve\" for v in answers.values())\n            if chose_approve:\n                await self._toggle_callback()\n                return ToolReturnValue(\n                    is_error=False,\n                    output=(\n                        f\"Plan approved by user. Plan mode deactivated. \"\n                        f\"All tools are now available.\\n\"\n                        f\"Plan saved to: {plan_path}\\n\\n\"\n                        f\"## Approved Plan:\\n{plan_content}\"\n                    ),\n                    message=\"Plan approved\",\n                    display=[BriefDisplayBlock(text=\"Plan approved\")],\n                )\n            else:\n                # Revise — extract feedback text\n                feedback = \"\"\n                for v in answers.values():\n                    if v not in (\"Approve\", \"Reject\"):\n                        feedback = v\n\n                msg = (\n                    \"Plan needs revision. Please revise your plan based on \"\n                    \"feedback and call ExitPlanMode again.\"\n                )\n                if feedback:\n                    msg += f\"\\n\\nUser feedback: {feedback}\"\n                return ToolReturnValue(\n                    is_error=False,\n                    output=msg,\n                    message=\"Plan revised\",\n                    display=[BriefDisplayBlock(text=\"Plan revised\")],\n                )\n"
  },
  {
    "path": "src/kimi_cli/tools/plan/description.md",
    "content": "Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval.\n\n## How This Tool Works\n- You should have already written your plan to the plan file specified in the plan mode reminder.\n- This tool does NOT take the plan content as a parameter — it reads the plan from the file you wrote.\n- The user will see the contents of your plan file when they review it.\n\n## When to Use\nOnly use this tool for tasks that require planning implementation steps. For research tasks (searching files, reading code, understanding the codebase), do NOT use this tool.\n\n## Multiple Approaches\nIf your plan contains multiple alternative approaches:\n- Pass them via the `options` parameter so the user can choose which approach to execute.\n- Each option should have a concise label and a brief description of trade-offs.\n- If you recommend one option, append \"(Recommended)\" to its label.\n- The user will see all options alongside Reject and Revise choices.\n- Provide 2-3 options at most (the system appends a \"Reject\" option automatically, so the total shown to the user is 3-4).\n- Do NOT use \"Reject\", \"Revise\", or \"Approve\" as option labels — these are reserved by the system.\n\n## Before Using\n- If you have unresolved questions, use AskUserQuestion first.\n- If you have multiple approaches and haven't narrowed down yet, consider using AskUserQuestion first to let the user choose, then write a plan for the chosen approach only.\n- Once your plan is finalized, use THIS tool to request approval.\n- Do NOT use AskUserQuestion to ask \"Is this plan OK?\" or \"Should I proceed?\" — that is exactly what ExitPlanMode does.\n- If rejected, revise based on feedback and call ExitPlanMode again.\n"
  },
  {
    "path": "src/kimi_cli/tools/plan/enter.py",
    "content": "\"\"\"EnterPlanMode tool — lets the LLM request to enter plan mode.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom collections.abc import Awaitable, Callable\nfrom pathlib import Path\nfrom typing import override\nfrom uuid import uuid4\n\nfrom kosong.tooling import BriefDisplayBlock, CallableTool2, ToolError, ToolReturnValue\nfrom pydantic import BaseModel\n\nfrom kimi_cli.soul import get_wire_or_none, wire_send\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.tools.utils import load_desc\nfrom kimi_cli.wire.types import QuestionItem, QuestionNotSupported, QuestionOption, QuestionRequest\n\nlogger = logging.getLogger(__name__)\n\nNAME = \"EnterPlanMode\"\n\n_DESCRIPTION = load_desc(Path(__file__).parent / \"enter_description.md\")\n\n\nclass Params(BaseModel):\n    pass\n\n\nclass EnterPlanMode(CallableTool2[Params]):\n    name: str = NAME\n    description: str = _DESCRIPTION\n    params: type[Params] = Params\n\n    def __init__(self) -> None:\n        super().__init__()\n        self._toggle_callback: Callable[[], Awaitable[bool]] | None = None\n        self._plan_file_path_getter: Callable[[], Path | None] | None = None\n        self._plan_mode_checker: Callable[[], bool] | None = None\n\n    def bind(\n        self,\n        toggle_callback: Callable[[], Awaitable[bool]],\n        plan_file_path_getter: Callable[[], Path | None],\n        plan_mode_checker: Callable[[], bool],\n    ) -> None:\n        \"\"\"Late-bind soul callbacks after KimiSoul is constructed.\"\"\"\n        self._toggle_callback = toggle_callback\n        self._plan_file_path_getter = plan_file_path_getter\n        self._plan_mode_checker = plan_mode_checker\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        # Guard: already in plan mode\n        if self._plan_mode_checker and self._plan_mode_checker():\n            return ToolError(\n                message=\"Already in plan mode. Use ExitPlanMode when your plan is ready.\",\n                brief=\"Already in plan mode\",\n            )\n\n        if not self._toggle_callback or not self._plan_file_path_getter:\n            return ToolError(\n                message=\"EnterPlanMode is not properly initialized.\",\n                brief=\"Not initialized\",\n            )\n\n        # Present confirmation dialog to user via QuestionRequest\n        wire = get_wire_or_none()\n        if wire is None:\n            return ToolError(\n                message=\"Cannot request user confirmation: Wire is not available.\",\n                brief=\"Wire unavailable\",\n            )\n\n        tool_call = get_current_tool_call_or_none()\n        if tool_call is None:\n            return ToolError(\n                message=\"EnterPlanMode must be called from a tool call context.\",\n                brief=\"Invalid context\",\n            )\n\n        request = QuestionRequest(\n            id=str(uuid4()),\n            tool_call_id=tool_call.id,\n            questions=[\n                QuestionItem(\n                    question=\"Enter plan mode?\",\n                    header=\"Plan Mode\",\n                    options=[\n                        QuestionOption(\n                            label=\"Yes\",\n                            description=\"Enter plan mode to explore and design an approach\",\n                        ),\n                        QuestionOption(\n                            label=\"No\",\n                            description=\"Skip planning, start implementing now\",\n                        ),\n                    ],\n                )\n            ],\n        )\n\n        wire_send(request)\n\n        try:\n            answers = await request.wait()\n        except QuestionNotSupported:\n            return ToolError(\n                message=\"The connected client does not support plan mode. \"\n                \"Do NOT call this tool again.\",\n                brief=\"Client unsupported\",\n            )\n        except Exception:\n            logger.exception(\"Failed to get user response for EnterPlanMode\")\n            return ToolError(\n                message=\"Failed to get user response.\",\n                brief=\"Question failed\",\n            )\n\n        if not answers:\n            return ToolReturnValue(\n                is_error=False,\n                output=\"User dismissed without choosing. Proceed with implementation directly.\",\n                message=\"Dismissed\",\n                display=[BriefDisplayBlock(text=\"Dismissed\")],\n            )\n\n        # Parse user choice — exact match on option label\n        chose_yes = any(v == \"Yes\" for v in answers.values())\n        if chose_yes:\n            await self._toggle_callback()\n            plan_path = self._plan_file_path_getter()\n            return ToolReturnValue(\n                is_error=False,\n                output=(\n                    f\"Plan mode activated. You MUST NOT edit code files — only read and plan.\\n\"\n                    f\"Plan file: {plan_path}\\n\"\n                    f\"Workflow: explore with Glob/Grep/ReadFile → design approach → \"\n                    f\"modify the plan file with WriteFile or StrReplaceFile \"\n                    f\"(create it with WriteFile first if it does not exist) → \"\n                    f\"call ExitPlanMode.\\n\"\n                    f\"Use AskUserQuestion only to clarify missing requirements or choose \"\n                    f\"between approaches.\\n\"\n                    f\"Do NOT use AskUserQuestion to ask about plan approval.\"\n                ),\n                message=\"Plan mode on\",\n                display=[BriefDisplayBlock(text=\"Plan mode on\")],\n            )\n        else:\n            return ToolReturnValue(\n                is_error=False,\n                output=(\n                    \"User declined to enter plan mode. Please check with user whether \"\n                    \"to proceed with implementation directly.\"\n                ),\n                message=\"Declined\",\n                display=[BriefDisplayBlock(text=\"Declined\")],\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/plan/enter_description.md",
    "content": "Use this tool proactively when you're about to start a non-trivial implementation task.\nGetting user sign-off on your approach before writing code prevents wasted effort.\n\nUse it when ANY of these conditions apply:\n\n1. New Feature Implementation — e.g. \"Add a caching layer to the API\"\n2. Multiple Valid Approaches — e.g. \"Optimize database queries\" (indexing vs rewrite vs caching)\n3. Code Modifications — e.g. \"Refactor auth module to support OAuth\"\n4. Architectural Decisions — e.g. \"Add WebSocket support\"\n5. Multi-File Changes — involves more than 2-3 files\n6. Unclear Requirements — need exploration to understand scope\n7. User Preferences Matter — if you'd use AskUserQuestion to clarify approach, use EnterPlanMode instead\n\nYolo mode note:\n- Yolo mode users chose continuous execution.\n- In yolo mode, use EnterPlanMode only when the user explicitly asks for planning or when\n  there is exceptional architectural ambiguity that requires user input before proceeding.\n\nWhen NOT to use:\n- Single-line or few-line fixes (typos, obvious bugs, small tweaks)\n- User gave very specific, detailed instructions\n- Pure research/exploration tasks\n\n## What Happens in Plan Mode\nIn plan mode, you will:\n1. Explore the codebase using Glob, Grep, ReadFile (read-only)\n2. Design an implementation approach\n3. Write your plan to a plan file\n4. Present your plan to the user via ExitPlanMode for approval\n"
  },
  {
    "path": "src/kimi_cli/tools/plan/heroes.py",
    "content": "\"\"\"Plan file slug generation using Marvel and DC hero names.\"\"\"\n\nfrom __future__ import annotations\n\nimport secrets\nfrom pathlib import Path\n\nPLANS_DIR = Path.home() / \".kimi\" / \"plans\"\n\nHERO_NAMES: list[str] = [\n    # --- Marvel ---\n    \"iron-man\",\n    \"spider-man\",\n    \"captain-america\",\n    \"thor\",\n    \"hulk\",\n    \"black-widow\",\n    \"hawkeye\",\n    \"black-panther\",\n    \"doctor-strange\",\n    \"scarlet-witch\",\n    \"vision\",\n    \"falcon\",\n    \"war-machine\",\n    \"ant-man\",\n    \"wasp\",\n    \"captain-marvel\",\n    \"gamora\",\n    \"star-lord\",\n    \"groot\",\n    \"rocket\",\n    \"drax\",\n    \"mantis\",\n    \"nebula\",\n    \"shang-chi\",\n    \"moon-knight\",\n    \"ms-marvel\",\n    \"she-hulk\",\n    \"echo\",\n    \"wolverine\",\n    \"cyclops\",\n    \"storm\",\n    \"jean-grey\",\n    \"rogue\",\n    \"beast\",\n    \"nightcrawler\",\n    \"colossus\",\n    \"shadowcat\",\n    \"jubilee\",\n    \"cable\",\n    \"deadpool\",\n    \"bishop\",\n    \"magik\",\n    \"iceman\",\n    \"archangel\",\n    \"psylocke\",\n    \"dazzler\",\n    \"forge\",\n    \"havok\",\n    \"polaris\",\n    \"emma-frost\",\n    \"namor\",\n    \"silver-surfer\",\n    \"adam-warlock\",\n    \"nova\",\n    \"quasar\",\n    \"sentry\",\n    \"blue-marvel\",\n    \"spectrum\",\n    \"squirrel-girl\",\n    \"cloak\",\n    \"dagger\",\n    \"punisher\",\n    \"elektra\",\n    \"luke-cage\",\n    \"iron-fist\",\n    \"jessica-jones\",\n    \"daredevil\",\n    \"blade\",\n    \"ghost-rider\",\n    \"morbius\",\n    \"venom\",\n    \"carnage\",\n    \"silk\",\n    \"spider-gwen\",\n    \"miles-morales\",\n    \"america-chavez\",\n    \"kate-bishop\",\n    \"yelena-belova\",\n    \"white-tiger\",\n    \"moon-girl\",\n    \"devil-dinosaur\",\n    \"amadeus-cho\",\n    \"riri-williams\",\n    \"kamala-khan\",\n    \"sam-alexander\",\n    \"nova-prime\",\n    \"medusa\",\n    \"black-bolt\",\n    \"crystal\",\n    \"karnak\",\n    \"gorgon\",\n    \"lockjaw\",\n    \"quake\",\n    \"mockingbird\",\n    \"bobbi-morse\",\n    \"maria-hill\",\n    \"nick-fury\",\n    \"phil-coulson\",\n    \"winter-soldier\",\n    \"us-agent\",\n    \"patriot\",\n    \"speed\",\n    \"wiccan\",\n    \"hulkling\",\n    \"stature\",\n    \"yellowjacket\",\n    \"tigra\",\n    \"hellcat\",\n    \"valkyrie\",\n    \"sif\",\n    \"beta-ray-bill\",\n    \"hercules\",\n    \"wonder-man\",\n    \"taskmaster\",\n    \"domino\",\n    \"cannonball\",\n    \"sunspot\",\n    \"wolfsbane\",\n    \"warpath\",\n    \"multiple-man\",\n    \"banshee\",\n    \"siryn\",\n    \"monet\",\n    \"rictor\",\n    \"shatterstar\",\n    \"longshot\",\n    \"daken\",\n    \"x-23\",\n    \"fantomex\",\n    \"batman\",\n    \"superman\",\n    \"wonder-woman\",\n    \"flash\",\n    \"aquaman\",\n    \"green-lantern\",\n    \"martian-manhunter\",\n    \"cyborg\",\n    \"hawkgirl\",\n    \"green-arrow\",\n    \"black-canary\",\n    \"zatanna\",\n    \"constantine\",\n    \"shazam\",\n    \"blue-beetle\",\n    \"booster-gold\",\n    \"firestorm\",\n    \"atom\",\n    \"hawkman\",\n    \"plastic-man\",\n    \"red-tornado\",\n    \"starfire\",\n    \"raven\",\n    \"beast-boy\",\n    \"robin\",\n    \"nightwing\",\n    \"batgirl\",\n    \"batwoman\",\n    \"red-hood\",\n    \"signal\",\n    \"orphan\",\n    \"spoiler\",\n    \"catwoman\",\n    \"huntress\",\n    \"supergirl\",\n    \"superboy\",\n    \"power-girl\",\n    \"steel\",\n    \"stargirl\",\n    \"wildcat\",\n    \"doctor-fate\",\n    \"mister-terrific\",\n    \"hourman\",\n    \"sandman\",\n    \"spectre\",\n    \"phantom-stranger\",\n    \"swamp-thing\",\n    \"animal-man\",\n    \"deadman\",\n    \"vixen\",\n    \"black-lightning\",\n    \"static\",\n    \"icon\",\n    \"rocket-dc\",\n    \"captain-atom\",\n    \"fire\",\n    \"ice\",\n    \"elongated-man\",\n    \"metamorpho\",\n    \"black-hawk\",\n    \"crimson-avenger\",\n    \"doctor-mid-nite\",\n    \"jakeem-thunder\",\n    \"mister-miracle\",\n    \"big-barda\",\n    \"orion\",\n    \"lightray\",\n    \"forager\",\n    \"killer-frost\",\n    \"jessica-cruz\",\n    \"simon-baz\",\n    \"john-stewart\",\n    \"guy-gardner\",\n    \"kyle-rayner\",\n    \"hal-jordan\",\n    \"wally-west\",\n    \"barry-allen\",\n    \"jay-garrick\",\n    \"impulse\",\n    \"kid-flash\",\n    \"donna-troy\",\n    \"tempest\",\n    \"aqualad\",\n    \"miss-martian\",\n    \"terra\",\n    \"jericho\",\n    \"ravager\",\n    \"red-star\",\n    \"pantha\",\n    \"argent\",\n    \"damage\",\n    \"jade\",\n    \"obsidian\",\n    \"cyclone\",\n    \"atom-smasher\",\n    \"maxima\",\n    \"starman\",\n    \"liberty-belle\",\n]\n\n_slug_cache: dict[str, str] = {}\n\n\ndef seed_slug_cache(session_id: str, slug: str) -> None:\n    \"\"\"Pre-warm the in-process slug cache with a previously persisted slug.\"\"\"\n    _slug_cache[session_id] = slug\n\n\ndef get_or_create_slug(session_id: str) -> str:\n    \"\"\"Get or create a plan file slug for the given session.\"\"\"\n    if session_id in _slug_cache:\n        return _slug_cache[session_id]\n    PLANS_DIR.mkdir(parents=True, exist_ok=True)\n    slug = \"\"\n    for _ in range(20):\n        words = [secrets.choice(HERO_NAMES) for _ in range(3)]\n        slug = \"-\".join(words)\n        if not (PLANS_DIR / f\"{slug}.md\").exists():\n            break\n    else:\n        # All 20 attempts collided; append session prefix for uniqueness\n        slug = f\"{slug}-{session_id[:8]}\"\n    _slug_cache[session_id] = slug\n    return slug\n\n\ndef get_plan_file_path(session_id: str) -> Path:\n    \"\"\"Get the plan file path for the given session.\"\"\"\n    return PLANS_DIR / f\"{get_or_create_slug(session_id)}.md\"\n\n\ndef read_plan_file(session_id: str) -> str | None:\n    \"\"\"Read the plan file content for the given session, or None if not found.\"\"\"\n    path = get_plan_file_path(session_id)\n    if path.exists():\n        return path.read_text(encoding=\"utf-8\")\n    return None\n"
  },
  {
    "path": "src/kimi_cli/tools/shell/__init__.py",
    "content": "import asyncio\nfrom collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Self, override\n\nimport kaos\nfrom kaos import AsyncReadable\nfrom kosong.tooling import CallableTool2, ToolReturnValue\nfrom pydantic import BaseModel, Field, model_validator\n\nfrom kimi_cli.background import TaskView, format_task\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.tools.display import BackgroundTaskDisplayBlock, ShellDisplayBlock\nfrom kimi_cli.tools.utils import ToolRejectedError, ToolResultBuilder, load_desc\nfrom kimi_cli.utils.environment import Environment\nfrom kimi_cli.utils.subprocess_env import get_clean_env\n\nMAX_FOREGROUND_TIMEOUT = 5 * 60\nMAX_BACKGROUND_TIMEOUT = 24 * 60 * 60\n\n\nclass Params(BaseModel):\n    command: str = Field(description=\"The bash command to execute.\")\n    timeout: int = Field(\n        description=(\n            \"The timeout in seconds for the command to execute. \"\n            \"If the command takes longer than this, it will be killed.\"\n        ),\n        default=60,\n        ge=1,\n        le=MAX_BACKGROUND_TIMEOUT,\n    )\n    run_in_background: bool = Field(\n        default=False,\n        description=\"Whether to run the command as a background task.\",\n    )\n    description: str = Field(\n        default=\"\",\n        description=(\n            \"A short description for the background task. Required when run_in_background=true.\"\n        ),\n    )\n\n    @model_validator(mode=\"after\")\n    def _validate_background_fields(self) -> Self:\n        if self.run_in_background and not self.description.strip():\n            raise ValueError(\"description is required when run_in_background is true\")\n        if not self.run_in_background and self.timeout > MAX_FOREGROUND_TIMEOUT:\n            raise ValueError(\n                f\"timeout must be <= {MAX_FOREGROUND_TIMEOUT}s for foreground commands; \"\n                f\"use run_in_background=true for longer timeouts (up to {MAX_BACKGROUND_TIMEOUT}s)\"\n            )\n        return self\n\n\nclass Shell(CallableTool2[Params]):\n    name: str = \"Shell\"\n    params: type[Params] = Params\n\n    def __init__(self, approval: Approval, environment: Environment, runtime: Runtime):\n        is_powershell = environment.shell_name == \"Windows PowerShell\"\n        super().__init__(\n            description=load_desc(\n                Path(__file__).parent / (\"powershell.md\" if is_powershell else \"bash.md\"),\n                {\"SHELL\": f\"{environment.shell_name} (`{environment.shell_path}`)\"},\n            )\n        )\n        self._approval = approval\n        self._is_powershell = is_powershell\n        self._shell_path = environment.shell_path\n        self._runtime = runtime\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        builder = ToolResultBuilder()\n\n        if not params.command:\n            return builder.error(\"Command cannot be empty.\", brief=\"Empty command\")\n\n        if params.run_in_background:\n            return await self._run_in_background(params)\n\n        if not await self._approval.request(\n            self.name,\n            \"run command\",\n            f\"Run command `{params.command}`\",\n            display=[\n                ShellDisplayBlock(\n                    language=\"powershell\" if self._is_powershell else \"bash\",\n                    command=params.command,\n                )\n            ],\n        ):\n            return ToolRejectedError()\n\n        def stdout_cb(line: bytes):\n            line_str = line.decode(encoding=\"utf-8\", errors=\"replace\")\n            builder.write(line_str)\n\n        def stderr_cb(line: bytes):\n            line_str = line.decode(encoding=\"utf-8\", errors=\"replace\")\n            builder.write(line_str)\n\n        try:\n            exitcode = await self._run_shell_command(\n                params.command, stdout_cb, stderr_cb, params.timeout\n            )\n\n            if exitcode == 0:\n                return builder.ok(\"Command executed successfully.\")\n            else:\n                return builder.error(\n                    f\"Command failed with exit code: {exitcode}.\",\n                    brief=f\"Failed with exit code: {exitcode}\",\n                )\n        except TimeoutError:\n            return builder.error(\n                f\"Command killed by timeout ({params.timeout}s)\",\n                brief=f\"Killed by timeout ({params.timeout}s)\",\n            )\n\n    async def _run_in_background(self, params: Params) -> ToolReturnValue:\n        tool_call = get_current_tool_call_or_none()\n        if tool_call is None:\n            return ToolResultBuilder().error(\n                \"Background shell requires a tool call context.\",\n                brief=\"No tool call context\",\n            )\n\n        if not await self._approval.request(\n            self.name,\n            \"run background command\",\n            f\"Run background command `{params.command}`\",\n            display=[\n                ShellDisplayBlock(\n                    language=\"powershell\" if self._is_powershell else \"bash\",\n                    command=params.command,\n                )\n            ],\n        ):\n            return ToolRejectedError()\n\n        try:\n            view = self._runtime.background_tasks.create_bash_task(\n                command=params.command,\n                description=params.description.strip(),\n                timeout_s=params.timeout,\n                tool_call_id=tool_call.id,\n                shell_name=\"Windows PowerShell\" if self._is_powershell else \"bash\",\n                shell_path=str(self._shell_path),\n                cwd=str(self._runtime.session.work_dir),\n            )\n        except Exception as exc:\n            builder = ToolResultBuilder()\n            return builder.error(f\"Failed to start background task: {exc}\", brief=\"Start failed\")\n\n        return self._background_ok(view)\n\n    def _background_ok(self, view: TaskView) -> ToolReturnValue:\n        builder = ToolResultBuilder()\n        builder.write(\n            \"\\n\".join(\n                [\n                    format_task(view, include_command=True),\n                    \"automatic_notification: true\",\n                    \"next_step: You will be automatically notified when it completes.\",\n                    (\n                        \"next_step: Use TaskOutput with this task_id \"\n                        \"if you need progress or want to wait.\"\n                    ),\n                    \"next_step: Use TaskStop only if the task must be cancelled.\",\n                    (\n                        \"human_shell_hint: For users in the interactive shell, \"\n                        \"the only task-management slash command is /task. \"\n                        \"Do not suggest /task list, /task output, /task stop, or /tasks.\"\n                    ),\n                ]\n            )\n        )\n        builder.display(\n            BackgroundTaskDisplayBlock(\n                task_id=view.spec.id,\n                kind=view.spec.kind,\n                status=view.runtime.status,\n                description=view.spec.description,\n            )\n        )\n        return builder.ok(\"Background task started\", brief=f\"Started {view.spec.id}\")\n\n    async def _run_shell_command(\n        self,\n        command: str,\n        stdout_cb: Callable[[bytes], None],\n        stderr_cb: Callable[[bytes], None],\n        timeout: int,\n    ) -> int:\n        async def _read_stream(stream: AsyncReadable, cb: Callable[[bytes], None]):\n            while True:\n                line = await stream.readline()\n                if line:\n                    cb(line)\n                else:\n                    break\n\n        process = await kaos.exec(*self._shell_args(command), env=get_clean_env())\n\n        try:\n            await asyncio.wait_for(\n                asyncio.gather(\n                    _read_stream(process.stdout, stdout_cb),\n                    _read_stream(process.stderr, stderr_cb),\n                ),\n                timeout,\n            )\n            return await process.wait()\n        except asyncio.CancelledError:\n            await process.kill()\n            raise\n        except TimeoutError:\n            await process.kill()\n            raise\n\n    def _shell_args(self, command: str) -> tuple[str, ...]:\n        if self._is_powershell:\n            return (str(self._shell_path), \"-command\", command)\n        return (str(self._shell_path), \"-c\", command)\n"
  },
  {
    "path": "src/kimi_cli/tools/shell/bash.md",
    "content": "Execute a ${SHELL} command. Use this tool to explore the filesystem, edit files, run scripts, get system information, etc.\n\n**Output:**\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the exit code will be provided in a system tag.\n\nIf `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. You will be automatically notified when the task completes. Use `TaskOutput` if you need progress or want to wait for completion, and use `TaskStop` only if the task must be cancelled. For human users in the interactive shell, background tasks are managed through `/task` only; do not suggest `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented shell subcommands.\n\n**Guidelines for safety and security:**\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls.\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, you shall set `timeout` argument to a reasonable value.\n- Avoid using `..` to access files or directories outside of the working directory.\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\n\n**Guidelines for efficiency:**\n- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la`\n- Use `;` to run commands sequentially regardless of success/failure\n- Use `||` for conditional execution (run second command only if first fails)\n- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands\n- Always quote file paths containing spaces with double quotes (e.g., cd \"/path with spaces/\")\n- Use `if`, `case`, `for`, `while` control flows to execute complex logic in a single call.\n- Verify directory structure before create/edit/delete files or directories to reduce the risk of failure.\n- Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes.\n- After starting a background task, do not guess its outcome. Rely on the automatic completion notification whenever possible. Use `TaskOutput` only when you need to inspect progress or block until completion.\n- If you need to tell a human shell user how to manage background tasks, only mention `/task`. Do not invent `/task list`, `/task output`, `/task stop`, or `/tasks`.\n\n**Commands available:**\n- Shell environment: cd, pwd, export, unset, env\n- File system operations: ls, find, mkdir, rm, cp, mv, touch, chmod, chown\n- File viewing/editing: cat, grep, head, tail, diff, patch\n- Text processing: awk, sed, sort, uniq, wc\n- System information/operations: ps, kill, top, df, free, uname, whoami, id, date\n- Network operations: curl, wget, ping, telnet, ssh\n- Archive operations: tar, zip, unzip\n- Other: Other commands available in the shell environment. Check the existence of a command by running `which <command>` before using it.\n"
  },
  {
    "path": "src/kimi_cli/tools/shell/powershell.md",
    "content": "Execute a ${SHELL} command. Use this tool to explore the filesystem, inspect or edit files, run Windows scripts, collect system information, etc., whenever the agent is running on Windows.\n\nNote that you are running on Windows, so make sure to use Windows commands, paths, and conventions.\n\n**Output:**\nThe stdout and stderr streams are combined and returned as a single string. Extremely long output may be truncated. When a command fails, the exit code is provided in a system tag.\n\nIf `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for completion. When doing that, you must provide a short `description`. You will be automatically notified when the task completes. Use `TaskOutput` if you need progress or want to wait for completion, and use `TaskStop` only if the task must be cancelled. For human users in the interactive shell, background tasks are managed through `/task` only; do not suggest `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented shell subcommands.\n\n**Guidelines for safety and security:**\n- Every tool call starts a fresh ${SHELL} session. Environment variables, `cd` changes, and command history do not persist between calls.\n- Do not launch interactive programs or anything that is expected to block indefinitely; ensure each command finishes promptly. Provide a `timeout` argument for potentially long runs.\n- Avoid using `..` to leave the working directory, and never touch files outside that directory unless explicitly instructed.\n- Never attempt commands that require elevated (Administrator) privileges unless explicitly authorized.\n\n**Guidelines for efficiency:**\n- Chain related commands with `;` and use `if ($?)` or `if (-not $?)` to conditionally execute commands based on the success or failure of previous ones.\n- Redirect or pipe output with `>`, `>>`, `|`, and leverage `for /f`, `if`, and `set` to build richer one-liners instead of multiple tool calls.\n- Reuse built-in utilities (e.g., `findstr`, `where`) to filter, transform, or locate data in a single invocation.\n- Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes.\n- After starting a background task, do not guess its outcome. Rely on the automatic completion notification whenever possible. Use `TaskOutput` only when you need to inspect progress or block until completion.\n- If you need to tell a human shell user how to manage background tasks, only mention `/task`. Do not invent `/task list`, `/task output`, `/task stop`, or `/tasks`.\n\n**Commands available:**\n- Shell environment: `cd`, `dir`, `set`, `setlocal`, `echo`, `call`, `where`\n- File operations: `type`, `copy`, `move`, `del`, `erase`, `mkdir`, `rmdir`, `attrib`, `mklink`\n- Text/search: `find`, `findstr`, `more`, `sort`, `Get-Content`\n- System info: `ver`, `systeminfo`, `tasklist`, `wmic`, `hostname`\n- Archives/scripts: `tar`, `Compress-Archive`, `powershell`, `python`, `node`\n- Other: Any other binaries available on the system PATH; run `where <command>` first if unsure.\n"
  },
  {
    "path": "src/kimi_cli/tools/test.py",
    "content": "import asyncio\nfrom typing import override\n\nfrom kosong.tooling import CallableTool2, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel\n\n\nclass PlusParams(BaseModel):\n    a: float\n    b: float\n\n\nclass Plus(CallableTool2[PlusParams]):\n    name: str = \"plus\"\n    description: str = \"Add two numbers\"\n    params: type[PlusParams] = PlusParams\n\n    @override\n    async def __call__(self, params: PlusParams) -> ToolReturnValue:\n        return ToolOk(output=str(params.a + params.b))\n\n\nclass CompareParams(BaseModel):\n    a: float\n    b: float\n\n\nclass Compare(CallableTool2[CompareParams]):\n    name: str = \"compare\"\n    description: str = \"Compare two numbers\"\n    params: type[CompareParams] = CompareParams\n\n    @override\n    async def __call__(self, params: CompareParams) -> ToolReturnValue:\n        if params.a > params.b:\n            return ToolOk(output=\"greater\")\n        elif params.a < params.b:\n            return ToolOk(output=\"less\")\n        else:\n            return ToolOk(output=\"equal\")\n\n\nclass PanicParams(BaseModel):\n    message: str\n\n\nclass Panic(CallableTool2[PanicParams]):\n    name: str = \"panic\"\n    description: str = \"Raise an exception to cause the tool call to fail.\"\n    params: type[PanicParams] = PanicParams\n\n    @override\n    async def __call__(self, params: PanicParams) -> ToolReturnValue:\n        await asyncio.sleep(2)\n        raise Exception(f\"panicked with a message with {len(params.message)} characters\")\n"
  },
  {
    "path": "src/kimi_cli/tools/think/__init__.py",
    "content": "from pathlib import Path\nfrom typing import override\n\nfrom kosong.tooling import CallableTool2, ToolOk, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.tools.utils import load_desc\n\n\nclass Params(BaseModel):\n    thought: str = Field(description=(\"A thought to think about.\"))\n\n\nclass Think(CallableTool2[Params]):\n    name: str = \"Think\"\n    description: str = load_desc(Path(__file__).parent / \"think.md\", {})\n    params: type[Params] = Params\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        return ToolOk(output=\"\", message=\"Thought logged\")\n"
  },
  {
    "path": "src/kimi_cli/tools/think/think.md",
    "content": "Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed.\n"
  },
  {
    "path": "src/kimi_cli/tools/todo/__init__.py",
    "content": "from pathlib import Path\nfrom typing import Literal, override\n\nfrom kosong.tooling import CallableTool2, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.tools.display import TodoDisplayBlock, TodoDisplayItem\nfrom kimi_cli.tools.utils import load_desc\n\n\nclass Todo(BaseModel):\n    title: str = Field(description=\"The title of the todo\", min_length=1)\n    status: Literal[\"pending\", \"in_progress\", \"done\"] = Field(description=\"The status of the todo\")\n\n\nclass Params(BaseModel):\n    todos: list[Todo] = Field(description=\"The updated todo list\")\n\n\nclass SetTodoList(CallableTool2[Params]):\n    name: str = \"SetTodoList\"\n    description: str = load_desc(Path(__file__).parent / \"set_todo_list.md\")\n    params: type[Params] = Params\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        items = [TodoDisplayItem(title=todo.title, status=todo.status) for todo in params.todos]\n        return ToolReturnValue(\n            is_error=False,\n            output=\"\",\n            message=\"Todo list updated\",\n            display=[TodoDisplayBlock(items=items)],\n        )\n"
  },
  {
    "path": "src/kimi_cli/tools/todo/set_todo_list.md",
    "content": "Update the whole todo list.\n\nTodo list is a simple yet powerful tool to help you get things done. You typically want to use this tool when the given task involves multiple subtasks/milestones, or, multiple tasks are given in a single request. This tool can help you to break down the task and track the progress.\n\nThis is the only todo list tool available to you. That said, each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly.\n\nOnce you finished a subtask/milestone, remember to update the todo list to reflect the progress. Also, you can give yourself a self-encouragement to keep you motivated.\n\nAbusing this tool to track too small steps will just waste your time and make your context messy. For example, here are some cases you should not use this tool:\n\n- When the user just simply ask you a question. E.g. \"What language and framework is used in the project?\", \"What is the best practice for x?\"\n- When it only takes a few steps/tool calls to complete the task. E.g. \"Fix the unit test function 'test_xxx'\", \"Refactor the function 'xxx' to make it more solid.\"\n- When the user prompt is very specific and the only thing you need to do is brainlessly following the instructions. E.g. \"Replace xxx to yyy in the file zzz\", \"Create a file xxx with content yyy.\"\n\nHowever, do not get stuck in a rut. Be flexible. Sometimes, you may try to use todo list at first, then realize the task is too simple and you can simply stop using it; or, sometimes, you may realize the task is complex after a few steps and then you can start using todo list to break it down.\n"
  },
  {
    "path": "src/kimi_cli/tools/utils.py",
    "content": "import re\nfrom pathlib import Path\n\nfrom jinja2 import Environment, Undefined\nfrom kosong.tooling import BriefDisplayBlock, DisplayBlock, ToolError, ToolReturnValue\nfrom kosong.utils.typing import JsonType\n\n\nclass _KeepPlaceholderUndefined(Undefined):\n    def __str__(self) -> str:\n        if self._undefined_name is None:\n            return \"\"\n        return f\"${{{self._undefined_name}}}\"\n\n    __repr__ = __str__\n\n\ndef load_desc(path: Path, context: dict[str, object] | None = None) -> str:\n    \"\"\"Load a tool description from a file, rendered via Jinja2.\"\"\"\n    description = path.read_text(encoding=\"utf-8\")\n    env = Environment(\n        keep_trailing_newline=True,\n        lstrip_blocks=True,\n        trim_blocks=True,\n        variable_start_string=\"${\",\n        variable_end_string=\"}\",\n        undefined=_KeepPlaceholderUndefined,\n    )\n    template = env.from_string(description)\n    return template.render(context or {})\n\n\ndef truncate_line(line: str, max_length: int, marker: str = \"...\") -> str:\n    \"\"\"\n    Truncate a line if it exceeds `max_length`, preserving the beginning and the line break.\n    The output may be longer than `max_length` if it is too short to fit the marker.\n    \"\"\"\n    if len(line) <= max_length:\n        return line\n\n    # Find line breaks at the end of the line\n    m = re.search(r\"[\\r\\n]+$\", line)\n    linebreak = m.group(0) if m else \"\"\n    end = marker + linebreak\n    max_length = max(max_length, len(end))\n    return line[: max_length - len(end)] + end\n\n\n# Default output limits\nDEFAULT_MAX_CHARS = 50_000\nDEFAULT_MAX_LINE_LENGTH = 2000\n\n\nclass ToolResultBuilder:\n    \"\"\"\n    Builder for tool results with character and line limits.\n    \"\"\"\n\n    def __init__(\n        self,\n        max_chars: int = DEFAULT_MAX_CHARS,\n        max_line_length: int | None = DEFAULT_MAX_LINE_LENGTH,\n    ):\n        self.max_chars = max_chars\n        self.max_line_length = max_line_length\n        self._marker = \"[...truncated]\"\n        if max_line_length is not None:\n            assert max_line_length > len(self._marker)\n        self._buffer: list[str] = []\n        self._n_chars = 0\n        self._n_lines = 0\n        self._truncation_happened = False\n        self._display: list[DisplayBlock] = []\n        self._extras: dict[str, JsonType] | None = None\n\n    @property\n    def is_full(self) -> bool:\n        \"\"\"Check if output buffer is full due to character limit.\"\"\"\n        return self._n_chars >= self.max_chars\n\n    @property\n    def n_chars(self) -> int:\n        \"\"\"Get current character count.\"\"\"\n        return self._n_chars\n\n    @property\n    def n_lines(self) -> int:\n        \"\"\"Get current line count.\"\"\"\n        return self._n_lines\n\n    def write(self, text: str) -> int:\n        \"\"\"\n        Write text to the output buffer.\n\n        Returns:\n            int: Number of characters actually written\n        \"\"\"\n        if self.is_full:\n            return 0\n\n        lines = text.splitlines(keepends=True)\n        if not lines:\n            return 0\n\n        chars_written = 0\n\n        for line in lines:\n            if self.is_full:\n                break\n\n            original_line = line\n            remaining_chars = self.max_chars - self._n_chars\n            limit = (\n                min(remaining_chars, self.max_line_length)\n                if self.max_line_length is not None\n                else remaining_chars\n            )\n            line = truncate_line(line, limit, self._marker)\n            if line != original_line:\n                self._truncation_happened = True\n\n            self._buffer.append(line)\n            chars_written += len(line)\n            self._n_chars += len(line)\n            if line.endswith(\"\\n\"):\n                self._n_lines += 1\n\n        return chars_written\n\n    def display(self, *blocks: DisplayBlock) -> None:\n        \"\"\"Add display blocks to the tool result.\"\"\"\n        self._display.extend(blocks)\n\n    def extras(self, **extras: JsonType) -> None:\n        \"\"\"Add extra data to the tool result.\"\"\"\n        if self._extras is None:\n            self._extras = {}\n        self._extras.update(extras)\n\n    def ok(self, message: str = \"\", *, brief: str = \"\") -> ToolReturnValue:\n        \"\"\"Create a ToolReturnValue with is_error=False and the current output.\"\"\"\n        output = \"\".join(self._buffer)\n\n        final_message = message\n        if final_message and not final_message.endswith(\".\"):\n            final_message += \".\"\n        truncation_msg = \"Output is truncated to fit in the message.\"\n        if self._truncation_happened:\n            if final_message:\n                final_message += f\" {truncation_msg}\"\n            else:\n                final_message = truncation_msg\n        return ToolReturnValue(\n            is_error=False,\n            output=output,\n            message=final_message,\n            display=([BriefDisplayBlock(text=brief)] if brief else []) + self._display,\n            extras=self._extras,\n        )\n\n    def error(self, message: str, *, brief: str) -> ToolReturnValue:\n        \"\"\"Create a ToolReturnValue with is_error=True and the current output.\"\"\"\n        output = \"\".join(self._buffer)\n\n        final_message = message\n        if self._truncation_happened:\n            truncation_msg = \"Output is truncated to fit in the message.\"\n            if final_message:\n                final_message += f\" {truncation_msg}\"\n            else:\n                final_message = truncation_msg\n\n        return ToolReturnValue(\n            is_error=True,\n            output=output,\n            message=final_message,\n            display=([BriefDisplayBlock(text=brief)] if brief else []) + self._display,\n            extras=self._extras,\n        )\n\n\nclass ToolRejectedError(ToolError):\n    def __init__(self, message: str | None = None, brief: str = \"Rejected by user\"):\n        super().__init__(\n            message=message\n            or (\n                \"The tool call is rejected by the user. \"\n                \"Please follow the new instructions from the user.\"\n            ),\n            brief=brief,\n        )\n"
  },
  {
    "path": "src/kimi_cli/tools/web/__init__.py",
    "content": "from .fetch import FetchURL\nfrom .search import SearchWeb\n\n__all__ = (\"SearchWeb\", \"FetchURL\")\n"
  },
  {
    "path": "src/kimi_cli/tools/web/fetch.md",
    "content": "Fetch a web page from a URL and extract main text content from it.\n"
  },
  {
    "path": "src/kimi_cli/tools/web/fetch.py",
    "content": "from pathlib import Path\nfrom typing import override\n\nimport aiohttp\nimport trafilatura\nfrom kosong.tooling import CallableTool2, ToolReturnValue\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli.config import Config\nfrom kimi_cli.constant import USER_AGENT\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.tools.utils import ToolResultBuilder, load_desc\nfrom kimi_cli.utils.aiohttp import new_client_session\nfrom kimi_cli.utils.logging import logger\n\n\nclass Params(BaseModel):\n    url: str = Field(description=\"The URL to fetch content from.\")\n\n\nclass FetchURL(CallableTool2[Params]):\n    name: str = \"FetchURL\"\n    description: str = load_desc(Path(__file__).parent / \"fetch.md\", {})\n    params: type[Params] = Params\n\n    def __init__(self, config: Config, runtime: Runtime):\n        super().__init__()\n        self._runtime = runtime\n        self._service_config = config.services.moonshot_fetch\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        if self._service_config:\n            ret = await self._fetch_with_service(params)\n            if not ret.is_error:\n                return ret\n            logger.warning(\"Failed to fetch URL via service: {error}\", error=ret.message)\n            # fallback to local fetch if service fetch fails\n        return await self.fetch_with_http_get(params)\n\n    @staticmethod\n    async def fetch_with_http_get(params: Params) -> ToolReturnValue:\n        builder = ToolResultBuilder(max_line_length=None)\n        try:\n            async with (\n                new_client_session() as session,\n                session.get(\n                    params.url,\n                    headers={\n                        \"User-Agent\": (\n                            \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \"\n                            \"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\"\n                        ),\n                    },\n                ) as response,\n            ):\n                if response.status >= 400:\n                    return builder.error(\n                        (\n                            f\"Failed to fetch URL. Status: {response.status}. \"\n                            f\"This may indicate the page is not accessible or the server is down.\"\n                        ),\n                        brief=f\"HTTP {response.status} error\",\n                    )\n\n                resp_text = await response.text()\n\n                content_type = response.headers.get(aiohttp.hdrs.CONTENT_TYPE, \"\").lower()\n                if content_type.startswith((\"text/plain\", \"text/markdown\")):\n                    builder.write(resp_text)\n                    return builder.ok(\"The returned content is the full content of the page.\")\n        except aiohttp.ClientError as e:\n            return builder.error(\n                (\n                    f\"Failed to fetch URL due to network error: {str(e)}. \"\n                    \"This may indicate the URL is invalid or the server is unreachable.\"\n                ),\n                brief=\"Network error\",\n            )\n\n        if not resp_text:\n            return builder.ok(\n                \"The response body is empty.\",\n                brief=\"Empty response body\",\n            )\n\n        extracted_text = trafilatura.extract(\n            resp_text,\n            include_comments=True,\n            include_tables=True,\n            include_formatting=False,\n            output_format=\"txt\",\n            with_metadata=True,\n        )\n\n        if not extracted_text:\n            return builder.error(\n                (\n                    \"Failed to extract meaningful content from the page. \"\n                    \"This may indicate the page content is not suitable for text extraction, \"\n                    \"or the page requires JavaScript to render its content.\"\n                ),\n                brief=\"No content extracted\",\n            )\n\n        builder.write(extracted_text)\n        return builder.ok(\"The returned content is the main text content extracted from the page.\")\n\n    async def _fetch_with_service(self, params: Params) -> ToolReturnValue:\n        assert self._service_config is not None\n\n        tool_call = get_current_tool_call_or_none()\n        assert tool_call is not None, \"Tool call is expected to be set\"\n\n        builder = ToolResultBuilder(max_line_length=None)\n        api_key = self._runtime.oauth.resolve_api_key(\n            self._service_config.api_key, self._service_config.oauth\n        )\n        if not api_key:\n            return builder.error(\n                \"Fetch service is not configured. You may want to try other methods to fetch.\",\n                brief=\"Fetch service not configured\",\n            )\n        headers = {\n            \"User-Agent\": USER_AGENT,\n            \"Authorization\": f\"Bearer {api_key}\",\n            \"Accept\": \"text/markdown\",\n            \"X-Msh-Tool-Call-Id\": tool_call.id,\n            **self._runtime.oauth.common_headers(),\n            **(self._service_config.custom_headers or {}),\n        }\n\n        try:\n            async with (\n                new_client_session() as session,\n                session.post(\n                    self._service_config.base_url,\n                    headers=headers,\n                    json={\"url\": params.url},\n                ) as response,\n            ):\n                if response.status != 200:\n                    return builder.error(\n                        f\"Failed to fetch URL via service. Status: {response.status}.\",\n                        brief=\"Failed to fetch URL via fetch service\",\n                    )\n\n                content = await response.text()\n                builder.write(content)\n                return builder.ok(\n                    \"The returned content is the main content extracted from the page.\"\n                )\n        except aiohttp.ClientError as e:\n            return builder.error(\n                (\n                    f\"Failed to fetch URL via service due to network error: {str(e)}. \"\n                    \"This may indicate the service is unreachable.\"\n                ),\n                brief=\"Network error when calling fetch service\",\n            )\n"
  },
  {
    "path": "src/kimi_cli/tools/web/search.md",
    "content": "WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc.\n"
  },
  {
    "path": "src/kimi_cli/tools/web/search.py",
    "content": "from pathlib import Path\nfrom typing import override\n\nfrom kosong.tooling import CallableTool2, ToolReturnValue\nfrom pydantic import BaseModel, Field, ValidationError\n\nfrom kimi_cli.config import Config\nfrom kimi_cli.constant import USER_AGENT\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.toolset import get_current_tool_call_or_none\nfrom kimi_cli.tools import SkipThisTool\nfrom kimi_cli.tools.utils import ToolResultBuilder, load_desc\nfrom kimi_cli.utils.aiohttp import new_client_session\n\n\nclass Params(BaseModel):\n    query: str = Field(description=\"The query text to search for.\")\n    limit: int = Field(\n        description=(\n            \"The number of results to return. \"\n            \"Typically you do not need to set this value. \"\n            \"When the results do not contain what you need, \"\n            \"you probably want to give a more concrete query.\"\n        ),\n        default=5,\n        ge=1,\n        le=20,\n    )\n    include_content: bool = Field(\n        description=(\n            \"Whether to include the content of the web pages in the results. \"\n            \"It can consume a large amount of tokens when this is set to True. \"\n            \"You should avoid enabling this when `limit` is set to a large value.\"\n        ),\n        default=False,\n    )\n\n\nclass SearchWeb(CallableTool2[Params]):\n    name: str = \"SearchWeb\"\n    description: str = load_desc(Path(__file__).parent / \"search.md\", {})\n    params: type[Params] = Params\n\n    def __init__(self, config: Config, runtime: Runtime):\n        super().__init__()\n        if config.services.moonshot_search is None:\n            raise SkipThisTool()\n        self._runtime = runtime\n        self._base_url = config.services.moonshot_search.base_url\n        self._api_key = config.services.moonshot_search.api_key\n        self._oauth_ref = config.services.moonshot_search.oauth\n        self._custom_headers = config.services.moonshot_search.custom_headers or {}\n\n    @override\n    async def __call__(self, params: Params) -> ToolReturnValue:\n        builder = ToolResultBuilder(max_line_length=None)\n\n        api_key = self._runtime.oauth.resolve_api_key(self._api_key, self._oauth_ref)\n        if not self._base_url or not api_key:\n            return builder.error(\n                \"Search service is not configured. You may want to try other methods to search.\",\n                brief=\"Search service not configured\",\n            )\n\n        tool_call = get_current_tool_call_or_none()\n        assert tool_call is not None, \"Tool call is expected to be set\"\n\n        async with (\n            new_client_session() as session,\n            session.post(\n                self._base_url,\n                headers={\n                    \"User-Agent\": USER_AGENT,\n                    \"Authorization\": f\"Bearer {api_key}\",\n                    \"X-Msh-Tool-Call-Id\": tool_call.id,\n                    **self._runtime.oauth.common_headers(),\n                    **self._custom_headers,\n                },\n                json={\n                    \"text_query\": params.query,\n                    \"limit\": params.limit,\n                    \"enable_page_crawling\": params.include_content,\n                    \"timeout_seconds\": 30,\n                },\n            ) as response,\n        ):\n            if response.status != 200:\n                return builder.error(\n                    (\n                        f\"Failed to search. Status: {response.status}. \"\n                        \"This may indicates that the search service is currently unavailable.\"\n                    ),\n                    brief=\"Failed to search\",\n                )\n\n            try:\n                results = Response(**await response.json()).search_results\n            except ValidationError as e:\n                return builder.error(\n                    (\n                        f\"Failed to parse search results. Error: {e}. \"\n                        \"This may indicates that the search service is currently unavailable.\"\n                    ),\n                    brief=\"Failed to parse search results\",\n                )\n\n        for i, result in enumerate(results):\n            if i > 0:\n                builder.write(\"---\\n\\n\")\n            builder.write(\n                f\"Title: {result.title}\\nDate: {result.date}\\n\"\n                f\"URL: {result.url}\\nSummary: {result.snippet}\\n\\n\"\n            )\n            if result.content:\n                builder.write(f\"{result.content}\\n\\n\")\n\n        return builder.ok()\n\n\nclass SearchResult(BaseModel):\n    site_name: str\n    title: str\n    url: str\n    snippet: str\n    content: str = \"\"\n    date: str = \"\"\n    icon: str = \"\"\n    mime: str = \"\"\n\n\nclass Response(BaseModel):\n    search_results: list[SearchResult]\n"
  },
  {
    "path": "src/kimi_cli/ui/__init__.py",
    "content": ""
  },
  {
    "path": "src/kimi_cli/ui/acp/__init__.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any, NoReturn\n\nimport acp\n\nfrom kimi_cli.acp.types import ACPContentBlock, MCPServer\nfrom kimi_cli.soul import Soul\nfrom kimi_cli.utils.logging import logger\n\n_DEPRECATED_MESSAGE = (\n    \"`kimi --acp` is deprecated. \"\n    \"Update your ACP client settings to use `kimi acp` without any flags or options.\"\n)\n\n\nclass ACPServerSingleSession:\n    def __init__(self, soul: Soul):\n        self.soul = soul\n\n    def on_connect(self, conn: acp.Client) -> None:\n        logger.info(\"ACP client connected\")\n\n    def _raise(self) -> NoReturn:\n        logger.error(_DEPRECATED_MESSAGE)\n        raise acp.RequestError.invalid_params({\"error\": _DEPRECATED_MESSAGE})\n\n    async def initialize(\n        self,\n        protocol_version: int,\n        client_capabilities: acp.schema.ClientCapabilities | None = None,\n        client_info: acp.schema.Implementation | None = None,\n        **kwargs: Any,\n    ) -> acp.InitializeResponse:\n        self._raise()\n\n    async def new_session(\n        self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> acp.NewSessionResponse:\n        self._raise()\n\n    async def load_session(\n        self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> None:\n        self._raise()\n\n    async def resume_session(\n        self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> acp.schema.ResumeSessionResponse:\n        self._raise()\n\n    async def fork_session(\n        self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any\n    ) -> acp.schema.ForkSessionResponse:\n        self._raise()\n\n    async def list_sessions(\n        self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any\n    ) -> acp.schema.ListSessionsResponse:\n        self._raise()\n\n    async def set_session_mode(\n        self, mode_id: str, session_id: str, **kwargs: Any\n    ) -> acp.SetSessionModeResponse | None:\n        self._raise()\n\n    async def set_session_model(\n        self, model_id: str, session_id: str, **kwargs: Any\n    ) -> acp.SetSessionModelResponse | None:\n        self._raise()\n\n    async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateResponse | None:\n        self._raise()\n\n    async def prompt(\n        self, prompt: list[ACPContentBlock], session_id: str, **kwargs: Any\n    ) -> acp.PromptResponse:\n        self._raise()\n\n    async def cancel(self, session_id: str, **kwargs: Any) -> None:\n        self._raise()\n\n    async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:\n        self._raise()\n\n    async def ext_notification(self, method: str, params: dict[str, Any]) -> None:\n        self._raise()\n\n\nclass ACP:\n    \"\"\"ACP server using the official acp library.\"\"\"\n\n    def __init__(self, soul: Soul):\n        self.soul = soul\n\n    async def run(self):\n        \"\"\"Run the ACP server.\"\"\"\n        logger.info(\"Starting ACP server (single session) on stdio\")\n        await acp.run_agent(ACPServerSingleSession(self.soul))\n"
  },
  {
    "path": "src/kimi_cli/ui/print/__init__.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport sys\nfrom functools import partial\nfrom pathlib import Path\n\nfrom kosong.chat_provider import ChatProviderError\nfrom kosong.message import Message\nfrom rich import print\n\nfrom kimi_cli.cli import InputFormat, OutputFormat\nfrom kimi_cli.soul import (\n    LLMNotSet,\n    LLMNotSupported,\n    MaxStepsReached,\n    RunCancelled,\n    Soul,\n    run_soul,\n)\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.print.visualize import visualize\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.signals import install_sigint_handler\n\n\nclass Print:\n    \"\"\"\n    An app implementation that prints the agent behavior to the console.\n\n    Args:\n        soul (Soul): The soul to run.\n        input_format (InputFormat): The input format to use.\n        output_format (OutputFormat): The output format to use.\n        context_file (Path): The file to store the context.\n        final_only (bool): Whether to only print the final assistant message.\n    \"\"\"\n\n    def __init__(\n        self,\n        soul: Soul,\n        input_format: InputFormat,\n        output_format: OutputFormat,\n        context_file: Path,\n        *,\n        final_only: bool = False,\n    ):\n        self.soul = soul\n        self.input_format: InputFormat = input_format\n        self.output_format: OutputFormat = output_format\n        self.context_file = context_file\n        self.final_only = final_only\n\n    async def run(self, command: str | None = None) -> bool:\n        cancel_event = asyncio.Event()\n\n        def _handler():\n            logger.debug(\"SIGINT received.\")\n            cancel_event.set()\n\n        loop = asyncio.get_running_loop()\n        remove_sigint = install_sigint_handler(loop, _handler)\n\n        if command is None and not sys.stdin.isatty() and self.input_format == \"text\":\n            command = sys.stdin.read().strip()\n            logger.info(\"Read command from stdin: {command}\", command=command)\n\n        try:\n            while True:\n                if command is None:\n                    if self.input_format == \"text\":\n                        return True\n                    else:\n                        assert self.input_format == \"stream-json\"\n                        command = self._read_next_command()\n                        if command is None:\n                            return True\n\n                if command:\n                    logger.info(\"Running agent with command: {command}\", command=command)\n                    if self.output_format == \"text\" and not self.final_only:\n                        print(command)\n                    runtime = self.soul.runtime if isinstance(self.soul, KimiSoul) else None\n                    await run_soul(\n                        self.soul,\n                        command,\n                        partial(visualize, self.output_format, self.final_only),\n                        cancel_event,\n                        runtime.session.wire_file if runtime else None,\n                        runtime,\n                    )\n                else:\n                    logger.info(\"Empty command, skipping\")\n\n                command = None\n        except LLMNotSet as e:\n            logger.exception(\"LLM not set:\")\n            print(str(e))\n        except LLMNotSupported as e:\n            logger.exception(\"LLM not supported:\")\n            print(str(e))\n        except ChatProviderError as e:\n            logger.exception(\"LLM provider error:\")\n            print(str(e))\n        except MaxStepsReached as e:\n            logger.warning(\"Max steps reached: {n_steps}\", n_steps=e.n_steps)\n            print(str(e))\n        except RunCancelled:\n            logger.error(\"Interrupted by user\")\n            print(\"Interrupted by user\")\n        except BaseException as e:\n            logger.exception(\"Unknown error:\")\n            print(f\"Unknown error: {e}\")\n            raise\n        finally:\n            remove_sigint()\n        return False\n\n    def _read_next_command(self) -> str | None:\n        while True:\n            json_line = sys.stdin.readline()\n            if not json_line:\n                # EOF\n                return None\n\n            json_line = json_line.strip()\n            if not json_line:\n                # for empty line, read next line\n                continue\n\n            try:\n                data = json.loads(json_line)\n                message = Message.model_validate(data)\n                if message.role == \"user\":\n                    return message.extract_text(sep=\"\\n\")\n                logger.warning(\n                    \"Ignoring message with role `{role}`: {json_line}\",\n                    role=message.role,\n                    json_line=json_line,\n                )\n            except Exception:\n                logger.warning(\"Ignoring invalid user message: {json_line}\", json_line=json_line)\n"
  },
  {
    "path": "src/kimi_cli/ui/print/visualize.py",
    "content": "from typing import Protocol\n\nimport rich\nfrom kosong.message import Message\n\nfrom kimi_cli.cli import OutputFormat\nfrom kimi_cli.soul.message import tool_result_to_message\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.types import (\n    ContentPart,\n    Notification,\n    StepBegin,\n    StepInterrupted,\n    ToolCall,\n    ToolCallPart,\n    ToolResult,\n    WireMessage,\n)\n\n\nclass Printer(Protocol):\n    def feed(self, msg: WireMessage) -> None: ...\n    def flush(self) -> None: ...\n\n\ndef _merge_content(buffer: list[ContentPart], part: ContentPart) -> None:\n    if not buffer or not buffer[-1].merge_in_place(part):\n        buffer.append(part)\n\n\nclass TextPrinter(Printer):\n    def feed(self, msg: WireMessage) -> None:\n        rich.print(msg)\n\n    def flush(self) -> None:\n        pass\n\n\nclass JsonPrinter(Printer):\n    def __init__(self) -> None:\n        self._content_buffer: list[ContentPart] = []\n        \"\"\"The buffer to merge content parts.\"\"\"\n        self._tool_call_buffer: list[ToolCall] = []\n        \"\"\"The buffer to store the current assistant message's tool calls.\"\"\"\n        self._pending_notifications: list[Notification] = []\n        \"\"\"Notifications buffered until the current assistant message reaches a safe boundary.\"\"\"\n        self._last_tool_call: ToolCall | None = None\n\n    def feed(self, msg: WireMessage) -> None:\n        match msg:\n            case StepBegin() | StepInterrupted():\n                self.flush()\n            case Notification() as notification:\n                if self._content_buffer or self._tool_call_buffer:\n                    self._pending_notifications.append(notification)\n                else:\n                    self._flush_assistant_message()\n                    self._flush_notifications()\n                    self._emit_notification(notification)\n            case ContentPart() as part:\n                # merge with previous parts as much as possible\n                _merge_content(self._content_buffer, part)\n            case ToolCall() as call:\n                self._tool_call_buffer.append(call)\n                self._last_tool_call = call\n            case ToolCallPart() as part:\n                if self._last_tool_call is None:\n                    return\n                assert self._last_tool_call.merge_in_place(part)\n            case ToolResult() as result:\n                self._flush_assistant_message()\n                self._flush_notifications()\n                message = tool_result_to_message(result)\n                print(message.model_dump_json(exclude_none=True), flush=True)\n            case _:\n                # ignore other messages\n                pass\n\n    def _flush_assistant_message(self) -> None:\n        if not self._content_buffer and not self._tool_call_buffer:\n            return\n\n        message = Message(\n            role=\"assistant\",\n            content=self._content_buffer,\n            tool_calls=self._tool_call_buffer or None,\n        )\n        print(message.model_dump_json(exclude_none=True), flush=True)\n\n        self._content_buffer.clear()\n        self._tool_call_buffer.clear()\n        self._last_tool_call = None\n\n    def _emit_notification(self, notification: Notification) -> None:\n        print(notification.model_dump_json(exclude_none=True), flush=True)\n\n    def _flush_notifications(self) -> None:\n        for notification in self._pending_notifications:\n            self._emit_notification(notification)\n        self._pending_notifications.clear()\n\n    def flush(self) -> None:\n        self._flush_assistant_message()\n        self._flush_notifications()\n\n\nclass FinalOnlyTextPrinter(Printer):\n    def __init__(self) -> None:\n        self._content_buffer: list[ContentPart] = []\n\n    def feed(self, msg: WireMessage) -> None:\n        match msg:\n            case StepBegin() | StepInterrupted():\n                self._content_buffer.clear()\n            case ContentPart() as part:\n                _merge_content(self._content_buffer, part)\n            case _:\n                pass\n\n    def flush(self) -> None:\n        if not self._content_buffer:\n            return\n        message = Message(role=\"assistant\", content=self._content_buffer)\n        text = message.extract_text()\n        if text:\n            print(text, flush=True)\n        self._content_buffer.clear()\n\n\nclass FinalOnlyJsonPrinter(Printer):\n    def __init__(self) -> None:\n        self._content_buffer: list[ContentPart] = []\n\n    def feed(self, msg: WireMessage) -> None:\n        match msg:\n            case StepBegin() | StepInterrupted():\n                self._content_buffer.clear()\n            case ContentPart() as part:\n                _merge_content(self._content_buffer, part)\n            case _:\n                pass\n\n    def flush(self) -> None:\n        if not self._content_buffer:\n            return\n        message = Message(role=\"assistant\", content=self._content_buffer)\n        text = message.extract_text()\n        if text:\n            final_message = Message(role=\"assistant\", content=text)\n            print(final_message.model_dump_json(exclude_none=True), flush=True)\n        self._content_buffer.clear()\n\n\nasync def visualize(output_format: OutputFormat, final_only: bool, wire: Wire) -> None:\n    if final_only:\n        match output_format:\n            case \"text\":\n                handler = FinalOnlyTextPrinter()\n            case \"stream-json\":\n                handler = FinalOnlyJsonPrinter()\n    else:\n        match output_format:\n            case \"text\":\n                handler = TextPrinter()\n            case \"stream-json\":\n                handler = JsonPrinter()\n\n    wire_ui = wire.ui_side(merge=True)\n    while True:\n        try:\n            msg = await wire_ui.receive()\n        except QueueShutDown:\n            handler.flush()\n            break\n\n        handler.feed(msg)\n\n        if isinstance(msg, StepInterrupted):\n            break\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/__init__.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport shlex\nimport time\nfrom collections.abc import Awaitable, Callable, Coroutine\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom typing import Any\n\nfrom kosong.chat_provider import APIStatusError, ChatProviderError\nfrom rich.console import Group, RenderableType\nfrom rich.panel import Panel\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom kimi_cli import logger\nfrom kimi_cli.background import list_task_views\nfrom kimi_cli.notifications import NotificationWatcher\nfrom kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell import update as _update_mod\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.echo import render_user_echo_text\nfrom kimi_cli.ui.shell.mcp_status import render_mcp_prompt\nfrom kimi_cli.ui.shell.prompt import (\n    CustomPromptSession,\n    PromptMode,\n    UserInput,\n    toast,\n)\nfrom kimi_cli.ui.shell.replay import replay_recent_history\nfrom kimi_cli.ui.shell.slash import registry as shell_slash_registry\nfrom kimi_cli.ui.shell.slash import shell_mode_registry\nfrom kimi_cli.ui.shell.update import LATEST_VERSION_FILE, UpdateResult, do_update, semver_tuple\nfrom kimi_cli.ui.shell.visualize import visualize\nfrom kimi_cli.utils.envvar import get_env_bool\nfrom kimi_cli.utils.logging import open_original_stderr\nfrom kimi_cli.utils.signals import install_sigint_handler\nfrom kimi_cli.utils.slashcmd import SlashCommand, SlashCommandCall, parse_slash_command_call\nfrom kimi_cli.utils.subprocess_env import get_clean_env\nfrom kimi_cli.utils.term import ensure_new_line, ensure_tty_sane\nfrom kimi_cli.wire.types import ContentPart, StatusUpdate\n\n\n@dataclass(slots=True)\nclass _PromptEvent:\n    kind: str\n    user_input: UserInput | None = None\n\n\nclass Shell:\n    def __init__(self, soul: Soul, welcome_info: list[WelcomeInfoItem] | None = None):\n        self.soul = soul\n        self._welcome_info = list(welcome_info or [])\n        self._background_tasks: set[asyncio.Task[Any]] = set()\n        self._prompt_session: CustomPromptSession | None = None\n        self._running_input_handler: Callable[[UserInput], None] | None = None\n        self._running_interrupt_handler: Callable[[], None] | None = None\n        self._exit_after_run = False\n        self._available_slash_commands: dict[str, SlashCommand[Any]] = {\n            **{cmd.name: cmd for cmd in soul.available_slash_commands},\n            **{cmd.name: cmd for cmd in shell_slash_registry.list_commands()},\n        }\n        \"\"\"Shell-level slash commands + soul-level slash commands. Name to command mapping.\"\"\"\n\n    @property\n    def available_slash_commands(self) -> dict[str, SlashCommand[Any]]:\n        \"\"\"Get all available slash commands, including shell-level and soul-level commands.\"\"\"\n        return self._available_slash_commands\n\n    @staticmethod\n    def _should_exit_input(user_input: UserInput) -> bool:\n        return user_input.command.strip() in {\"exit\", \"quit\", \"/exit\", \"/quit\"}\n\n    @staticmethod\n    def _agent_slash_command_call(user_input: UserInput) -> SlashCommandCall | None:\n        if user_input.mode != PromptMode.AGENT:\n            return None\n        display_call = parse_slash_command_call(user_input.command)\n        if display_call is None:\n            return None\n        resolved_call = parse_slash_command_call(user_input.resolved_command)\n        if resolved_call is None or resolved_call.name != display_call.name:\n            return display_call\n        return resolved_call\n\n    @staticmethod\n    def _should_echo_agent_input(user_input: UserInput) -> bool:\n        if user_input.mode != PromptMode.AGENT:\n            return False\n        if Shell._should_exit_input(user_input):\n            return False\n        return Shell._agent_slash_command_call(user_input) is None\n\n    @staticmethod\n    def _echo_agent_input(user_input: UserInput) -> None:\n        console.print(render_user_echo_text(user_input.command))\n\n    def _bind_running_input(\n        self,\n        on_input: Callable[[UserInput], None],\n        on_interrupt: Callable[[], None],\n    ) -> None:\n        self._running_input_handler = on_input\n        self._running_interrupt_handler = on_interrupt\n\n    def _unbind_running_input(self) -> None:\n        self._running_input_handler = None\n        self._running_interrupt_handler = None\n\n    async def _route_prompt_events(\n        self,\n        prompt_session: CustomPromptSession,\n        idle_events: asyncio.Queue[_PromptEvent],\n        resume_prompt: asyncio.Event,\n    ) -> None:\n        while True:\n            # Keep exactly one active prompt read. Idle submissions pause the\n            # router until the shell decides whether the next prompt should\n            # wait for a blocking action or stay live during an agent run.\n            await resume_prompt.wait()\n            ensure_tty_sane()\n            try:\n                ensure_new_line()\n                user_input = await prompt_session.prompt_next()\n            except KeyboardInterrupt:\n                logger.debug(\"Prompt router got KeyboardInterrupt\")\n                if self._running_input_handler is not None:\n                    if self._running_interrupt_handler is not None:\n                        self._running_interrupt_handler()\n                    continue\n                resume_prompt.clear()\n                await idle_events.put(_PromptEvent(kind=\"interrupt\"))\n                continue\n            except EOFError:\n                logger.debug(\"Prompt router got EOF\")\n                if self._running_input_handler is not None:\n                    self._exit_after_run = True\n                    if self._running_interrupt_handler is not None:\n                        self._running_interrupt_handler()\n                    return\n                resume_prompt.clear()\n                await idle_events.put(_PromptEvent(kind=\"eof\"))\n                return\n            except Exception:\n                logger.exception(\"Prompt router crashed\")\n                resume_prompt.clear()\n                await idle_events.put(_PromptEvent(kind=\"error\"))\n                return\n\n            if prompt_session.last_submission_was_running:  # noqa: SIM102\n                if self._running_input_handler is not None:\n                    if user_input:\n                        self._running_input_handler(user_input)\n                    continue\n                # Handler already unbound — fall through to idle path.\n\n            resume_prompt.clear()\n            await idle_events.put(_PromptEvent(kind=\"input\", user_input=user_input))\n\n    async def run(self, command: str | None = None) -> bool:\n        if command is not None:\n            # run single command and exit\n            logger.info(\"Running agent with command: {command}\", command=command)\n            return await self.run_soul_command(command)\n\n        # Start auto-update background task if not disabled\n        if get_env_bool(\"KIMI_CLI_NO_AUTO_UPDATE\"):\n            logger.info(\"Auto-update disabled by KIMI_CLI_NO_AUTO_UPDATE environment variable\")\n        else:\n            self._start_background_task(self._auto_update())\n\n        _print_welcome_info(self.soul.name or \"Kimi Code CLI\", self._welcome_info)\n\n        if isinstance(self.soul, KimiSoul):\n            watcher = NotificationWatcher(\n                self.soul.runtime.notifications,\n                sink=\"shell\",\n                before_poll=self.soul.runtime.background_tasks.reconcile,\n                on_notification=lambda notification: toast(\n                    f\"[{notification.event.type}] {notification.event.title}\",\n                    topic=\"notification\",\n                    duration=10.0,\n                ),\n            )\n            self._start_background_task(watcher.run_forever())\n            await replay_recent_history(\n                self.soul.context.history,\n                wire_file=self.soul.wire_file,\n            )\n            await self.soul.start_background_mcp_loading()\n\n        async def _plan_mode_toggle() -> bool:\n            if isinstance(self.soul, KimiSoul):\n                return await self.soul.toggle_plan_mode_from_manual()\n            return False\n\n        def _mcp_status_block(columns: int):\n            if not isinstance(self.soul, KimiSoul):\n                return None\n            snapshot = self.soul.status.mcp_status\n            if snapshot is None:\n                return None\n            return render_mcp_prompt(snapshot)\n\n        def _mcp_status_loading() -> bool:\n            if not isinstance(self.soul, KimiSoul):\n                return False\n            snapshot = self.soul.status.mcp_status\n            return bool(snapshot and snapshot.loading)\n\n        @dataclass\n        class _BgCountCache:\n            time: float = 0.0\n            count: int = 0\n\n        _bg_cache = _BgCountCache()\n\n        def _bg_task_count() -> int:\n            if not isinstance(self.soul, KimiSoul):\n                return 0\n            now = time.monotonic()\n            if now - _bg_cache.time < 1.0:\n                return _bg_cache.count\n            views = list_task_views(self.soul.runtime.background_tasks, active_only=True)\n            _bg_cache.count = sum(1 for v in views if v.spec.kind == \"bash\")\n            _bg_cache.time = now\n            return _bg_cache.count\n\n        with CustomPromptSession(\n            status_provider=lambda: self.soul.status,\n            status_block_provider=_mcp_status_block,\n            fast_refresh_provider=_mcp_status_loading,\n            background_task_count_provider=_bg_task_count,\n            model_capabilities=self.soul.model_capabilities or set(),\n            model_name=self.soul.model_name,\n            thinking=self.soul.thinking or False,\n            agent_mode_slash_commands=list(self._available_slash_commands.values()),\n            shell_mode_slash_commands=shell_mode_registry.list_commands(),\n            editor_command_provider=lambda: (\n                self.soul.runtime.config.default_editor if isinstance(self.soul, KimiSoul) else \"\"\n            ),\n            plan_mode_toggle_callback=_plan_mode_toggle,\n        ) as prompt_session:\n            self._prompt_session = prompt_session\n            if isinstance(self.soul, KimiSoul):\n                kimi_soul = self.soul\n                snapshot = kimi_soul.status.mcp_status\n                if snapshot and snapshot.loading:\n\n                    async def _invalidate_after_mcp_loading() -> None:\n                        try:\n                            await kimi_soul.wait_for_background_mcp_loading()\n                        except Exception:\n                            logger.debug(\"MCP loading finished with error while refreshing prompt\")\n                        if self._prompt_session is prompt_session:\n                            prompt_session.invalidate()\n\n                    self._start_background_task(_invalidate_after_mcp_loading())\n            self._exit_after_run = False\n            idle_events: asyncio.Queue[_PromptEvent] = asyncio.Queue()\n            # resume_prompt controls whether the prompt router reads input.\n            # Set BEFORE an await = prompt stays live during the operation\n            # (agent runs that accept steer input); set AFTER = prompt is\n            # paused until the operation finishes.\n            resume_prompt = asyncio.Event()\n            resume_prompt.set()\n            prompt_task = asyncio.create_task(\n                self._route_prompt_events(prompt_session, idle_events, resume_prompt)\n            )\n            shell_ok = True\n            try:\n                while True:\n                    event = await idle_events.get()\n\n                    if event.kind == \"interrupt\":\n                        console.print(\"[grey50]Tip: press Ctrl-D or send 'exit' to quit[/grey50]\")\n                        resume_prompt.set()\n                        continue\n\n                    if event.kind == \"eof\":\n                        console.print(\"Bye!\")\n                        break\n\n                    if event.kind == \"error\":\n                        shell_ok = False\n                        break\n\n                    user_input = event.user_input\n                    assert user_input is not None\n                    if not user_input:\n                        logger.debug(\"Got empty input, skipping\")\n                        resume_prompt.set()\n                        continue\n                    logger.debug(\"Got user input: {user_input}\", user_input=user_input)\n\n                    if self._should_echo_agent_input(user_input):\n                        self._echo_agent_input(user_input)\n\n                    if self._should_exit_input(user_input):\n                        logger.debug(\"Exiting by slash command\")\n                        console.print(\"Bye!\")\n                        break\n\n                    if user_input.mode == PromptMode.SHELL:\n                        await self._run_shell_command(user_input.command)\n                        resume_prompt.set()\n                        continue\n\n                    if slash_cmd_call := self._agent_slash_command_call(user_input):\n                        is_soul_slash = (\n                            slash_cmd_call.name in self._available_slash_commands\n                            and shell_slash_registry.find_command(slash_cmd_call.name) is None\n                        )\n                        if is_soul_slash:\n                            resume_prompt.set()\n                            await self.run_soul_command(slash_cmd_call.raw_input)\n                            console.print()\n                            if self._exit_after_run:\n                                console.print(\"Bye!\")\n                                break\n                        else:\n                            await self._run_slash_command(slash_cmd_call)\n                            resume_prompt.set()\n                        continue\n\n                    resume_prompt.set()\n                    await self.run_soul_command(user_input.content)\n                    console.print()\n                    if self._exit_after_run:\n                        console.print(\"Bye!\")\n                        break\n            finally:\n                prompt_task.cancel()\n                with contextlib.suppress(asyncio.CancelledError):\n                    await prompt_task\n                self._running_input_handler = None\n                self._running_interrupt_handler = None\n                self._prompt_session = None\n                self._cancel_background_tasks()\n                ensure_tty_sane()\n\n        return shell_ok\n\n    async def _run_shell_command(self, command: str) -> None:\n        \"\"\"Run a shell command in foreground.\"\"\"\n        if not command.strip():\n            return\n\n        # Check if it's an allowed slash command in shell mode\n        if slash_cmd_call := parse_slash_command_call(command):\n            if shell_mode_registry.find_command(slash_cmd_call.name):\n                await self._run_slash_command(slash_cmd_call)\n                return\n            else:\n                console.print(\n                    f'[yellow]\"/{slash_cmd_call.name}\" is not available in shell mode. '\n                    \"Press Ctrl-X to switch to agent mode.[/yellow]\"\n                )\n                return\n\n        # Check if user is trying to use 'cd' command\n        stripped_cmd = command.strip()\n        split_cmd: list[str] | None = None\n        try:\n            split_cmd = shlex.split(stripped_cmd)\n        except ValueError as exc:\n            logger.debug(\"Failed to parse shell command for cd check: {error}\", error=exc)\n        if split_cmd and len(split_cmd) == 2 and split_cmd[0] == \"cd\":\n            console.print(\n                \"[yellow]Warning: Directory changes are not preserved across command executions.\"\n                \"[/yellow]\"\n            )\n            return\n\n        logger.info(\"Running shell command: {cmd}\", cmd=command)\n\n        proc: asyncio.subprocess.Process | None = None\n\n        def _handler():\n            logger.debug(\"SIGINT received.\")\n            if proc:\n                proc.terminate()\n\n        loop = asyncio.get_running_loop()\n        remove_sigint = install_sigint_handler(loop, _handler)\n        try:\n            # TODO: For the sake of simplicity, we now use `create_subprocess_shell`.\n            # Later we should consider making this behave like a real shell.\n            with open_original_stderr() as stderr:\n                kwargs: dict[str, Any] = {}\n                if stderr is not None:\n                    kwargs[\"stderr\"] = stderr\n                proc = await asyncio.create_subprocess_shell(command, env=get_clean_env(), **kwargs)\n                await proc.wait()\n        except Exception as e:\n            logger.exception(\"Failed to run shell command:\")\n            console.print(f\"[red]Failed to run shell command: {e}[/red]\")\n        finally:\n            remove_sigint()\n\n    async def _run_slash_command(self, command_call: SlashCommandCall) -> None:\n        from kimi_cli.cli import Reload, SwitchToWeb\n\n        if command_call.name not in self._available_slash_commands:\n            logger.info(\"Unknown slash command /{command}\", command=command_call.name)\n            console.print(\n                f'[red]Unknown slash command \"/{command_call.name}\", '\n                'type \"/\" for all available commands[/red]'\n            )\n            return\n\n        command = shell_slash_registry.find_command(command_call.name)\n        if command is None:\n            # the input is a soul-level slash command call\n            await self.run_soul_command(command_call.raw_input)\n            return\n\n        logger.debug(\n            \"Running shell-level slash command: /{command} with args: {args}\",\n            command=command_call.name,\n            args=command_call.args,\n        )\n\n        try:\n            ret = command.func(self, command_call.args)\n            if isinstance(ret, Awaitable):\n                await ret\n        except (Reload, SwitchToWeb):\n            # just propagate\n            raise\n        except (asyncio.CancelledError, KeyboardInterrupt):\n            # Handle Ctrl-C during slash command execution, return to shell prompt\n            logger.debug(\"Slash command interrupted by KeyboardInterrupt\")\n            console.print(\"[red]Interrupted by user[/red]\")\n        except Exception as e:\n            logger.exception(\"Unknown error:\")\n            console.print(f\"[red]Unknown error: {e}[/red]\")\n            raise  # re-raise unknown error\n\n    async def run_soul_command(self, user_input: str | list[ContentPart]) -> bool:\n        \"\"\"\n        Run the soul and handle any known exceptions.\n\n        Returns:\n            bool: Whether the run is successful.\n        \"\"\"\n        logger.info(\"Running soul with user input: {user_input}\", user_input=user_input)\n\n        cancel_event = asyncio.Event()\n\n        def _handler():\n            logger.debug(\"SIGINT received.\")\n            cancel_event.set()\n\n        loop = asyncio.get_running_loop()\n        remove_sigint = install_sigint_handler(loop, _handler)\n\n        try:\n            snap = self.soul.status\n            runtime = self.soul.runtime if isinstance(self.soul, KimiSoul) else None\n            await run_soul(\n                self.soul,\n                user_input,\n                lambda wire: visualize(\n                    wire.ui_side(merge=False),  # shell UI maintain its own merge buffer\n                    initial_status=StatusUpdate(\n                        context_usage=snap.context_usage,\n                        context_tokens=snap.context_tokens,\n                        max_context_tokens=snap.max_context_tokens,\n                        mcp_status=snap.mcp_status,\n                    ),\n                    cancel_event=cancel_event,\n                    prompt_session=self._prompt_session,\n                    steer=self.soul.steer if isinstance(self.soul, KimiSoul) else None,\n                    bind_running_input=self._bind_running_input,\n                    unbind_running_input=self._unbind_running_input,\n                ),\n                cancel_event,\n                runtime.session.wire_file if runtime else None,\n                runtime,\n            )\n            return True\n        except LLMNotSet:\n            logger.exception(\"LLM not set:\")\n            console.print('[red]LLM not set, send \"/login\" to login[/red]')\n        except LLMNotSupported as e:\n            # actually unsupported input/mode should already be blocked by prompt session\n            logger.exception(\"LLM not supported:\")\n            console.print(f\"[red]{e}[/red]\")\n        except ChatProviderError as e:\n            logger.exception(\"LLM provider error:\")\n            if isinstance(e, APIStatusError) and e.status_code == 401:\n                console.print(\"[red]Authorization failed, please check your login status[/red]\")\n            elif isinstance(e, APIStatusError) and e.status_code == 402:\n                console.print(\"[red]Membership expired, please renew your plan[/red]\")\n            elif isinstance(e, APIStatusError) and e.status_code == 403:\n                console.print(\"[red]Quota exceeded, please upgrade your plan or retry later[/red]\")\n            else:\n                console.print(f\"[red]LLM provider error: {e}[/red]\")\n        except MaxStepsReached as e:\n            logger.warning(\"Max steps reached: {n_steps}\", n_steps=e.n_steps)\n            console.print(f\"[yellow]{e}[/yellow]\")\n        except RunCancelled:\n            logger.info(\"Cancelled by user\")\n            console.print(\"[red]Interrupted by user[/red]\")\n        except Exception as e:\n            logger.exception(\"Unexpected error:\")\n            console.print(f\"[red]Unexpected error: {e}[/red]\")\n            raise  # re-raise unknown error\n        finally:\n            remove_sigint()\n        return False\n\n    async def _auto_update(self) -> None:\n        result = await do_update(print=False, check_only=True)\n        if result == UpdateResult.UPDATE_AVAILABLE:\n            while True:\n                toast(\n                    f\"new version found, run `{_update_mod.UPGRADE_COMMAND}` to upgrade\",\n                    topic=\"update\",\n                    duration=30.0,\n                )\n                await asyncio.sleep(60.0)\n        elif result == UpdateResult.UPDATED:\n            toast(\"auto updated, restart to use the new version\", topic=\"update\", duration=5.0)\n\n    def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:\n        task = asyncio.create_task(coro)\n        self._background_tasks.add(task)\n\n        def _cleanup(t: asyncio.Task[Any]) -> None:\n            self._background_tasks.discard(t)\n            try:\n                t.result()\n            except asyncio.CancelledError:\n                pass\n            except Exception:\n                logger.exception(\"Background task failed:\")\n\n        task.add_done_callback(_cleanup)\n        return task\n\n    def _cancel_background_tasks(self) -> None:\n        \"\"\"Cancel all background tasks (notification watcher, auto-update, etc.).\"\"\"\n        for task in self._background_tasks:\n            task.cancel()\n        self._background_tasks.clear()\n\n\n_KIMI_BLUE = \"dodger_blue1\"\n_LOGO = f\"\"\"\\\n[{_KIMI_BLUE}]\\\n▐█▛█▛█▌\n▐█████▌\\\n[{_KIMI_BLUE}]\\\n\"\"\"\n\n\n@dataclass(slots=True)\nclass WelcomeInfoItem:\n    class Level(Enum):\n        INFO = \"grey50\"\n        WARN = \"yellow\"\n        ERROR = \"red\"\n\n    name: str\n    value: str\n    level: Level = Level.INFO\n\n\ndef _print_welcome_info(name: str, info_items: list[WelcomeInfoItem]) -> None:\n    head = Text.from_markup(\"Welcome to Kimi Code CLI!\")\n    help_text = Text.from_markup(\"[grey50]Send /help for help information.[/grey50]\")\n\n    # Use Table for precise width control\n    logo = Text.from_markup(_LOGO)\n    table = Table(show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False)\n    table.add_column(justify=\"left\")\n    table.add_column(justify=\"left\")\n    table.add_row(logo, Group(head, help_text))\n\n    rows: list[RenderableType] = [table]\n\n    if info_items:\n        rows.append(Text(\"\"))  # empty line\n    for item in info_items:\n        rows.append(Text(f\"{item.name}: {item.value}\", style=item.level.value))\n\n    if LATEST_VERSION_FILE.exists():\n        from kimi_cli.constant import VERSION as current_version\n\n        latest_version = LATEST_VERSION_FILE.read_text(encoding=\"utf-8\").strip()\n        if semver_tuple(latest_version) > semver_tuple(current_version):\n            rows.append(\n                Text.from_markup(\n                    f\"\\n[yellow]New version available: {latest_version}. \"\n                    f\"Please run `{_update_mod.UPGRADE_COMMAND}` to upgrade.[/yellow]\"\n                )\n            )\n\n    console.print(\n        Panel(\n            Group(*rows),\n            border_style=_KIMI_BLUE,\n            expand=False,\n            padding=(1, 2),\n        )\n    )\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/console.py",
    "content": "from __future__ import annotations\n\nfrom rich.console import Console\nfrom rich.theme import Theme\n\nNEUTRAL_MARKDOWN_THEME = Theme(\n    {\n        \"markdown.paragraph\": \"none\",\n        \"markdown.block_quote\": \"none\",\n        \"markdown.hr\": \"none\",\n        \"markdown.item\": \"none\",\n        \"markdown.item.bullet\": \"none\",\n        \"markdown.item.number\": \"none\",\n        \"markdown.link\": \"none\",\n        \"markdown.link_url\": \"none\",\n        \"markdown.h1\": \"none\",\n        \"markdown.h1.border\": \"none\",\n        \"markdown.h2\": \"none\",\n        \"markdown.h3\": \"none\",\n        \"markdown.h4\": \"none\",\n        \"markdown.h5\": \"none\",\n        \"markdown.h6\": \"none\",\n        \"markdown.em\": \"none\",\n        \"markdown.strong\": \"none\",\n        \"markdown.s\": \"none\",\n        \"status.spinner\": \"none\",\n    },\n    inherit=True,\n)\n\n_NEUTRAL_MARKDOWN_THEME = NEUTRAL_MARKDOWN_THEME\nconsole = Console(highlight=False, theme=NEUTRAL_MARKDOWN_THEME)\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/debug.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom typing import TYPE_CHECKING\n\nfrom kosong.message import Message\nfrom rich.console import Group, RenderableType\nfrom rich.panel import Panel\nfrom rich.rule import Rule\nfrom rich.syntax import Syntax\nfrom rich.text import Text\n\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.slash import registry\nfrom kimi_cli.wire.types import (\n    AudioURLPart,\n    ContentPart,\n    ImageURLPart,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    VideoURLPart,\n)\n\nif TYPE_CHECKING:\n    from kimi_cli.ui.shell import Shell\n\n\ndef _format_content_part(part: ContentPart) -> Text | Panel | Group:\n    \"\"\"Format a single content part.\"\"\"\n    match part:\n        case TextPart(text=text):\n            # Check if it looks like a system tag\n            if text.strip().startswith(\"<system>\") and text.strip().endswith(\"</system>\"):\n                return Panel(\n                    text.strip()[8:-9].strip(),\n                    title=\"[dim]system[/dim]\",\n                    border_style=\"dim yellow\",\n                    padding=(0, 1),\n                )\n            return Text(text, style=\"white\")\n\n        case ThinkPart(think=think):\n            return Panel(\n                think,\n                title=\"[dim]thinking[/dim]\",\n                border_style=\"dim cyan\",\n                padding=(0, 1),\n            )\n\n        case ImageURLPart(image_url=img):\n            url_display = img.url[:80] + \"...\" if len(img.url) > 80 else img.url\n            return Text(f\"[Image] {url_display}\", style=\"blue\")\n\n        case AudioURLPart(audio_url=audio):\n            url_display = audio.url[:80] + \"...\" if len(audio.url) > 80 else audio.url\n            id_text = f\" (id: {audio.id})\" if audio.id else \"\"\n            return Text(f\"[Audio{id_text}] {url_display}\", style=\"blue\")\n\n        case VideoURLPart(video_url=video):\n            url_display = video.url[:80] + \"...\" if len(video.url) > 80 else video.url\n            return Text(f\"[Video] {url_display}\", style=\"blue\")\n\n        case _:\n            return Text(f\"[Unknown content type: {type(part).__name__}]\", style=\"red\")\n\n\ndef _format_tool_call(tool_call: ToolCall) -> Panel:\n    \"\"\"Format a tool call.\"\"\"\n    args = tool_call.function.arguments or \"{}\"\n    try:\n        args_formatted = json.dumps(json.loads(args), indent=2)\n        args_syntax = Syntax(args_formatted, \"json\", theme=\"monokai\", padding=(0, 1))\n    except json.JSONDecodeError:\n        args_syntax = Text(args, style=\"red\")\n\n    content = Group(\n        Text(f\"Function: {tool_call.function.name}\", style=\"bold cyan\"),\n        Text(f\"Call ID: {tool_call.id}\", style=\"dim\"),\n        Text(\"Arguments:\", style=\"bold\"),\n        args_syntax,\n    )\n\n    return Panel(\n        content,\n        title=\"[bold yellow]Tool Call[/bold yellow]\",\n        border_style=\"yellow\",\n        padding=(0, 1),\n    )\n\n\ndef _format_message(msg: Message, index: int) -> Panel:\n    \"\"\"Format a single message.\"\"\"\n    # Role styling\n    role_colors = {\n        \"system\": \"magenta\",\n        \"developer\": \"magenta\",\n        \"user\": \"green\",\n        \"assistant\": \"blue\",\n        \"tool\": \"yellow\",\n    }\n    role_color = role_colors.get(msg.role, \"white\")\n    role_text = f\"[bold {role_color}]{msg.role.upper()}[/bold {role_color}]\"\n\n    # Add name if present\n    if msg.name:\n        role_text += f\" [dim]({msg.name})[/dim]\"\n\n    # Add tool call ID for tool messages\n    if msg.tool_call_id:\n        role_text += f\" [dim]→ {msg.tool_call_id}[/dim]\"\n\n    # Format content\n    content_items: list[RenderableType] = []\n\n    for part in msg.content:\n        formatted = _format_content_part(part)\n        content_items.append(formatted)\n\n    # Add tool calls if present\n    if msg.tool_calls:\n        if content_items:\n            content_items.append(Text())  # Empty line\n        for tool_call in msg.tool_calls:\n            content_items.append(_format_tool_call(tool_call))\n\n    # Combine all content\n    if not content_items:\n        content_items.append(Text(\"[empty message]\", style=\"dim italic\"))\n\n    group = Group(*content_items)\n\n    # Create panel\n    title = f\"#{index + 1} {role_text}\"\n    if msg.partial:\n        title += \" [dim italic](partial)[/dim italic]\"\n\n    return Panel(\n        group,\n        title=title,\n        border_style=role_color,\n        padding=(0, 1),\n    )\n\n\n@registry.command\ndef debug(app: Shell, args: str):\n    \"\"\"Debug the context\"\"\"\n    assert isinstance(app.soul, KimiSoul)\n\n    context = app.soul.context\n    history = context.history\n\n    if not history:\n        console.print(\n            Panel(\n                \"Context is empty - no messages yet\",\n                border_style=\"yellow\",\n                padding=(1, 2),\n            )\n        )\n        return\n\n    # Build the debug output\n    output_items = [\n        Panel(\n            Group(\n                Text(f\"Total messages: {len(history)}\", style=\"bold\"),\n                Text(f\"Token count: {context.token_count:,}\", style=\"bold\"),\n                Text(f\"Checkpoints: {context.n_checkpoints}\", style=\"bold\"),\n                Text(f\"Trajectory: {context.file_backend}\", style=\"dim\"),\n            ),\n            title=\"[bold]Context Info[/bold]\",\n            border_style=\"cyan\",\n            padding=(0, 1),\n        ),\n        Rule(style=\"dim\"),\n    ]\n\n    # Add all messages\n    for idx, msg in enumerate(history):\n        output_items.append(_format_message(msg, idx))\n\n    # Display using rich pager\n    display_group = Group(*output_items)\n\n    # Use pager to display\n    with console.pager(styles=True):\n        console.print(display_group)\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/echo.py",
    "content": "from __future__ import annotations\n\nfrom kosong.message import Message\nfrom rich.text import Text\n\nfrom kimi_cli.ui.shell.prompt import PROMPT_SYMBOL\nfrom kimi_cli.utils.message import message_stringify\n\n\ndef render_user_echo(message: Message) -> Text:\n    \"\"\"Render a user message as literal shell transcript text.\"\"\"\n    return Text(f\"{PROMPT_SYMBOL} {message_stringify(message)}\")\n\n\ndef render_user_echo_text(text: str) -> Text:\n    \"\"\"Render the local prompt text exactly as the user saw it in the buffer.\"\"\"\n    return Text(f\"{PROMPT_SYMBOL} {text}\")\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/export_import.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.slash import ensure_kimi_soul, registry, shell_mode_registry\nfrom kimi_cli.utils.export import is_sensitive_file\nfrom kimi_cli.utils.path import sanitize_cli_path, shorten_home\nfrom kimi_cli.wire.types import TurnBegin, TurnEnd\n\nif TYPE_CHECKING:\n    from kimi_cli.ui.shell import Shell\n\n\n# ---------------------------------------------------------------------------\n# /export command\n# ---------------------------------------------------------------------------\n\n\n@registry.command\n@shell_mode_registry.command\nasync def export(app: Shell, args: str):\n    \"\"\"Export current session context to a markdown file\"\"\"\n    from kimi_cli.utils.export import perform_export\n\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n\n    session = soul.runtime.session\n    result = await perform_export(\n        history=list(soul.context.history),\n        session_id=session.id,\n        work_dir=str(session.work_dir),\n        token_count=soul.context.token_count,\n        args=args,\n        default_dir=Path(str(session.work_dir)),\n    )\n    if isinstance(result, str):\n        console.print(f\"[yellow]{result}[/yellow]\")\n        return\n\n    output, count = result\n    display = shorten_home(KaosPath(str(output)))\n    console.print(f\"[green]Exported {count} messages to {display}[/green]\")\n    console.print(\n        \"[yellow]Note: The exported file may contain sensitive information. \"\n        \"Please be cautious when sharing it externally.[/yellow]\"\n    )\n\n\n# ---------------------------------------------------------------------------\n# /import command\n# ---------------------------------------------------------------------------\n\n\n@registry.command(name=\"import\")\n@shell_mode_registry.command(name=\"import\")\nasync def import_context(app: Shell, args: str):\n    \"\"\"Import context from a file or session ID\"\"\"\n    from kimi_cli.utils.export import perform_import\n\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n\n    target = sanitize_cli_path(args)\n    if not target:\n        console.print(\"[yellow]Usage: /import <file_path or session_id>[/yellow]\")\n        return\n\n    session = soul.runtime.session\n    raw_max_context_size = (\n        soul.runtime.llm.max_context_size if soul.runtime.llm is not None else None\n    )\n    max_context_size = (\n        raw_max_context_size\n        if isinstance(raw_max_context_size, int) and raw_max_context_size > 0\n        else None\n    )\n    result = await perform_import(\n        target=target,\n        current_session_id=session.id,\n        work_dir=session.work_dir,\n        context=soul.context,\n        max_context_size=max_context_size,\n    )\n    if isinstance(result, str):\n        console.print(f\"[red]{result}[/red]\")\n        return\n\n    source_desc, content_len = result\n\n    # Write to wire file so the import appears in session replay\n    await soul.wire_file.append_message(\n        TurnBegin(user_input=f\"[Imported context from {source_desc}]\")\n    )\n    await soul.wire_file.append_message(TurnEnd())\n\n    console.print(\n        f\"[green]Imported context from {source_desc} \"\n        f\"({content_len} chars) into current session.[/green]\"\n    )\n    if source_desc.startswith(\"file\") and is_sensitive_file(Path(target).name):\n        console.print(\n            \"[yellow]Warning: This file may contain secrets (API keys, tokens, credentials). \"\n            \"The content is now part of your session context.[/yellow]\"\n        )\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/keyboard.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport sys\nimport threading\nimport time\nfrom collections.abc import AsyncGenerator, Callable\nfrom enum import Enum, auto\n\nfrom kimi_cli.utils.aioqueue import Queue\n\n\nclass KeyEvent(Enum):\n    UP = auto()\n    DOWN = auto()\n    LEFT = auto()\n    RIGHT = auto()\n    ENTER = auto()\n    ESCAPE = auto()\n    TAB = auto()\n    SPACE = auto()\n    CTRL_E = auto()\n    NUM_1 = auto()\n    NUM_2 = auto()\n    NUM_3 = auto()\n    NUM_4 = auto()\n    NUM_5 = auto()\n    NUM_6 = auto()\n\n\nclass KeyboardListener:\n    def __init__(self) -> None:\n        self._queue = Queue[KeyEvent]()\n        self._cancel_event = threading.Event()\n        self._pause_event = threading.Event()\n        self._paused_event = threading.Event()\n        self._listener: threading.Thread | None = None\n        self._loop: asyncio.AbstractEventLoop | None = None\n\n    async def start(self) -> None:\n        if self._listener is not None:\n            return\n        self._loop = asyncio.get_running_loop()\n\n        def emit(event: KeyEvent) -> None:\n            if self._loop is None:\n                return\n            self._loop.call_soon_threadsafe(self._queue.put_nowait, event)\n\n        self._listener = threading.Thread(\n            target=_listen_for_keyboard_thread,\n            args=(self._cancel_event, self._pause_event, self._paused_event, emit),\n            name=\"kimi-cli-keyboard-listener\",\n            daemon=True,\n        )\n        self._listener.start()\n\n    async def stop(self) -> None:\n        self._cancel_event.set()\n        self._pause_event.clear()\n        if self._listener and self._listener.is_alive():\n            await asyncio.to_thread(self._listener.join)\n\n    def _pause_sync(self) -> None:\n        self._pause_event.set()\n        self._paused_event.wait()\n\n    async def pause(self) -> None:\n        await asyncio.to_thread(self._pause_sync)\n\n    def _resume_sync(self) -> None:\n        self._pause_event.clear()\n        while self._paused_event.is_set() and not self._cancel_event.is_set():\n            time.sleep(0.01)\n\n    async def resume(self) -> None:\n        await asyncio.to_thread(self._resume_sync)\n\n    async def get(self) -> KeyEvent:\n        return await self._queue.get()\n\n\nasync def listen_for_keyboard() -> AsyncGenerator[KeyEvent]:\n    listener = KeyboardListener()\n    await listener.start()\n\n    try:\n        while True:\n            yield await listener.get()\n    finally:\n        await listener.stop()\n\n\ndef _listen_for_keyboard_thread(\n    cancel: threading.Event,\n    pause: threading.Event,\n    paused: threading.Event,\n    emit: Callable[[KeyEvent], None],\n) -> None:\n    if sys.platform == \"win32\":\n        _listen_for_keyboard_windows(cancel, pause, paused, emit)\n    else:\n        _listen_for_keyboard_unix(cancel, pause, paused, emit)\n\n\ndef _listen_for_keyboard_unix(\n    cancel: threading.Event,\n    pause: threading.Event,\n    paused: threading.Event,\n    emit: Callable[[KeyEvent], None],\n) -> None:\n    if sys.platform == \"win32\":\n        raise RuntimeError(\"Unix keyboard listener requires a non-Windows platform\")\n\n    import termios\n\n    fd = sys.stdin.fileno()\n    oldterm = termios.tcgetattr(fd)\n    rawattr = termios.tcgetattr(fd)\n    rawattr[3] = rawattr[3] & ~termios.ICANON & ~termios.ECHO\n    rawattr[6][termios.VMIN] = 0\n    rawattr[6][termios.VTIME] = 0\n    raw_enabled = False\n\n    def enable_raw() -> None:\n        nonlocal raw_enabled\n        if raw_enabled:\n            return\n        termios.tcsetattr(fd, termios.TCSANOW, rawattr)\n        raw_enabled = True\n\n    def disable_raw() -> None:\n        nonlocal raw_enabled\n        if not raw_enabled:\n            return\n        termios.tcsetattr(fd, termios.TCSANOW, oldterm)\n        raw_enabled = False\n\n    enable_raw()\n\n    try:\n        while not cancel.is_set():\n            if pause.is_set():\n                disable_raw()\n                paused.set()\n                time.sleep(0.01)\n                continue\n            if paused.is_set():\n                paused.clear()\n                enable_raw()\n\n            try:\n                c = sys.stdin.buffer.read(1)\n            except (OSError, ValueError):\n                c = b\"\"\n\n            if not c:\n                if cancel.is_set():\n                    break\n                time.sleep(0.01)\n                continue\n\n            if c == b\"\\x1b\":\n                sequence = c\n                for _ in range(2):\n                    if cancel.is_set():\n                        break\n                    try:\n                        fragment = sys.stdin.buffer.read(1)\n                    except (OSError, ValueError):\n                        fragment = b\"\"\n                    if not fragment:\n                        break\n                    sequence += fragment\n                    if sequence in _ARROW_KEY_MAP:\n                        break\n\n                event = _ARROW_KEY_MAP.get(sequence)\n                if event is not None:\n                    emit(event)\n                elif sequence == b\"\\x1b\":\n                    emit(KeyEvent.ESCAPE)\n            elif c in (b\"\\r\", b\"\\n\"):\n                emit(KeyEvent.ENTER)\n            elif c == b\" \":\n                emit(KeyEvent.SPACE)\n            elif c == b\"\\t\":\n                emit(KeyEvent.TAB)\n            elif c == b\"\\x05\":  # Ctrl+E\n                emit(KeyEvent.CTRL_E)\n            elif c == b\"1\":\n                emit(KeyEvent.NUM_1)\n            elif c == b\"2\":\n                emit(KeyEvent.NUM_2)\n            elif c == b\"3\":\n                emit(KeyEvent.NUM_3)\n            elif c == b\"4\":\n                emit(KeyEvent.NUM_4)\n            elif c == b\"5\":\n                emit(KeyEvent.NUM_5)\n            elif c == b\"6\":\n                emit(KeyEvent.NUM_6)\n    finally:\n        termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm)\n\n\ndef _listen_for_keyboard_windows(\n    cancel: threading.Event,\n    pause: threading.Event,\n    paused: threading.Event,\n    emit: Callable[[KeyEvent], None],\n) -> None:\n    if sys.platform != \"win32\":\n        raise RuntimeError(\"Windows keyboard listener requires a Windows platform\")\n\n    import msvcrt\n\n    while not cancel.is_set():\n        if pause.is_set():\n            paused.set()\n            time.sleep(0.01)\n            continue\n        if paused.is_set():\n            paused.clear()\n\n        if msvcrt.kbhit():\n            c = msvcrt.getch()\n\n            # Handle special keys (arrow keys, etc.)\n            if c in (b\"\\x00\", b\"\\xe0\"):\n                # Extended key, read the next byte\n                extended = msvcrt.getch()\n                event = _WINDOWS_KEY_MAP.get(extended)\n                if event is not None:\n                    emit(event)\n            elif c == b\"\\x1b\":\n                sequence = c\n                for _ in range(2):\n                    if cancel.is_set():\n                        break\n                    fragment = msvcrt.getch() if msvcrt.kbhit() else b\"\"\n                    if not fragment:\n                        break\n                    sequence += fragment\n                    if sequence in _ARROW_KEY_MAP:\n                        break\n\n                event = _ARROW_KEY_MAP.get(sequence)\n                if event is not None:\n                    emit(event)\n                elif sequence == b\"\\x1b\":\n                    emit(KeyEvent.ESCAPE)\n            elif c in (b\"\\r\", b\"\\n\"):\n                emit(KeyEvent.ENTER)\n            elif c == b\" \":\n                emit(KeyEvent.SPACE)\n            elif c == b\"\\t\":\n                emit(KeyEvent.TAB)\n            elif c == b\"\\x05\":  # Ctrl+E\n                emit(KeyEvent.CTRL_E)\n            elif c == b\"1\":\n                emit(KeyEvent.NUM_1)\n            elif c == b\"2\":\n                emit(KeyEvent.NUM_2)\n            elif c == b\"3\":\n                emit(KeyEvent.NUM_3)\n            elif c == b\"4\":\n                emit(KeyEvent.NUM_4)\n            elif c == b\"5\":\n                emit(KeyEvent.NUM_5)\n            elif c == b\"6\":\n                emit(KeyEvent.NUM_6)\n        else:\n            if cancel.is_set():\n                break\n            time.sleep(0.01)\n\n\n_ARROW_KEY_MAP: dict[bytes, KeyEvent] = {\n    b\"\\x1b[A\": KeyEvent.UP,\n    b\"\\x1b[B\": KeyEvent.DOWN,\n    b\"\\x1b[C\": KeyEvent.RIGHT,\n    b\"\\x1b[D\": KeyEvent.LEFT,\n}\n\n_WINDOWS_KEY_MAP: dict[bytes, KeyEvent] = {\n    b\"H\": KeyEvent.UP,  # Up arrow\n    b\"P\": KeyEvent.DOWN,  # Down arrow\n    b\"M\": KeyEvent.RIGHT,  # Right arrow\n    b\"K\": KeyEvent.LEFT,  # Left arrow\n}\n\n\nif __name__ == \"__main__\":\n\n    async def dev_main():\n        async for event in listen_for_keyboard():\n            print(event)\n\n    asyncio.run(dev_main())\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/mcp_status.py",
    "content": "from __future__ import annotations\n\nimport time\n\nfrom prompt_toolkit.formatted_text import FormattedText\nfrom rich.console import Group, RenderableType\nfrom rich.spinner import Spinner\nfrom rich.text import Text\n\nfrom kimi_cli.utils.rich.columns import BulletColumns\nfrom kimi_cli.wire.types import MCPServerSnapshot, MCPStatusSnapshot\n\n_SPINNER_FRAMES = (\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\")\n\n\ndef render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType:\n    header_text = Text.assemble(\n        (\"MCP Servers: \", \"bold\"),\n        f\"{snapshot.connected}/{snapshot.total} connected, {snapshot.tools} tools\",\n    )\n    header: RenderableType = Spinner(\"dots\", header_text) if snapshot.loading else header_text\n\n    renderables: list[RenderableType] = [BulletColumns(header)]\n    for server in snapshot.servers:\n        color = _status_color(server.status)\n        server_text = f\"[{color}]{server.name}[/{color}]\"\n        if server.status == \"unauthorized\":\n            server_text += f\" [grey50](unauthorized - run: kimi mcp auth {server.name})[/grey50]\"\n        elif server.status != \"connected\":\n            server_text += f\" [grey50]({server.status})[/grey50]\"\n\n        lines: list[RenderableType] = [Text.from_markup(server_text)]\n        for tool_name in server.tools:\n            lines.append(\n                BulletColumns(\n                    Text.from_markup(f\"[grey50]{tool_name}[/grey50]\"),\n                    bullet_style=\"grey50\",\n                )\n            )\n        renderables.append(BulletColumns(Group(*lines), bullet_style=color))\n\n    return Group(*renderables)\n\n\ndef render_mcp_prompt(snapshot: MCPStatusSnapshot, *, now: float | None = None) -> FormattedText:\n    if not snapshot.loading:\n        return FormattedText([])\n\n    fragments: list[tuple[str, str]] = []\n    prefix = f\"{_spinner_frame(now)} \" if snapshot.loading else \"\"\n    fragments.append(\n        (\n            \"fg:#d4d4d4\",\n            (\n                f\"{prefix}MCP Servers: \"\n                f\"{snapshot.connected}/{snapshot.total} connected, {snapshot.tools} tools\"\n            ),\n        )\n    )\n    fragments.append((\"\", \"\\n\"))\n\n    for server in snapshot.servers:\n        fragments.append((_prompt_status_style(server.status), f\"• {server.name}\"))\n        detail = _prompt_server_detail(server)\n        if detail:\n            fragments.append((\"fg:#7c8594\", detail))\n        fragments.append((\"\", \"\\n\"))\n\n    return FormattedText(fragments)\n\n\ndef _spinner_frame(now: float | None = None) -> str:\n    timestamp = time.monotonic() if now is None else now\n    return _SPINNER_FRAMES[int(timestamp * 8) % len(_SPINNER_FRAMES)]\n\n\ndef _status_color(status: str) -> str:\n    return {\n        \"connected\": \"green\",\n        \"connecting\": \"cyan\",\n        \"pending\": \"yellow\",\n        \"failed\": \"red\",\n        \"unauthorized\": \"red\",\n    }.get(status, \"red\")\n\n\ndef _prompt_status_style(status: str) -> str:\n    return {\n        \"connected\": \"fg:#56d364\",\n        \"connecting\": \"fg:#56a4ff\",\n        \"pending\": \"fg:#f2cc60\",\n        \"failed\": \"fg:#ff7b72\",\n        \"unauthorized\": \"fg:#ff7b72\",\n    }.get(status, \"fg:#ff7b72\")\n\n\ndef _prompt_server_detail(server: MCPServerSnapshot) -> str:\n    if server.status == \"unauthorized\":\n        return f\" (unauthorized - run: kimi mcp auth {server.name})\"\n\n    parts: list[str] = []\n    if server.status != \"connected\":\n        parts.append(server.status)\n    if server.tools:\n        label = \"tool\" if len(server.tools) == 1 else \"tools\"\n        parts.append(f\"{len(server.tools)} {label}\")\n\n    return f\" ({', '.join(parts)})\" if parts else \"\"\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/oauth.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom typing import TYPE_CHECKING\n\nfrom rich.status import Status\n\nfrom kimi_cli.auth import KIMI_CODE_PLATFORM_ID\nfrom kimi_cli.auth.oauth import login_kimi_code, logout_kimi_code\nfrom kimi_cli.auth.platforms import is_managed_provider_key, parse_managed_provider_key\nfrom kimi_cli.cli import Reload\nfrom kimi_cli.config import save_config\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.setup import select_platform, setup_platform\nfrom kimi_cli.ui.shell.slash import ensure_kimi_soul, registry\n\nif TYPE_CHECKING:\n    from kimi_cli.ui.shell import Shell\n\n\nasync def _login_kimi_code(soul: KimiSoul) -> bool:\n    status: Status | None = None\n    ok = True\n    try:\n        async for event in login_kimi_code(soul.runtime.config):\n            if event.type == \"waiting\":\n                if status is None:\n                    status = console.status(\"[cyan]Waiting for user authorization...[/cyan]\")\n                    status.start()\n                continue\n            if status is not None:\n                status.stop()\n                status = None\n            match event.type:\n                case \"error\":\n                    style = \"red\"\n                case \"success\":\n                    style = \"green\"\n                case _:\n                    style = None\n            console.print(event.message, markup=False, style=style)\n            if event.type == \"error\":\n                ok = False\n    finally:\n        if status is not None:\n            status.stop()\n    return ok\n\n\ndef _current_model_key(soul: KimiSoul) -> str | None:\n    config = soul.runtime.config\n    curr_model_cfg = soul.runtime.llm.model_config if soul.runtime.llm else None\n    if curr_model_cfg is not None:\n        for name, model_cfg in config.models.items():\n            if model_cfg == curr_model_cfg:\n                return name\n    return config.default_model or None\n\n\n@registry.command(aliases=[\"setup\"])\nasync def login(app: Shell, args: str) -> None:\n    \"\"\"Login or setup a platform.\"\"\"\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n    platform = await select_platform()\n    if platform is None:\n        return\n    if platform.id == KIMI_CODE_PLATFORM_ID:\n        ok = await _login_kimi_code(soul)\n    else:\n        ok = await setup_platform(platform)\n    if not ok:\n        return\n    await asyncio.sleep(1)\n    console.clear()\n    raise Reload\n\n\n@registry.command\nasync def logout(app: Shell, args: str) -> None:\n    \"\"\"Logout from the current platform.\"\"\"\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n    config = soul.runtime.config\n    if not config.is_from_default_location:\n        console.print(\n            \"[red]Logout requires the default config file; \"\n            \"restart without --config/--config-file.[/red]\"\n        )\n        return\n    model_key = _current_model_key(soul)\n    if not model_key:\n        console.print(\"[yellow]No model selected; nothing to logout.[/yellow]\")\n        return\n    model_cfg = config.models.get(model_key)\n    if model_cfg is None:\n        console.print(\"[yellow]Current model not found; nothing to logout.[/yellow]\")\n        return\n    provider_key = model_cfg.provider\n    if not is_managed_provider_key(provider_key):\n        console.print(\"[yellow]Current provider is not managed; nothing to logout.[/yellow]\")\n        return\n    platform_id = parse_managed_provider_key(provider_key)\n    if not platform_id:\n        console.print(\"[yellow]Current provider is not managed; nothing to logout.[/yellow]\")\n        return\n\n    if platform_id == KIMI_CODE_PLATFORM_ID:\n        ok = True\n        async for event in logout_kimi_code(config):\n            match event.type:\n                case \"error\":\n                    style = \"red\"\n                case \"success\":\n                    style = \"green\"\n                case _:\n                    style = None\n            console.print(event.message, markup=False, style=style)\n            if event.type == \"error\":\n                ok = False\n        if not ok:\n            return\n    else:\n        if provider_key in config.providers:\n            del config.providers[provider_key]\n        removed_default = False\n        for key, model in list(config.models.items()):\n            if model.provider != provider_key:\n                continue\n            del config.models[key]\n            if config.default_model == key:\n                removed_default = True\n        if removed_default:\n            config.default_model = \"\"\n        save_config(config)\n        console.print(\"[green]✓[/green] Logged out successfully.\")\n\n    await asyncio.sleep(1)\n    console.clear()\n    raise Reload\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/placeholders.py",
    "content": "from __future__ import annotations\n\nimport base64\nimport mimetypes\nimport re\nfrom collections.abc import Callable, Sequence\nfrom dataclasses import dataclass\nfrom difflib import SequenceMatcher\nfrom hashlib import sha256\nfrom io import BytesIO\nfrom pathlib import Path\nfrom typing import Literal, Protocol\n\nfrom PIL import Image\n\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.media_tags import wrap_media_part\nfrom kimi_cli.utils.string import random_string\nfrom kimi_cli.wire.types import ContentPart, ImageURLPart, TextPart\n\n_DEFAULT_PROMPT_CACHE_ROOT = get_share_dir() / \"prompt-cache\"\n_LEGACY_PROMPT_CACHE_ROOT = Path(\"/tmp/kimi\")\n\n_IMAGE_PLACEHOLDER_RE = re.compile(\n    r\"\\[(?P<type>[a-zA-Z0-9_\\-]+):(?P<id>[a-zA-Z0-9_\\-\\.]+)\"\n    r\"(?:,(?P<width>\\d+)x(?P<height>\\d+))?\\]\"\n)\n_PASTED_TEXT_PLACEHOLDER_RE = re.compile(\n    r\"\\[Pasted text #(?P<id>\\d+)(?: \\+(?P<lines>\\d+) lines?)?\\]\"\n)\n\n_TEXT_PASTE_CHAR_THRESHOLD = 1000\n_TEXT_PASTE_LINE_THRESHOLD = 15\n\n\ndef sanitize_surrogates(text: str) -> str:\n    \"\"\"Replace lone UTF-16 surrogates that cannot be encoded as UTF-8.\n\n    Windows clipboard data sometimes contains unpaired surrogates from\n    applications that use UTF-16 internally.  Passing such strings to\n    ``json.dumps`` or writing them to a UTF-8 file raises\n    ``UnicodeEncodeError``, so we replace them with U+FFFD early.\n    \"\"\"\n    return text.encode(\"utf-8\", errors=\"surrogatepass\").decode(\"utf-8\", errors=\"replace\")\n\n\ndef normalize_pasted_text(text: str) -> str:\n    \"\"\"Normalize pasted text into the same newline format used by prompt_toolkit.\"\"\"\n    return text.replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n\n\ndef count_text_lines(text: str) -> int:\n    if not text:\n        return 1\n    return text.count(\"\\n\") + 1\n\n\ndef should_placeholderize_pasted_text(text: str) -> bool:\n    normalized = normalize_pasted_text(text)\n    return (\n        len(normalized) >= _TEXT_PASTE_CHAR_THRESHOLD\n        or count_text_lines(normalized) >= _TEXT_PASTE_LINE_THRESHOLD\n    )\n\n\ndef build_pasted_text_placeholder(paste_id: int, text: str) -> str:\n    line_count = count_text_lines(text)\n    if line_count <= 1:\n        return f\"[Pasted text #{paste_id}]\"\n    return f\"[Pasted text #{paste_id} +{line_count} lines]\"\n\n\ndef _guess_image_mime(path: Path) -> str:\n    mime, _ = mimetypes.guess_type(path.name)\n    if mime:\n        return mime\n    return \"image/png\"\n\n\ndef _build_image_part(image_bytes: bytes, mime_type: str) -> ImageURLPart:\n    image_base64 = base64.b64encode(image_bytes).decode(\"ascii\")\n    return ImageURLPart(\n        image_url=ImageURLPart.ImageURL(\n            url=f\"data:{mime_type};base64,{image_base64}\",\n        )\n    )\n\n\ntype CachedAttachmentKind = Literal[\"image\"]\n\n\n@dataclass(slots=True)\nclass CachedAttachment:\n    kind: CachedAttachmentKind\n    attachment_id: str\n    path: Path\n\n\nclass AttachmentCache:\n    \"\"\"Persistent cache for placeholder payloads that can safely survive history recall.\"\"\"\n\n    def __init__(\n        self,\n        root: Path | None = None,\n        *,\n        legacy_roots: Sequence[Path] | None = None,\n    ) -> None:\n        self._root = root or _DEFAULT_PROMPT_CACHE_ROOT\n        self._legacy_roots = tuple(legacy_roots or (_LEGACY_PROMPT_CACHE_ROOT,))\n        self._dir_map: dict[CachedAttachmentKind, str] = {\"image\": \"images\"}\n        self._payload_map: dict[tuple[CachedAttachmentKind, str, str], CachedAttachment] = {}\n\n    def _dir_for(self, kind: CachedAttachmentKind, *, root: Path | None = None) -> Path:\n        return (self._root if root is None else root) / self._dir_map[kind]\n\n    def _ensure_dir(self, kind: CachedAttachmentKind) -> Path | None:\n        path = self._dir_for(kind)\n        try:\n            path.mkdir(parents=True, exist_ok=True)\n        except OSError as exc:\n            logger.warning(\n                \"Failed to create attachment cache dir: {dir} ({error})\",\n                dir=path,\n                error=exc,\n            )\n            return None\n        return path\n\n    def _reserve_id(self, dir_path: Path, suffix: str) -> str:\n        for _ in range(5):\n            candidate = f\"{random_string(8)}{suffix}\"\n            if not (dir_path / candidate).exists():\n                return candidate\n        return f\"{random_string(12)}{suffix}\"\n\n    def store_bytes(\n        self, kind: CachedAttachmentKind, suffix: str, payload: bytes\n    ) -> CachedAttachment | None:\n        dir_path = self._ensure_dir(kind)\n        if dir_path is None:\n            return None\n\n        payload_hash = sha256(payload).hexdigest()\n        cache_key = (kind, suffix, payload_hash)\n        cached = self._payload_map.get(cache_key)\n        if cached is not None:\n            if cached.path.exists():\n                return cached\n            self._payload_map.pop(cache_key, None)\n\n        attachment_id = self._reserve_id(dir_path, suffix)\n        path = dir_path / attachment_id\n        try:\n            path.write_bytes(payload)\n        except OSError as exc:\n            logger.warning(\n                \"Failed to write cached attachment: {file} ({error})\",\n                file=path,\n                error=exc,\n            )\n            return None\n\n        cached = CachedAttachment(kind=kind, attachment_id=attachment_id, path=path)\n        self._payload_map[cache_key] = cached\n        return cached\n\n    def store_image(self, image: Image.Image) -> CachedAttachment | None:\n        png_bytes = BytesIO()\n        image.save(png_bytes, format=\"PNG\")\n        return self.store_bytes(\"image\", \".png\", png_bytes.getvalue())\n\n    def _candidate_paths(self, kind: CachedAttachmentKind, attachment_id: str) -> list[Path]:\n        roots = (self._root, *self._legacy_roots)\n        return [self._dir_for(kind, root=root) / attachment_id for root in roots]\n\n    def load_bytes(\n        self, kind: CachedAttachmentKind, attachment_id: str\n    ) -> tuple[Path, bytes] | None:\n        for path in self._candidate_paths(kind, attachment_id):\n            if not path.exists():\n                continue\n            try:\n                return path, path.read_bytes()\n            except OSError as exc:\n                logger.warning(\n                    \"Failed to read cached attachment: {file} ({error})\",\n                    file=path,\n                    error=exc,\n                )\n                return None\n        return None\n\n    def load_content_parts(\n        self, kind: CachedAttachmentKind, attachment_id: str\n    ) -> list[ContentPart] | None:\n        if kind == \"image\":\n            payload = self.load_bytes(kind, attachment_id)\n            if payload is None:\n                return None\n            path, image_bytes = payload\n            mime_type = _guess_image_mime(path)\n            part = _build_image_part(image_bytes, mime_type)\n            return wrap_media_part(part, tag=\"image\", attrs={\"path\": str(path)})\n        return None\n\n\ndef parse_attachment_kind(raw_kind: str) -> CachedAttachmentKind | None:\n    if raw_kind == \"image\":\n        return \"image\"\n    return None\n\n\n_parse_attachment_kind = parse_attachment_kind\n\n\n@dataclass(slots=True)\nclass PlaceholderTokenMatch:\n    start: int\n    end: int\n    raw: str\n    handler: PlaceholderHandler\n    match: re.Match[str]\n\n\nclass PlaceholderHandler(Protocol):\n    def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: ...\n\n    def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None: ...\n\n    def expand_text(self, match: PlaceholderTokenMatch) -> str | None: ...\n\n    def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None: ...\n\n    def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None: ...\n\n\n@dataclass(slots=True)\nclass PastedTextEntry:\n    paste_id: int\n    text: str\n\n    @property\n    def token(self) -> str:\n        return build_pasted_text_placeholder(self.paste_id, self.text)\n\n\nclass PastedTextPlaceholderHandler:\n    def __init__(self) -> None:\n        self._entries: dict[int, PastedTextEntry] = {}\n        self._next_id = 1\n\n    def create_placeholder(self, text: str) -> str:\n        normalized = sanitize_surrogates(normalize_pasted_text(text))\n        entry = PastedTextEntry(paste_id=self._next_id, text=normalized)\n        self._entries[entry.paste_id] = entry\n        self._next_id += 1\n        return entry.token\n\n    def maybe_placeholderize(self, text: str) -> str:\n        normalized = normalize_pasted_text(text)\n        if not should_placeholderize_pasted_text(normalized):\n            return normalized\n        return self.create_placeholder(normalized)\n\n    def entry_for_id(self, paste_id: int) -> PastedTextEntry | None:\n        return self._entries.get(paste_id)\n\n    def iter_entries_for_command(\n        self, command: str\n    ) -> list[tuple[PlaceholderTokenMatch, PastedTextEntry]]:\n        entries: list[tuple[PlaceholderTokenMatch, PastedTextEntry]] = []\n        cursor = 0\n        while match := self.find_next(command, cursor):\n            paste_id = int(match.match.group(\"id\"))\n            entry = self.entry_for_id(paste_id)\n            if entry is not None:\n                entries.append((match, entry))\n            cursor = match.end\n        return entries\n\n    def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None:\n        match = _PASTED_TEXT_PLACEHOLDER_RE.search(text, start)\n        if match is None:\n            return None\n        return PlaceholderTokenMatch(\n            start=match.start(),\n            end=match.end(),\n            raw=match.group(0),\n            handler=self,\n            match=match,\n        )\n\n    def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None:\n        paste_id = int(match.match.group(\"id\"))\n        entry = self.entry_for_id(paste_id)\n        if entry is None:\n            return None\n        return [TextPart(text=entry.text)]\n\n    def expand_text(self, match: PlaceholderTokenMatch) -> str | None:\n        paste_id = int(match.match.group(\"id\"))\n        entry = self.entry_for_id(paste_id)\n        return None if entry is None else entry.text\n\n    def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None:\n        return self.expand_text(match)\n\n    def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None:\n        return self.expand_text(match)\n\n    def refold_after_editor(self, edited_text: str, original_command: str) -> str:\n        expanded_original, intervals = self._expanded_text_and_intervals(original_command)\n        if not intervals:\n            return edited_text\n\n        opcodes = SequenceMatcher(\n            a=expanded_original,\n            b=edited_text,\n            autojunk=False,\n        ).get_opcodes()\n        replacements: list[tuple[int, int, str]] = []\n        for start, end, token, expected_text in intervals:\n            mapped = self._map_interval(opcodes, start, end)\n            if mapped is None:\n                continue\n            mapped_start, mapped_end = mapped\n            if edited_text[mapped_start:mapped_end] != expected_text:\n                continue\n            replacements.append((mapped_start, mapped_end, token))\n\n        result = edited_text\n        for start, end, token in reversed(replacements):\n            result = result[:start] + token + result[end:]\n        return result\n\n    def _expanded_text_and_intervals(\n        self, command: str\n    ) -> tuple[str, list[tuple[int, int, str, str]]]:\n        parts: list[str] = []\n        intervals: list[tuple[int, int, str, str]] = []\n        cursor = 0\n        expanded_cursor = 0\n        for match, entry in self.iter_entries_for_command(command):\n            literal = command[cursor : match.start]\n            if literal:\n                parts.append(literal)\n                expanded_cursor += len(literal)\n            start = expanded_cursor\n            parts.append(entry.text)\n            expanded_cursor += len(entry.text)\n            intervals.append((start, expanded_cursor, match.raw, entry.text))\n            cursor = match.end\n        if cursor < len(command):\n            parts.append(command[cursor:])\n        return \"\".join(parts), intervals\n\n    @staticmethod\n    def _map_interval(\n        opcodes: Sequence[tuple[str, int, int, int, int]], start: int, end: int\n    ) -> tuple[int, int] | None:\n        mapped_start: int | None = None\n        mapped_end: int | None = None\n        cursor = start\n        for tag, i1, i2, j1, _j2 in opcodes:\n            if i2 <= cursor:\n                continue\n            if i1 >= end:\n                break\n            overlap_start = max(i1, cursor, start)\n            overlap_end = min(i2, end)\n            if overlap_start >= overlap_end:\n                continue\n            if tag != \"equal\":\n                return None\n            segment_start = j1 + (overlap_start - i1)\n            segment_end = j1 + (overlap_end - i1)\n            if mapped_start is None:\n                mapped_start = segment_start\n            elif mapped_end != segment_start:\n                return None\n            mapped_end = segment_end\n            cursor = overlap_end\n        if cursor != end or mapped_start is None or mapped_end is None:\n            return None\n        return mapped_start, mapped_end\n\n\nclass ImagePlaceholderHandler:\n    def __init__(self, attachment_cache: AttachmentCache) -> None:\n        self._attachment_cache = attachment_cache\n\n    def create_placeholder(self, image: Image.Image) -> str | None:\n        cached = self._attachment_cache.store_image(image)\n        if cached is None:\n            return None\n        return f\"[image:{cached.attachment_id},{image.width}x{image.height}]\"\n\n    def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None:\n        match = _IMAGE_PLACEHOLDER_RE.search(text, start)\n        if match is None:\n            return None\n        return PlaceholderTokenMatch(\n            start=match.start(),\n            end=match.end(),\n            raw=match.group(0),\n            handler=self,\n            match=match,\n        )\n\n    def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None:\n        kind = parse_attachment_kind(match.match.group(\"type\"))\n        if kind is None:\n            return None\n        return self._attachment_cache.load_content_parts(kind, match.match.group(\"id\"))\n\n    def expand_text(self, match: PlaceholderTokenMatch) -> str | None:\n        return match.raw\n\n    def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None:\n        return match.raw\n\n    def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None:\n        return match.raw\n\n\n@dataclass(slots=True)\nclass ResolvedPromptCommand:\n    display_command: str\n    resolved_text: str\n    content: list[ContentPart]\n\n\nclass PromptPlaceholderManager:\n    def __init__(self, attachment_cache: AttachmentCache | None = None) -> None:\n        self._attachment_cache = attachment_cache or AttachmentCache()\n        self._text_handler = PastedTextPlaceholderHandler()\n        self._image_handler = ImagePlaceholderHandler(self._attachment_cache)\n        self._handlers: tuple[PlaceholderHandler, ...] = (\n            self._text_handler,\n            self._image_handler,\n        )\n\n    @property\n    def attachment_cache(self) -> AttachmentCache:\n        return self._attachment_cache\n\n    def maybe_placeholderize_pasted_text(self, text: str) -> str:\n        return self._text_handler.maybe_placeholderize(text)\n\n    def create_image_placeholder(self, image: Image.Image) -> str | None:\n        return self._image_handler.create_placeholder(image)\n\n    def resolve_command(self, command: str) -> ResolvedPromptCommand:\n        content: list[ContentPart] = []\n        resolved_chunks: list[str] = []\n        cursor = 0\n\n        while match := self._find_next_match(command, cursor):\n            if match.start > cursor:\n                literal = command[cursor : match.start]\n                content.append(TextPart(text=literal))\n                resolved_chunks.append(literal)\n\n            resolved_content = match.handler.resolve_content(match)\n            if resolved_content is None:\n                content.append(TextPart(text=match.raw))\n                resolved_chunks.append(match.raw)\n            else:\n                content.extend(resolved_content)\n                expanded = match.handler.expand_text(match)\n                resolved_chunks.append(match.raw if expanded is None else expanded)\n\n            cursor = match.end\n\n        if cursor < len(command):\n            literal = command[cursor:]\n            content.append(TextPart(text=literal))\n            resolved_chunks.append(literal)\n\n        return ResolvedPromptCommand(\n            display_command=command,\n            resolved_text=\"\".join(resolved_chunks),\n            content=content,\n        )\n\n    def serialize_for_history(self, command: str) -> str:\n        return self._rewrite_command(\n            command,\n            lambda handler, match: handler.serialize_for_history(match),\n        )\n\n    def expand_for_editor(self, command: str) -> str:\n        return self._rewrite_command(\n            command,\n            lambda handler, match: handler.expand_for_editor(match),\n        )\n\n    def refold_after_editor(self, edited_text: str, original_command: str) -> str:\n        return self._text_handler.refold_after_editor(edited_text, original_command)\n\n    def _find_next_match(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None:\n        earliest: PlaceholderTokenMatch | None = None\n        for handler in self._handlers:\n            match = handler.find_next(text, start)\n            if match is None:\n                continue\n            if earliest is None or match.start < earliest.start:\n                earliest = match\n        return earliest\n\n    def _rewrite_command(\n        self,\n        command: str,\n        replacer: Callable[[PlaceholderHandler, PlaceholderTokenMatch], str | None],\n    ) -> str:\n        parts: list[str] = []\n        cursor = 0\n\n        while match := self._find_next_match(command, cursor):\n            if match.start > cursor:\n                parts.append(command[cursor : match.start])\n            replacement = replacer(match.handler, match)\n            parts.append(match.raw if replacement is None else replacement)\n            cursor = match.end\n\n        if cursor < len(command):\n            parts.append(command[cursor:])\n\n        return \"\".join(parts)\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/prompt.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\nimport os\nimport re\nimport shlex\nimport subprocess\nimport time\nfrom collections import deque\nfrom collections.abc import Awaitable, Callable, Iterable, Sequence\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom hashlib import md5\nfrom pathlib import Path\nfrom typing import Any, Literal, Protocol, cast, override\n\nfrom kaos.path import KaosPath\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.application.current import get_app_or_none\nfrom prompt_toolkit.buffer import Buffer\nfrom prompt_toolkit.clipboard.pyperclip import PyperclipClipboard\nfrom prompt_toolkit.completion import (\n    CompleteEvent,\n    Completer,\n    Completion,\n    FuzzyCompleter,\n    WordCompleter,\n    merge_completers,\n)\nfrom prompt_toolkit.data_structures import Point\nfrom prompt_toolkit.document import Document\nfrom prompt_toolkit.filters import Condition, has_completions, has_focus, is_done\nfrom prompt_toolkit.formatted_text import AnyFormattedText, FormattedText, to_formatted_text\nfrom prompt_toolkit.history import InMemoryHistory\nfrom prompt_toolkit.key_binding import KeyBindings, KeyPressEvent\nfrom prompt_toolkit.keys import Keys\nfrom prompt_toolkit.layout.containers import (\n    ConditionalContainer,\n    Float,\n    FloatContainer,\n    HSplit,\n    Window,\n)\nfrom prompt_toolkit.layout.controls import UIContent, UIControl\nfrom prompt_toolkit.layout.dimension import Dimension\nfrom prompt_toolkit.layout.menus import CompletionsMenu\nfrom prompt_toolkit.patch_stdout import patch_stdout\nfrom prompt_toolkit.styles import Style\nfrom prompt_toolkit.utils import get_cwidth\nfrom pydantic import BaseModel, ValidationError\n\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.soul import StatusSnapshot, format_context_status\nfrom kimi_cli.ui.shell import placeholders as prompt_placeholders\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.placeholders import (\n    PromptPlaceholderManager,\n    normalize_pasted_text,\n    sanitize_surrogates,\n)\nfrom kimi_cli.utils.clipboard import (\n    grab_media_from_clipboard,\n    is_clipboard_available,\n)\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.slashcmd import SlashCommand\nfrom kimi_cli.wire.types import ContentPart\n\nAttachmentCache = prompt_placeholders.AttachmentCache\nCachedAttachment = prompt_placeholders.CachedAttachment\n_parse_attachment_kind = prompt_placeholders.parse_attachment_kind\n\nPROMPT_SYMBOL = \"✨\"\nPROMPT_SYMBOL_SHELL = \"$\"\nPROMPT_SYMBOL_THINKING = \"💫\"\nPROMPT_SYMBOL_PLAN = \"📋\"\n\n\nclass SlashCommandCompleter(Completer):\n    \"\"\"\n    A completer that:\n    - Shows one line per slash command using the canonical \"/name\"\n    - Fuzzy-matches by primary name or any alias while inserting the canonical \"/name\"\n    - Only activates when the current token starts with '/'\n    \"\"\"\n\n    def __init__(self, available_commands: Sequence[SlashCommand[Any]]) -> None:\n        super().__init__()\n        self._available_commands = list(available_commands)\n        self._command_lookup: dict[str, list[SlashCommand[Any]]] = {}\n        words: list[str] = []\n\n        for cmd in sorted(self._available_commands, key=lambda c: c.name):\n            if cmd.name not in self._command_lookup:\n                self._command_lookup[cmd.name] = []\n                words.append(cmd.name)\n            self._command_lookup[cmd.name].append(cmd)\n            for alias in cmd.aliases:\n                if alias in self._command_lookup:\n                    self._command_lookup[alias].append(cmd)\n                else:\n                    self._command_lookup[alias] = [cmd]\n                    words.append(alias)\n\n        self._word_pattern = re.compile(r\"[^\\s]+\")\n        self._fuzzy_pattern = r\"^[^\\s]*\"\n        self._word_completer = WordCompleter(words, WORD=False, pattern=self._word_pattern)\n        self._fuzzy = FuzzyCompleter(self._word_completer, WORD=False, pattern=self._fuzzy_pattern)\n\n    @staticmethod\n    def should_complete(document: Document) -> bool:\n        \"\"\"Return whether slash command completion should be active for the current buffer.\"\"\"\n        text = document.text_before_cursor\n\n        if document.text_after_cursor.strip():\n            return False\n\n        last_space = text.rfind(\" \")\n        token = text[last_space + 1 :]\n        prefix = text[: last_space + 1] if last_space != -1 else \"\"\n\n        return not prefix.strip() and token.startswith(\"/\")\n\n    @override\n    def get_completions(\n        self, document: Document, complete_event: CompleteEvent\n    ) -> Iterable[Completion]:\n        if not self.should_complete(document):\n            return\n        text = document.text_before_cursor\n        last_space = text.rfind(\" \")\n        token = text[last_space + 1 :]\n\n        typed = token[1:]\n        if typed and typed in self._command_lookup:\n            return\n        mention_doc = Document(text=typed, cursor_position=len(typed))\n        candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))\n\n        seen: set[str] = set()\n\n        for candidate in candidates:\n            commands = self._command_lookup.get(candidate.text)\n            if not commands:\n                continue\n            for cmd in commands:\n                if cmd.name in seen:\n                    continue\n                seen.add(cmd.name)\n                yield Completion(\n                    text=f\"/{cmd.name}\",\n                    start_position=-len(token),\n                    display=f\"/{cmd.name}\",\n                    display_meta=cmd.description,\n                )\n\n\ndef _truncate_to_width(text: str, width: int) -> str:\n    if width <= 0:\n        return \"\"\n\n    total = 0\n    chars: list[str] = []\n    for ch in text:\n        ch_width = get_cwidth(ch)\n        if total + ch_width > width:\n            break\n        chars.append(ch)\n        total += ch_width\n\n    if total == get_cwidth(text):\n        return text + (\" \" * max(0, width - total))\n\n    ellipsis = \"...\"\n    ellipsis_width = get_cwidth(ellipsis)\n    if width <= ellipsis_width:\n        return \".\" * width\n\n    available = width - ellipsis_width\n    total = 0\n    chars = []\n    for ch in text:\n        ch_width = get_cwidth(ch)\n        if total + ch_width > available:\n            break\n        chars.append(ch)\n        total += ch_width\n    return \"\".join(chars) + ellipsis + (\" \" * max(0, width - total - ellipsis_width))\n\n\ndef _wrap_to_width(text: str, width: int, *, max_lines: int | None = None) -> list[str]:\n    if width <= 0:\n        return []\n\n    words = text.split()\n    if not words:\n        return [\"\"]\n\n    lines: list[str] = []\n    current_words: list[str] = []\n    current_width = 0\n    index = 0\n\n    while index < len(words):\n        word = words[index]\n        word_width = get_cwidth(word)\n        separator_width = 1 if current_words else 0\n\n        if current_words and current_width + separator_width + word_width <= width:\n            current_words.append(word)\n            current_width += separator_width + word_width\n            index += 1\n            continue\n\n        if not current_words and word_width <= width:\n            current_words.append(word)\n            current_width = word_width\n            index += 1\n            continue\n\n        if not current_words and word_width > width:\n            current_words.append(_truncate_to_width(word, width).rstrip())\n            current_width = get_cwidth(current_words[0])\n            index += 1\n\n        lines.append(\" \".join(current_words))\n        current_words = []\n        current_width = 0\n\n        if max_lines is not None and len(lines) == max_lines:\n            remaining = \" \".join(words[index:])\n            if remaining:\n                prefix = f\"{lines[-1]} \" if lines[-1] else \"\"\n                lines[-1] = _truncate_to_width(prefix + remaining, width).rstrip()\n            return lines\n\n    if current_words:\n        line = \" \".join(current_words)\n        if max_lines is not None and len(lines) + 1 > max_lines:\n            if lines:\n                lines[-1] = _truncate_to_width(f\"{lines[-1]} {line}\", width).rstrip()\n            else:\n                lines.append(_truncate_to_width(line, width).rstrip())\n        else:\n            lines.append(line)\n\n    return lines\n\n\ndef _find_prompt_float_container(layout_container: object) -> FloatContainer | None:\n    if not isinstance(layout_container, HSplit):\n        return None\n\n    for child in cast(Sequence[object], layout_container.children):\n        float_container = _extract_float_container(child)\n        if float_container is not None:\n            return float_container\n    return None\n\n\ndef _extract_float_container(container: object) -> FloatContainer | None:\n    if isinstance(container, FloatContainer):\n        return container\n    if isinstance(container, ConditionalContainer):\n        if isinstance(container.content, FloatContainer):\n            return container.content\n        if isinstance(container.alternative_content, FloatContainer):\n            return container.alternative_content\n    return None\n\n\nclass SlashCommandMenuControl(UIControl):\n    \"\"\"Render slash command completions as a full-width menu that matches the shell UI.\"\"\"\n\n    _MAX_EXPANDED_META_LINES = 3\n\n    def __init__(\n        self,\n        *,\n        left_padding: Callable[[], int],\n        scroll_offset: int = 1,\n    ) -> None:\n        self._left_padding = left_padding\n        self._scroll_offset = scroll_offset\n\n    def has_focus(self) -> bool:\n        return False\n\n    def preferred_width(self, max_available_width: int) -> int | None:\n        return max_available_width\n\n    def preferred_height(\n        self,\n        width: int,\n        max_available_height: int,\n        wrap_lines: bool,\n        get_line_prefix: Callable[..., AnyFormattedText] | None,\n    ) -> int | None:\n        app = get_app_or_none()\n        complete_state = (\n            getattr(app.current_buffer, \"complete_state\", None) if app is not None else None\n        )\n        if complete_state is None:\n            return 0\n        completions = complete_state.completions\n        selected_index = complete_state.complete_index\n        if selected_index is None:\n            return min(max_available_height, len(completions) + 1)\n        menu_width = max(0, width - self._left_padding())\n        marker_width = 2\n        command_width = self._command_column_width(completions, menu_width, marker_width)\n        gap_width = 3 if menu_width > command_width + 6 else 1\n        meta_width = max(0, menu_width - marker_width - command_width - gap_width)\n        selected_meta_lines = self._selected_meta_lines(\n            completions[selected_index].display_meta_text,\n            meta_width,\n        )\n        return min(max_available_height, len(completions) + len(selected_meta_lines))\n\n    def create_content(self, width: int, height: int) -> UIContent:\n        app = get_app_or_none()\n        complete_state = (\n            getattr(app.current_buffer, \"complete_state\", None) if app is not None else None\n        )\n        if complete_state is None or not complete_state.completions:\n            return UIContent()\n\n        completions = complete_state.completions\n        selected_index = complete_state.complete_index\n        available_rows = max(1, height - 1)\n\n        menu_width = max(0, width - self._left_padding())\n        marker_width = 2\n        command_width = self._command_column_width(completions, menu_width, marker_width)\n        gap_width = 3 if menu_width > command_width + 6 else 1\n        meta_width = max(0, menu_width - marker_width - command_width - gap_width)\n\n        rendered_lines: list[FormattedText] = [\n            FormattedText([(\"class:slash-completion-menu.separator\", \"─\" * max(0, width))])\n        ]\n        selected_line_index = 0\n\n        if selected_index is None:\n            end = min(len(completions) - 1, available_rows - 1)\n            for index in range(0, end + 1):\n                rendered_lines.append(\n                    self._render_single_line_item(\n                        width=width,\n                        completion=completions[index],\n                        marker_width=marker_width,\n                        command_width=command_width,\n                        meta_width=meta_width,\n                        gap_width=gap_width,\n                        is_current=False,\n                    )\n                )\n\n            return UIContent(\n                get_line=lambda i: rendered_lines[i],\n                line_count=len(rendered_lines),\n                cursor_position=Point(x=0, y=selected_line_index),\n            )\n\n        selected_meta_lines = self._selected_meta_lines(\n            completions[selected_index].display_meta_text,\n            meta_width,\n        )\n        start, end = self._visible_window_bounds(\n            completion_count=len(completions),\n            selected_index=selected_index,\n            available_rows=available_rows,\n            selected_item_height=len(selected_meta_lines),\n        )\n        selected_line_index = 1\n\n        for index in range(start, end + 1):\n            completion = completions[index]\n            if index == selected_index:\n                selected_line_index = len(rendered_lines)\n                rendered_lines.extend(\n                    self._render_selected_item_lines(\n                        width=width,\n                        completion=completion,\n                        marker_width=marker_width,\n                        command_width=command_width,\n                        meta_width=meta_width,\n                        gap_width=gap_width,\n                        meta_lines=selected_meta_lines,\n                    )\n                )\n                continue\n\n            rendered_lines.append(\n                self._render_single_line_item(\n                    width=width,\n                    completion=completion,\n                    marker_width=marker_width,\n                    command_width=command_width,\n                    meta_width=meta_width,\n                    gap_width=gap_width,\n                    is_current=False,\n                )\n            )\n\n        return UIContent(\n            get_line=lambda i: rendered_lines[i],\n            line_count=len(rendered_lines),\n            cursor_position=Point(x=0, y=selected_line_index),\n        )\n\n    def _selected_meta_lines(self, text: str, meta_width: int) -> list[str]:\n        lines = _wrap_to_width(\n            text,\n            meta_width,\n            max_lines=self._MAX_EXPANDED_META_LINES,\n        )\n        return lines or [\"\"]\n\n    def _visible_window_bounds(\n        self,\n        *,\n        completion_count: int,\n        selected_index: int,\n        available_rows: int,\n        selected_item_height: int,\n    ) -> tuple[int, int]:\n        selected_item_height = min(selected_item_height, available_rows)\n        remaining_rows = max(0, available_rows - selected_item_height)\n\n        before = min(self._scroll_offset, selected_index, remaining_rows)\n        remaining_rows -= before\n        after = min(completion_count - selected_index - 1, remaining_rows)\n        remaining_rows -= after\n\n        extra_before = min(selected_index - before, remaining_rows)\n        before += extra_before\n        remaining_rows -= extra_before\n\n        extra_after = min(completion_count - selected_index - 1 - after, remaining_rows)\n        after += extra_after\n\n        return selected_index - before, selected_index + after\n\n    def _command_column_width(\n        self,\n        completions: Sequence[Completion],\n        menu_width: int,\n        marker_width: int,\n    ) -> int:\n        if menu_width <= 0:\n            return 0\n        longest = max((get_cwidth(c.display_text) for c in completions), default=0)\n        preferred = longest + 2\n        usable_width = max(0, menu_width - marker_width)\n        minimum = min(usable_width, 18)\n        maximum = max(minimum, min(28, usable_width // 2))\n        return max(minimum, min(preferred, maximum))\n\n    def _render_single_line_item(\n        self,\n        *,\n        width: int,\n        completion: Completion,\n        marker_width: int,\n        command_width: int,\n        meta_width: int,\n        gap_width: int,\n        is_current: bool,\n    ) -> FormattedText:\n        padding_width = max(0, width - marker_width - command_width - meta_width - gap_width)\n        left_padding = min(self._left_padding(), padding_width)\n        trailing_width = max(\n            0,\n            width - left_padding - marker_width - command_width - gap_width - meta_width,\n        )\n\n        command_style = (\n            \"class:slash-completion-menu.command.current\"\n            if is_current\n            else \"class:slash-completion-menu.command\"\n        )\n        meta_style = (\n            \"class:slash-completion-menu.meta.current\"\n            if is_current\n            else \"class:slash-completion-menu.meta\"\n        )\n        marker_style = (\n            \"class:slash-completion-menu.marker.current\"\n            if is_current\n            else \"class:slash-completion-menu.marker\"\n        )\n        marker = \"› \" if is_current else \"  \"\n\n        fragments: FormattedText = FormattedText()\n        fragments.append((\"class:slash-completion-menu\", \" \" * left_padding))\n        fragments.append((marker_style, marker.ljust(marker_width)))\n        fragments.append(\n            (command_style, _truncate_to_width(completion.display_text, command_width))\n        )\n        fragments.append((\"class:slash-completion-menu\", \" \" * gap_width))\n        fragments.append((meta_style, _truncate_to_width(completion.display_meta_text, meta_width)))\n        fragments.append((\"class:slash-completion-menu\", \" \" * trailing_width))\n        return fragments\n\n    def _render_selected_item_lines(\n        self,\n        *,\n        width: int,\n        completion: Completion,\n        marker_width: int,\n        command_width: int,\n        meta_width: int,\n        gap_width: int,\n        meta_lines: Sequence[str],\n    ) -> list[FormattedText]:\n        lines = [\n            self._render_single_line_item(\n                width=width,\n                completion=Completion(\n                    text=completion.text,\n                    start_position=completion.start_position,\n                    display=completion.display,\n                    display_meta=meta_lines[0],\n                ),\n                marker_width=marker_width,\n                command_width=command_width,\n                meta_width=meta_width,\n                gap_width=gap_width,\n                is_current=True,\n            )\n        ]\n\n        continuation_prefix = (\n            \" \" * self._left_padding() + \" \" * marker_width + \" \" * command_width + \" \" * gap_width\n        )\n        continuation_trailing = max(\n            0,\n            width - get_cwidth(continuation_prefix) - meta_width,\n        )\n        for meta_line in meta_lines[1:]:\n            fragments: FormattedText = FormattedText()\n            fragments.append((\"class:slash-completion-menu\", continuation_prefix))\n            fragments.append(\n                (\n                    \"class:slash-completion-menu.meta.current\",\n                    _truncate_to_width(meta_line, meta_width),\n                )\n            )\n            fragments.append((\"class:slash-completion-menu\", \" \" * continuation_trailing))\n            lines.append(fragments)\n\n        return lines\n\n\nclass LocalFileMentionCompleter(Completer):\n    \"\"\"Offer fuzzy `@` path completion by indexing workspace files.\"\"\"\n\n    _FRAGMENT_PATTERN = re.compile(r\"[^\\s@]+\")\n    _TRIGGER_GUARDS = frozenset((\".\", \"-\", \"_\", \"`\", \"'\", '\"', \":\", \"@\", \"#\", \"~\"))\n    _IGNORED_NAME_GROUPS: dict[str, tuple[str, ...]] = {\n        \"vcs_metadata\": (\".DS_Store\", \".bzr\", \".git\", \".hg\", \".svn\"),\n        \"tooling_caches\": (\n            \".build\",\n            \".cache\",\n            \".coverage\",\n            \".fleet\",\n            \".gradle\",\n            \".idea\",\n            \".ipynb_checkpoints\",\n            \".pnpm-store\",\n            \".pytest_cache\",\n            \".pub-cache\",\n            \".ruff_cache\",\n            \".swiftpm\",\n            \".tox\",\n            \".venv\",\n            \".vs\",\n            \".vscode\",\n            \".yarn\",\n            \".yarn-cache\",\n        ),\n        \"js_frontend\": (\n            \".next\",\n            \".nuxt\",\n            \".parcel-cache\",\n            \".svelte-kit\",\n            \".turbo\",\n            \".vercel\",\n            \"node_modules\",\n        ),\n        \"python_packaging\": (\n            \"__pycache__\",\n            \"build\",\n            \"coverage\",\n            \"dist\",\n            \"htmlcov\",\n            \"pip-wheel-metadata\",\n            \"venv\",\n        ),\n        \"java_jvm\": (\".mvn\", \"out\", \"target\"),\n        \"dotnet_native\": (\"bin\", \"cmake-build-debug\", \"cmake-build-release\", \"obj\"),\n        \"bazel_buck\": (\"bazel-bin\", \"bazel-out\", \"bazel-testlogs\", \"buck-out\"),\n        \"misc_artifacts\": (\n            \".dart_tool\",\n            \".serverless\",\n            \".stack-work\",\n            \".terraform\",\n            \".terragrunt-cache\",\n            \"DerivedData\",\n            \"Pods\",\n            \"deps\",\n            \"tmp\",\n            \"vendor\",\n        ),\n    }\n    _IGNORED_NAMES = frozenset(name for group in _IGNORED_NAME_GROUPS.values() for name in group)\n    _IGNORED_PATTERN_PARTS: tuple[str, ...] = (\n        r\".*_cache$\",\n        r\".*-cache$\",\n        r\".*\\.egg-info$\",\n        r\".*\\.dist-info$\",\n        r\".*\\.py[co]$\",\n        r\".*\\.class$\",\n        r\".*\\.sw[po]$\",\n        r\".*~$\",\n        r\".*\\.(?:tmp|bak)$\",\n    )\n    _IGNORED_PATTERNS = re.compile(\n        \"|\".join(f\"(?:{part})\" for part in _IGNORED_PATTERN_PARTS),\n        re.IGNORECASE,\n    )\n\n    def __init__(\n        self,\n        root: Path,\n        *,\n        refresh_interval: float = 2.0,\n        limit: int = 1000,\n    ) -> None:\n        self._root = root\n        self._refresh_interval = refresh_interval\n        self._limit = limit\n        self._cache_time: float = 0.0\n        self._cached_paths: list[str] = []\n        self._top_cache_time: float = 0.0\n        self._top_cached_paths: list[str] = []\n        self._fragment_hint: str | None = None\n\n        self._word_completer = WordCompleter(\n            self._get_paths,\n            WORD=False,\n            pattern=self._FRAGMENT_PATTERN,\n        )\n\n        self._fuzzy = FuzzyCompleter(\n            self._word_completer,\n            WORD=False,\n            pattern=r\"^[^\\s@]*\",\n        )\n\n    @classmethod\n    def _is_ignored(cls, name: str) -> bool:\n        if not name:\n            return True\n        if name in cls._IGNORED_NAMES:\n            return True\n        return bool(cls._IGNORED_PATTERNS.fullmatch(name))\n\n    def _get_paths(self) -> list[str]:\n        fragment = self._fragment_hint or \"\"\n        if \"/\" not in fragment and len(fragment) < 3:\n            return self._get_top_level_paths()\n        return self._get_deep_paths()\n\n    def _get_top_level_paths(self) -> list[str]:\n        now = time.monotonic()\n        if now - self._top_cache_time <= self._refresh_interval:\n            return self._top_cached_paths\n\n        entries: list[str] = []\n        try:\n            for entry in sorted(self._root.iterdir(), key=lambda p: p.name):\n                name = entry.name\n                if self._is_ignored(name):\n                    continue\n                entries.append(f\"{name}/\" if entry.is_dir() else name)\n                if len(entries) >= self._limit:\n                    break\n        except OSError:\n            return self._top_cached_paths\n\n        self._top_cached_paths = entries\n        self._top_cache_time = now\n        return self._top_cached_paths\n\n    def _get_deep_paths(self) -> list[str]:\n        now = time.monotonic()\n        if now - self._cache_time <= self._refresh_interval:\n            return self._cached_paths\n\n        paths: list[str] = []\n        try:\n            for current_root, dirs, files in os.walk(self._root):\n                relative_root = Path(current_root).relative_to(self._root)\n\n                # Prevent descending into ignored directories.\n                dirs[:] = sorted(d for d in dirs if not self._is_ignored(d))\n\n                if relative_root.parts and any(\n                    self._is_ignored(part) for part in relative_root.parts\n                ):\n                    dirs[:] = []\n                    continue\n\n                if relative_root.parts:\n                    paths.append(relative_root.as_posix() + \"/\")\n                    if len(paths) >= self._limit:\n                        break\n\n                for file_name in sorted(files):\n                    if self._is_ignored(file_name):\n                        continue\n                    relative = (relative_root / file_name).as_posix()\n                    if not relative:\n                        continue\n                    paths.append(relative)\n                    if len(paths) >= self._limit:\n                        break\n\n                if len(paths) >= self._limit:\n                    break\n        except OSError:\n            return self._cached_paths\n\n        self._cached_paths = paths\n        self._cache_time = now\n        return self._cached_paths\n\n    @staticmethod\n    def _extract_fragment(text: str) -> str | None:\n        index = text.rfind(\"@\")\n        if index == -1:\n            return None\n\n        if index > 0:\n            prev = text[index - 1]\n            if prev.isalnum() or prev in LocalFileMentionCompleter._TRIGGER_GUARDS:\n                return None\n\n        fragment = text[index + 1 :]\n        if not fragment:\n            return \"\"\n\n        if any(ch.isspace() for ch in fragment):\n            return None\n\n        return fragment\n\n    def _is_completed_file(self, fragment: str) -> bool:\n        candidate = fragment.rstrip(\"/\")\n        if not candidate:\n            return False\n        try:\n            return (self._root / candidate).is_file()\n        except OSError:\n            return False\n\n    @override\n    def get_completions(\n        self, document: Document, complete_event: CompleteEvent\n    ) -> Iterable[Completion]:\n        fragment = self._extract_fragment(document.text_before_cursor)\n        if fragment is None:\n            return\n        if self._is_completed_file(fragment):\n            return\n\n        mention_doc = Document(text=fragment, cursor_position=len(fragment))\n        self._fragment_hint = fragment\n        try:\n            # First, ask the fuzzy completer for candidates.\n            candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))\n\n            # re-rank: prefer basename matches\n            frag_lower = fragment.lower()\n\n            def _rank(c: Completion) -> tuple[int, ...]:\n                path = c.text\n                base = path.rstrip(\"/\").split(\"/\")[-1].lower()\n                if base.startswith(frag_lower):\n                    cat = 0\n                elif frag_lower in base:\n                    cat = 1\n                else:\n                    cat = 2\n                # preserve original FuzzyCompleter's order in the same category\n                return (cat,)\n\n            candidates.sort(key=_rank)\n            yield from candidates\n        finally:\n            self._fragment_hint = None\n\n\nclass _HistoryEntry(BaseModel):\n    content: str\n\n\ndef _load_history_entries(history_file: Path) -> list[_HistoryEntry]:\n    entries: list[_HistoryEntry] = []\n    if not history_file.exists():\n        return entries\n\n    try:\n        with history_file.open(encoding=\"utf-8\") as f:\n            for raw_line in f:\n                line = raw_line.strip()\n                if not line:\n                    continue\n                try:\n                    record = json.loads(line)\n                except json.JSONDecodeError:\n                    logger.warning(\n                        \"Failed to parse user history line; skipping: {line}\",\n                        line=line,\n                    )\n                    continue\n                try:\n                    entry = _HistoryEntry.model_validate(record)\n                    entries.append(entry)\n                except ValidationError:\n                    logger.warning(\n                        \"Failed to validate user history entry; skipping: {line}\",\n                        line=line,\n                    )\n                    continue\n    except OSError as exc:\n        logger.warning(\n            \"Failed to load user history file: {file} ({error})\",\n            file=history_file,\n            error=exc,\n        )\n\n    return entries\n\n\nclass PromptMode(Enum):\n    AGENT = \"agent\"\n    SHELL = \"shell\"\n\n    def toggle(self) -> PromptMode:\n        return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT\n\n    def __str__(self) -> str:\n        return self.value\n\n\nclass UserInput(BaseModel):\n    mode: PromptMode\n    command: str\n    \"\"\"The plain text representation of the user input.\"\"\"\n    resolved_command: str\n    \"\"\"The text command after UI-only placeholders are expanded.\"\"\"\n    content: list[ContentPart]\n    \"\"\"The rich content parts.\"\"\"\n\n    def __str__(self) -> str:\n        return self.command\n\n    def __bool__(self) -> bool:\n        return bool(self.command)\n\n\n_IDLE_REFRESH_INTERVAL = 1.0\n_RUNNING_REFRESH_INTERVAL = 0.1\n\n_GIT_BRANCH_TTL = 5.0\n_GIT_STATUS_TTL = 15.0\n_TIP_ROTATE_INTERVAL = 30.0\n_MAX_CWD_COLS = 30\n_MAX_BRANCH_COLS = 22\n\n\n@dataclass\nclass _GitBranchState:\n    timestamp: float = 0.0\n    branch: str | None = None\n    proc: subprocess.Popen[str] | None = None\n\n\n@dataclass\nclass _GitStatusState:\n    timestamp: float = 0.0\n    dirty: bool = False\n    ahead: int = 0\n    behind: int = 0\n    proc: subprocess.Popen[str] | None = None\n\n\n_git_branch_state = _GitBranchState()\n_git_status_state = _GitStatusState()\n\n_GIT_STATUS_AB_RE = re.compile(r\"\\[(?:ahead (\\d+))?(?:, )?(?:behind (\\d+))?\\]\")\n\n\ndef _get_git_branch() -> str | None:\n    \"\"\"Return the current git branch name via a non-blocking cached subprocess.\"\"\"\n    state = _git_branch_state\n    now = time.monotonic()\n\n    # Collect result if a previously launched process has finished\n    if state.proc is not None:\n        returncode = state.proc.poll()\n        if returncode is not None:\n            try:\n                stdout, _ = state.proc.communicate()\n                new_branch = stdout.strip() or None\n                # Branch changed — discard any in-flight status subprocess so it cannot\n                # write stale results for the old branch, then force an immediate refresh.\n                if new_branch != state.branch:\n                    if _git_status_state.proc is not None:\n                        with contextlib.suppress(Exception):\n                            _git_status_state.proc.terminate()\n                        _git_status_state.proc = None\n                    _git_status_state.timestamp = 0.0\n                state.branch = new_branch\n            except Exception:\n                state.branch = None\n            state.proc = None\n\n    # Launch a new process when the TTL has expired and nothing is running\n    if state.timestamp + _GIT_BRANCH_TTL <= now and state.proc is None:\n        state.timestamp = now\n        try:\n            state.proc = subprocess.Popen(\n                [\"git\", \"branch\", \"--show-current\"],\n                stdout=subprocess.PIPE,\n                stderr=subprocess.DEVNULL,\n                text=True,\n            )\n        except Exception:\n            state.branch = None\n\n    return state.branch\n\n\ndef _get_git_status() -> tuple[bool, int, int]:\n    \"\"\"Return (dirty, ahead, behind) via a non-blocking cached subprocess.\n\n    Runs ``git status --porcelain -b`` (includes untracked files so newly created\n    files show as dirty).  TTL is longer than the branch check because file-tree\n    scanning is expensive.\n    \"\"\"\n    state = _git_status_state\n    now = time.monotonic()\n\n    if state.proc is not None:\n        returncode = state.proc.poll()\n        if returncode is not None:\n            try:\n                stdout, _ = state.proc.communicate()\n                dirty = False\n                ahead = 0\n                behind = 0\n                for line in stdout.splitlines():\n                    if line.startswith(\"## \"):\n                        m = _GIT_STATUS_AB_RE.search(line)\n                        if m:\n                            ahead = int(m.group(1) or 0)\n                            behind = int(m.group(2) or 0)\n                    elif line.strip():\n                        dirty = True\n                state.dirty = dirty\n                state.ahead = ahead\n                state.behind = behind\n            except Exception:\n                pass\n            state.proc = None\n        elif now - state.timestamp > _GIT_STATUS_TTL:\n            # Subprocess is stuck (e.g. OS pipe buffer full from many untracked files).\n            # Terminate it so the toolbar is not permanently frozen; retry after next TTL.\n            with contextlib.suppress(Exception):\n                state.proc.terminate()\n            state.proc = None\n            state.timestamp = now  # delay next spawn by one full TTL\n\n    if state.timestamp + _GIT_STATUS_TTL <= now and state.proc is None:\n        state.timestamp = now\n        with contextlib.suppress(Exception):\n            state.proc = subprocess.Popen(\n                [\"git\", \"status\", \"--porcelain\", \"-b\"],\n                stdout=subprocess.PIPE,\n                stderr=subprocess.DEVNULL,\n                text=True,\n            )\n\n    return state.dirty, state.ahead, state.behind\n\n\ndef _format_git_badge(branch: str, dirty: bool, ahead: int, behind: int) -> str:\n    \"\"\"Format branch name with an optional status badge: ``main [± ↑3↓1]``.\"\"\"\n    parts: list[str] = []\n    if dirty:\n        parts.append(\"±\")\n    sync = \"\"\n    if ahead:\n        sync += f\"↑{ahead}\"\n    if behind:\n        sync += f\"↓{behind}\"\n    if sync:\n        parts.append(sync)\n    if not parts:\n        return branch\n    return f\"{branch} [{' '.join(parts)}]\"\n\n\ndef _shorten_cwd(path: str) -> str:\n    \"\"\"Replace the home directory prefix in *path* with ``~``.\"\"\"\n    home = str(Path.home())\n    if path == home:\n        return \"~\"\n    if path.startswith(home + os.sep):\n        return \"~\" + path[len(home) :]\n    return path\n\n\ndef _display_width(text: str) -> int:\n    \"\"\"Return the terminal column width of *text*, handling wide Unicode characters.\"\"\"\n    return sum(get_cwidth(c) for c in text)\n\n\ndef _truncate_left(text: str, max_cols: int) -> str:\n    \"\"\"Truncate *text* from the left, prepending '…' if it exceeds *max_cols*.\"\"\"\n    if max_cols <= 0:\n        return \"\"\n    if _display_width(text) <= max_cols:\n        return text\n    ellipsis = \"…\"\n    budget = max_cols - _display_width(ellipsis)\n    chars: list[str] = []\n    width = 0\n    for ch in reversed(text):\n        w = get_cwidth(ch)\n        if width + w > budget:\n            break\n        chars.append(ch)\n        width += w\n    return ellipsis + \"\".join(reversed(chars))\n\n\ndef _truncate_right(text: str, max_cols: int) -> str:\n    \"\"\"Truncate *text* from the right, appending '…' if it exceeds *max_cols*.\"\"\"\n    if max_cols <= 0:\n        return \"\"\n    if _display_width(text) <= max_cols:\n        return text\n    ellipsis = \"…\"\n    budget = max_cols - _display_width(ellipsis)\n    chars: list[str] = []\n    width = 0\n    for ch in text:\n        w = get_cwidth(ch)\n        if width + w > budget:\n            break\n        chars.append(ch)\n        width += w\n    return \"\".join(chars) + ellipsis\n\n\n@dataclass(slots=True)\nclass _ToastEntry:\n    topic: str | None\n    \"\"\"There can be only one toast of each non-None topic in the queue.\"\"\"\n    message: str\n    expires_at: float\n\n\nclass RunningPromptDelegate(Protocol):\n    def render_running_prompt_body(self, columns: int) -> AnyFormattedText: ...\n\n    def running_prompt_placeholder(self) -> AnyFormattedText | None: ...\n\n    def should_handle_running_prompt_key(self, key: str) -> bool: ...\n\n    def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: ...\n\n\n_toast_queues: dict[Literal[\"left\", \"right\"], deque[_ToastEntry]] = {\n    \"left\": deque(),\n    \"right\": deque(),\n}\n\"\"\"The queue of toasts to show, including the one currently being shown (the first one).\"\"\"\n\n\ndef toast(\n    message: str,\n    duration: float = 5.0,\n    topic: str | None = None,\n    immediate: bool = False,\n    position: Literal[\"left\", \"right\"] = \"left\",\n) -> None:\n    queue = _toast_queues[position]\n    duration = max(duration, _IDLE_REFRESH_INTERVAL)\n    entry = _ToastEntry(topic=topic, message=message, expires_at=time.monotonic() + duration)\n    if topic is not None:\n        # Remove existing toasts with the same topic\n        for existing in list(queue):\n            if existing.topic == topic:\n                queue.remove(existing)\n    if immediate:\n        queue.appendleft(entry)\n    else:\n        queue.append(entry)\n\n\ndef _current_toast(position: Literal[\"left\", \"right\"] = \"left\") -> _ToastEntry | None:\n    queue = _toast_queues[position]\n    now = time.monotonic()\n    while queue and queue[0].expires_at <= now:\n        queue.popleft()\n    if not queue:\n        return None\n    return queue[0]\n\n\ndef _build_toolbar_tips(clipboard_available: bool) -> list[str]:\n    tips = [\n        \"ctrl-x: toggle mode\",\n        \"shift-tab: plan mode\",\n        \"ctrl-o: editor\",\n        \"ctrl-j: newline\",\n    ]\n    if clipboard_available:\n        tips.append(\"ctrl-v: paste clipboard\")\n    tips.append(\"@: mention files\")\n    return tips\n\n\n_TIP_SEPARATOR = \" | \"\n\n\nclass CustomPromptSession:\n    def __init__(\n        self,\n        *,\n        status_provider: Callable[[], StatusSnapshot],\n        status_block_provider: Callable[[int], AnyFormattedText | None] | None = None,\n        fast_refresh_provider: Callable[[], bool] | None = None,\n        background_task_count_provider: Callable[[], int] | None = None,\n        model_capabilities: set[ModelCapability],\n        model_name: str | None,\n        thinking: bool,\n        agent_mode_slash_commands: Sequence[SlashCommand[Any]],\n        shell_mode_slash_commands: Sequence[SlashCommand[Any]],\n        editor_command_provider: Callable[[], str] = lambda: \"\",\n        plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None,\n    ) -> None:\n        history_dir = get_share_dir() / \"user-history\"\n        history_dir.mkdir(parents=True, exist_ok=True)\n        work_dir_id = md5(str(KaosPath.cwd()).encode(encoding=\"utf-8\")).hexdigest()\n        self._history_file = (history_dir / work_dir_id).with_suffix(\".jsonl\")\n        self._status_provider = status_provider\n        self._status_block_provider = status_block_provider\n        self._fast_refresh_provider = fast_refresh_provider\n        self._background_task_count_provider = background_task_count_provider\n        self._editor_command_provider = editor_command_provider\n        self._plan_mode_toggle_callback = plan_mode_toggle_callback\n        self._model_capabilities = model_capabilities\n        self._model_name = model_name\n        self._last_history_content: str | None = None\n        self._mode: PromptMode = PromptMode.AGENT\n        self._thinking = thinking\n        self._placeholder_manager = PromptPlaceholderManager()\n        # Keep the old attribute for test compatibility and for any external imports.\n        self._attachment_cache = self._placeholder_manager.attachment_cache\n        self._tip_rotation_index: int = 0\n        self._last_tip_rotate_time: float = time.monotonic()\n        self._last_submission_was_running = False\n        self._running_prompt_previous_mode: PromptMode | None = None\n        self._running_prompt_delegate: RunningPromptDelegate | None = None\n        clipboard_available = is_clipboard_available()\n        self._tips = _build_toolbar_tips(clipboard_available)\n\n        history_entries = _load_history_entries(self._history_file)\n        history = InMemoryHistory()\n        for entry in history_entries:\n            history.append_string(entry.content)\n\n        if history_entries:\n            # for consecutive deduplication\n            self._last_history_content = history_entries[-1].content\n\n        # Build completers\n        self._agent_mode_completer = merge_completers(\n            [\n                SlashCommandCompleter(agent_mode_slash_commands),\n                # TODO(kaos): we need an async KaosFileMentionCompleter\n                LocalFileMentionCompleter(KaosPath.cwd().unsafe_to_local_path()),\n            ],\n            deduplicate=True,\n        )\n        self._shell_mode_completer = SlashCommandCompleter(shell_mode_slash_commands)\n\n        # Build key bindings\n        _kb = KeyBindings()\n\n        @_kb.add(\"enter\", filter=has_completions)\n        def _(event: KeyPressEvent) -> None:\n            \"\"\"Accept the first completion when Enter is pressed and completions are shown.\"\"\"\n            buff = event.current_buffer\n            if buff.complete_state and buff.complete_state.completions:\n                # Get the current completion, or use the first one if none is selected\n                completion = buff.complete_state.current_completion\n                if not completion:\n                    completion = buff.complete_state.completions[0]\n                buff.apply_completion(completion)\n\n        @_kb.add(\"c-x\", eager=True)\n        def _(event: KeyPressEvent) -> None:\n            if self._running_prompt_delegate is not None:\n                return\n            self._mode = self._mode.toggle()\n            # Apply mode-specific settings\n            self._apply_mode(event)\n            # Redraw UI\n            event.app.invalidate()\n\n        @_kb.add(\"s-tab\", eager=True)\n        def _(event: KeyPressEvent) -> None:\n            \"\"\"Toggle plan mode with Shift+Tab.\"\"\"\n            if self._running_prompt_delegate is not None:\n                return\n            if self._plan_mode_toggle_callback is not None:\n\n                async def _toggle() -> None:\n                    assert self._plan_mode_toggle_callback is not None\n                    new_state = await self._plan_mode_toggle_callback()\n                    if new_state:\n                        toast(\"plan mode ON\", topic=\"plan_mode\", duration=3.0, immediate=True)\n                    else:\n                        toast(\"plan mode OFF\", topic=\"plan_mode\", duration=3.0, immediate=True)\n                    event.app.invalidate()\n\n                event.app.create_background_task(_toggle())\n            event.app.invalidate()\n\n        @_kb.add(\"escape\", \"enter\", eager=True)\n        @_kb.add(\"c-j\", eager=True)\n        def _(event: KeyPressEvent) -> None:\n            \"\"\"Insert a newline when Alt-Enter or Ctrl-J is pressed.\"\"\"\n            event.current_buffer.insert_text(\"\\n\")\n\n        @_kb.add(\"c-o\", eager=True)\n        def _(event: KeyPressEvent) -> None:\n            \"\"\"Open current buffer in external editor.\"\"\"\n            self._open_in_external_editor(event)\n\n        @_kb.add(\n            \"up\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"up\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"up\", event)\n\n        @_kb.add(\n            \"down\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"down\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"down\", event)\n\n        @_kb.add(\n            \"left\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"left\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"left\", event)\n\n        @_kb.add(\n            \"right\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"right\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"right\", event)\n\n        @_kb.add(\n            \"tab\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"tab\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"tab\", event)\n\n        @_kb.add(\n            \"enter\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"enter\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"enter\", event)\n\n        @_kb.add(\n            \"space\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"space\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"space\", event)\n\n        @_kb.add(\n            \"c-e\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"c-e\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"c-e\", event)\n\n        @_kb.add(\n            \"escape\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"escape\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"escape\", event)\n\n        @_kb.add(\n            \"1\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"1\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"1\", event)\n\n        @_kb.add(\n            \"2\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"2\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"2\", event)\n\n        @_kb.add(\n            \"3\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"3\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"3\", event)\n\n        @_kb.add(\n            \"4\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"4\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"4\", event)\n\n        @_kb.add(\n            \"5\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"5\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"5\", event)\n\n        @_kb.add(\n            \"6\",\n            eager=True,\n            filter=Condition(lambda: self._should_handle_running_prompt_key(\"6\")),\n        )\n        def _(event: KeyPressEvent) -> None:\n            self._handle_running_prompt_key(\"6\", event)\n\n        @_kb.add(Keys.BracketedPaste, eager=True)\n        def _(event: KeyPressEvent) -> None:\n            self._handle_bracketed_paste(event)\n\n        if clipboard_available:\n\n            @_kb.add(\"c-v\", eager=True)\n            def _(event: KeyPressEvent) -> None:\n                if self._try_paste_media(event):\n                    return\n                clipboard_data = event.app.clipboard.get_data()\n                if clipboard_data is None:  # type: ignore[reportUnnecessaryComparison]\n                    return\n                self._insert_pasted_text(event.current_buffer, clipboard_data.text)\n                event.app.invalidate()\n\n            clipboard = PyperclipClipboard()\n        else:\n            clipboard = None\n\n        self._session = PromptSession[str](\n            message=self._render_message,\n            # prompt_continuation=FormattedText([(\"fg:#4d4d4d\", \"... \")]),\n            completer=self._agent_mode_completer,\n            complete_while_typing=True,\n            reserve_space_for_menu=10,\n            key_bindings=_kb,\n            clipboard=clipboard,\n            history=history,\n            bottom_toolbar=self._render_bottom_toolbar,\n            style=Style.from_dict(\n                {\n                    \"bottom-toolbar\": \"noreverse\",\n                    \"running-prompt-placeholder\": \"fg:#7c8594 italic\",\n                    \"running-prompt-separator\": \"fg:#4a5568\",\n                    \"slash-completion-menu\": \"\",\n                    \"slash-completion-menu.separator\": \"fg:#4a5568\",\n                    \"slash-completion-menu.marker\": \"fg:#4a5568\",\n                    \"slash-completion-menu.marker.current\": \"fg:#4f9fff\",\n                    \"slash-completion-menu.command\": \"fg:#a6adba\",\n                    \"slash-completion-menu.meta\": \"fg:#7c8594\",\n                    \"slash-completion-menu.command.current\": \"fg:#6fb7ff bold\",\n                    \"slash-completion-menu.meta.current\": \"fg:#56a4ff\",\n                }\n            ),\n        )\n        self._install_slash_completion_menu()\n        self._apply_mode()\n\n        # Allow completion to be triggered when the text is changed,\n        # such as when backspace is used to delete text.\n        @self._session.default_buffer.on_text_changed.add_handler\n        def _(buffer: Buffer) -> None:\n            if buffer.complete_while_typing():\n                buffer.start_completion()\n\n        self._status_refresh_task: asyncio.Task[None] | None = None\n\n    def _install_slash_completion_menu(self) -> None:\n        float_container = _find_prompt_float_container(self._session.layout.container)\n        if not isinstance(float_container, FloatContainer):\n            return\n\n        slash_menu_filter = (\n            has_focus(self._session.default_buffer)\n            & has_completions\n            & ~is_done\n            & Condition(self._should_show_slash_completion_menu)\n        )\n        slash_menu = ConditionalContainer(\n            Window(\n                content=SlashCommandMenuControl(left_padding=self._slash_menu_left_padding),\n                dont_extend_height=True,\n                height=Dimension(max=10),\n                style=\"class:slash-completion-menu\",\n            ),\n            filter=slash_menu_filter,\n        )\n        float_container.floats.insert(\n            0,\n            Float(\n                left=0,\n                right=0,\n                ycursor=True,\n                content=slash_menu,\n                z_index=10**8,\n            ),\n        )\n\n        original_float = next(\n            (\n                float_\n                for float_ in float_container.floats[1:]\n                if isinstance(float_.content, CompletionsMenu)\n            ),\n            None,\n        )\n        if original_float is None:\n            return\n        original_float.content = ConditionalContainer(\n            original_float.content,\n            filter=~Condition(self._should_show_slash_completion_menu),\n        )\n\n    def _should_show_slash_completion_menu(self) -> bool:\n        document = self._session.default_buffer.document\n        return SlashCommandCompleter.should_complete(document)\n\n    def _slash_menu_left_padding(self) -> int:\n        if self._mode == PromptMode.SHELL:\n            return max(1, get_cwidth(f\"{PROMPT_SYMBOL_SHELL} \") - 2)\n        if self._status_provider().plan_mode:\n            return max(1, get_cwidth(f\"{PROMPT_SYMBOL_PLAN} \") - 2)\n        symbol = PROMPT_SYMBOL_THINKING if self._thinking else PROMPT_SYMBOL\n        return max(1, get_cwidth(f\"{symbol} \") - 2)\n\n    def _render_message(self) -> FormattedText:\n        if self._mode == PromptMode.SHELL:\n            return self._render_shell_prompt_message()\n        return self._render_agent_prompt_message()\n\n    def _render_shell_prompt_message(self) -> FormattedText:\n        app = get_app_or_none()\n        columns = app.output.get_size().columns if app is not None else 80\n        fragments: FormattedText = FormattedText()\n        body = self._render_status_block(columns)\n        if body:\n            fragments.extend(body)\n            if not body[-1][1].endswith(\"\\n\"):\n                fragments.append((\"\", \"\\n\"))\n            fragments.append((\"\", \"\\n\"))\n            fragments.append((\"class:running-prompt-separator\", \"─\" * max(0, columns)))\n            fragments.append((\"\", \"\\n\"))\n        fragments.append((\"bold\", f\"{PROMPT_SYMBOL_SHELL} \"))\n        return fragments\n\n    def _open_in_external_editor(self, event: KeyPressEvent) -> None:\n        \"\"\"Open the current buffer content in an external editor.\"\"\"\n        from prompt_toolkit.application.run_in_terminal import run_in_terminal\n\n        from kimi_cli.utils.editor import edit_text_in_editor, get_editor_command\n\n        configured = self._editor_command_provider()\n\n        if get_editor_command(configured) is None:\n            toast(\"No editor found. Set $VISUAL/$EDITOR or run /editor.\")\n            return\n\n        buff = event.current_buffer\n        original_text = buff.text\n        editor_text = self._get_placeholder_manager().expand_for_editor(original_text)\n\n        async def _run_editor() -> None:\n            result = await run_in_terminal(\n                lambda: edit_text_in_editor(editor_text, configured), in_executor=True\n            )\n            if result is not None:\n                refolded = self._get_placeholder_manager().refold_after_editor(\n                    result, original_text\n                )\n                buff.document = Document(text=refolded, cursor_position=len(refolded))\n\n        event.app.create_background_task(_run_editor())\n\n    def _apply_mode(self, event: KeyPressEvent | None = None) -> None:\n        # Apply mode to the active buffer (not the PromptSession itself)\n        try:\n            buff = event.current_buffer if event is not None else self._session.default_buffer\n        except Exception:\n            buff = None\n\n        if self._mode == PromptMode.SHELL:\n            if buff is not None:\n                buff.completer = self._shell_mode_completer\n        else:\n            if buff is not None:\n                buff.completer = self._agent_mode_completer\n        self._sync_erase_when_done()\n\n    def _sync_erase_when_done(self) -> None:\n        app = getattr(self._session, \"app\", None)\n        if app is not None:\n            app.erase_when_done = self._mode == PromptMode.AGENT\n\n    def _should_handle_running_prompt_key(self, key: str) -> bool:\n        running_prompt = getattr(self, \"_running_prompt_delegate\", None)\n        return running_prompt is not None and running_prompt.should_handle_running_prompt_key(key)\n\n    def _handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None:\n        running_prompt = self._running_prompt_delegate\n        if running_prompt is None:\n            return\n        running_prompt.handle_running_prompt_key(key, event)\n        event.app.invalidate()\n\n    def invalidate(self) -> None:\n        app = get_app_or_none()\n        if app is not None:\n            app.invalidate()\n\n    def _render_agent_prompt_message(self) -> FormattedText:\n        app = get_app_or_none()\n        columns = app.output.get_size().columns if app is not None else 80\n        fragments: FormattedText = FormattedText()\n        body = self._render_agent_prompt_body(columns)\n        if body:\n            fragments.extend(body)\n            if not body[-1][1].endswith(\"\\n\"):\n                fragments.append((\"\", \"\\n\"))\n        fragments.append((\"\", \"\\n\"))\n        fragments.append((\"class:running-prompt-separator\", \"─\" * max(0, columns)))\n        fragments.append((\"\", \"\\n\"))\n        fragments.extend(self._render_agent_prompt_label())\n        return fragments\n\n    def _render_agent_prompt_body(self, columns: int) -> FormattedText:\n        running_prompt = self._running_prompt_delegate\n        if running_prompt is None:\n            return self._render_status_block(columns)\n        return to_formatted_text(running_prompt.render_running_prompt_body(columns))\n\n    def _render_status_block(self, columns: int) -> FormattedText:\n        status_block_provider = getattr(self, \"_status_block_provider\", None)\n        if status_block_provider is None:\n            return FormattedText([])\n        block = status_block_provider(columns)\n        if block is None:\n            return FormattedText([])\n        return to_formatted_text(block)\n\n    def _render_agent_prompt_label(self) -> FormattedText:\n        status = self._status_provider()\n        if status.plan_mode:\n            return FormattedText([(\"fg:#00aaff\", f\"{PROMPT_SYMBOL_PLAN} \")])\n        symbol = PROMPT_SYMBOL_THINKING if self._thinking else PROMPT_SYMBOL\n        return FormattedText([(\"\", f\"{symbol} \")])\n\n    def __enter__(self) -> CustomPromptSession:\n        if self._status_refresh_task is not None and not self._status_refresh_task.done():\n            return self\n\n        async def _refresh() -> None:\n            try:\n                while True:\n                    app = get_app_or_none()\n                    if app is not None:\n                        app.invalidate()\n\n                    try:\n                        asyncio.get_running_loop()\n                    except RuntimeError:\n                        logger.warning(\"No running loop found, exiting status refresh task\")\n                        self._status_refresh_task = None\n                        break\n\n                    interval = (\n                        _RUNNING_REFRESH_INTERVAL\n                        if self._running_prompt_delegate is not None\n                        or (\n                            self._fast_refresh_provider is not None\n                            and self._fast_refresh_provider()\n                        )\n                        else _IDLE_REFRESH_INTERVAL\n                    )\n                    await asyncio.sleep(interval)\n            except asyncio.CancelledError:\n                # graceful exit\n                pass\n\n        self._status_refresh_task = asyncio.create_task(_refresh())\n        return self\n\n    def __exit__(self, *_) -> None:\n        if self._status_refresh_task is not None and not self._status_refresh_task.done():\n            self._status_refresh_task.cancel()\n        self._status_refresh_task = None\n\n    def _get_placeholder_manager(self) -> PromptPlaceholderManager:\n        manager = getattr(self, \"_placeholder_manager\", None)\n        if manager is None:\n            attachment_cache = getattr(self, \"_attachment_cache\", None)\n            manager = PromptPlaceholderManager(attachment_cache=attachment_cache)\n            self._placeholder_manager = manager\n            self._attachment_cache = manager.attachment_cache\n        return manager\n\n    def _insert_pasted_text(self, buffer: Buffer, text: str) -> None:\n        normalized = normalize_pasted_text(text)\n        if self._mode != PromptMode.AGENT:\n            buffer.insert_text(normalized)\n            return\n        token_or_text = self._get_placeholder_manager().maybe_placeholderize_pasted_text(normalized)\n        buffer.insert_text(token_or_text)\n\n    def _handle_bracketed_paste(self, event: KeyPressEvent) -> None:\n        self._insert_pasted_text(event.current_buffer, event.data)\n        event.app.invalidate()\n\n    def _try_paste_media(self, event: KeyPressEvent) -> bool:\n        \"\"\"Try to paste media from the clipboard.\n\n        Reads the clipboard once and handles all detected content:\n        non-image files (videos, PDFs, etc.) are inserted as paths,\n        image files are cached and inserted as placeholders.\n        Returns True if any media content was inserted.\n        \"\"\"\n        result = grab_media_from_clipboard()\n        if result is None:\n            return False\n\n        parts: list[str] = []\n\n        # 1. Insert file paths (videos, PDFs, etc.)\n        if result.file_paths:\n            logger.debug(\"Pasted {count} file path(s) from clipboard\", count=len(result.file_paths))\n            for p in result.file_paths:\n                text = str(p)\n                if self._mode == PromptMode.SHELL:\n                    text = shlex.quote(text)\n                parts.append(text)\n\n        # 2. Insert images via cache.\n        if result.images:\n            if \"image_in\" not in self._model_capabilities:\n                console.print(\n                    \"[yellow]Image input is not supported by the selected LLM model[/yellow]\"\n                )\n            else:\n                for image in result.images:\n                    token = self._get_placeholder_manager().create_image_placeholder(image)\n                    if token is None:\n                        continue\n                    logger.debug(\n                        \"Pasted image from clipboard placeholder: {token}, {image_size}\",\n                        token=token,\n                        image_size=image.size,\n                    )\n                    parts.append(token)\n\n        if parts:\n            event.current_buffer.insert_text(\" \".join(parts))\n        event.app.invalidate()\n        return bool(parts)\n\n    async def prompt_next(self) -> UserInput:\n        return await self._prompt_once(append_history=None)\n\n    @property\n    def last_submission_was_running(self) -> bool:\n        return getattr(self, \"_last_submission_was_running\", False)\n\n    def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None:\n        current = getattr(self, \"_running_prompt_delegate\", None)\n        if current is delegate:\n            return\n        if current is None:\n            self._running_prompt_previous_mode = self._mode\n        self._running_prompt_delegate = delegate\n        self._mode = PromptMode.AGENT\n        self._apply_mode()\n        self.invalidate()\n\n    def detach_running_prompt(self, delegate: RunningPromptDelegate) -> None:\n        if getattr(self, \"_running_prompt_delegate\", None) is not delegate:\n            return\n        previous_mode = getattr(self, \"_running_prompt_previous_mode\", None)\n        self._running_prompt_delegate = None\n        self._running_prompt_previous_mode = None\n        if previous_mode is not None:\n            self._mode = previous_mode\n        self._apply_mode()\n        self.invalidate()\n\n    async def _prompt_once(self, *, append_history: bool | None) -> UserInput:\n        placeholder = None\n        if self._running_prompt_delegate is not None:\n            placeholder = self._running_prompt_delegate.running_prompt_placeholder()\n        with patch_stdout(raw=True):\n            command = str(await self._session.prompt_async(placeholder=placeholder)).strip()\n            command = command.replace(\"\\x00\", \"\")  # just in case null bytes are somehow inserted\n            # Sanitize UTF-16 surrogates that may come from Windows clipboard\n            command = sanitize_surrogates(command)\n        was_running = self._running_prompt_delegate is not None\n        self._last_submission_was_running = was_running\n        if append_history is None:\n            append_history = not was_running\n        if append_history:\n            self._append_history_entry(command)\n        self._tip_rotation_index += 1\n        return self._build_user_input(command)\n\n    def _build_user_input(self, command: str) -> UserInput:\n        resolved = self._get_placeholder_manager().resolve_command(command)\n\n        return UserInput(\n            mode=self._mode,\n            command=resolved.display_command,\n            resolved_command=resolved.resolved_text,\n            content=resolved.content,\n        )\n\n    def _append_history_entry(self, text: str) -> None:\n        safe_history_text = self._get_placeholder_manager().serialize_for_history(text).strip()\n        entry = _HistoryEntry(content=safe_history_text)\n        if not entry.content:\n            return\n\n        # skip if same as last entry\n        if entry.content == self._last_history_content:\n            return\n\n        try:\n            self._history_file.parent.mkdir(parents=True, exist_ok=True)\n            with self._history_file.open(\"a\", encoding=\"utf-8\") as f:\n                f.write(entry.model_dump_json(ensure_ascii=False) + \"\\n\")\n            self._last_history_content = entry.content\n        except OSError as exc:\n            logger.warning(\n                \"Failed to append user history entry: {file} ({error})\",\n                file=self._history_file,\n                error=exc,\n            )\n\n    def _render_bottom_toolbar(self) -> FormattedText:\n        app = get_app_or_none()\n        assert app is not None\n        columns = app.output.get_size().columns\n\n        fragments: list[tuple[str, str]] = []\n\n        fragments.append((\"fg:#4d4d4d\", \"─\" * columns))\n        fragments.append((\"\", \"\\n\"))\n\n        remaining = columns\n\n        # Time-based tip rotation (every 30 s, independent of user submissions)\n        now = time.monotonic()\n        if now - self._last_tip_rotate_time >= _TIP_ROTATE_INTERVAL:\n            self._tip_rotation_index += 1\n            self._last_tip_rotate_time = now\n\n        # Status flags: yolo / plan\n        status = self._status_provider()\n        if status.yolo_enabled:\n            fragments.extend([(\"bold fg:#ffff00\", \"yolo\"), (\"\", \"  \")])\n            remaining -= 6  # \"yolo\" = 4, \"  \" = 2\n        if status.plan_mode:\n            fragments.extend([(\"bold fg:#00aaff\", \"plan\"), (\"\", \"  \")])\n            remaining -= 6\n\n        # Mode indicator (agent / shell) + model name + thinking indicator.\n        # Degrade gracefully on narrow terminals:\n        #   full: \"agent (model-name ○)\"  → mid: \"agent ○\"  → bare: \"agent\"\n        mode = str(self._mode)\n        if self._mode == PromptMode.AGENT and self._model_name:\n            thinking_dot = \"●\" if self._thinking else \"○\"\n            mode_full = f\"{mode} ({self._model_name} {thinking_dot})\"\n            mode_mid = f\"{mode} {thinking_dot}\"\n            if _display_width(mode_full) <= remaining - 2:\n                mode = mode_full\n            elif _display_width(mode_mid) <= remaining - 2:\n                mode = mode_mid\n            # else: keep bare mode name — model_name and dot are both dropped\n        fragments.extend([(\"\", mode), (\"\", \"  \")])\n        remaining -= _display_width(mode) + 2\n\n        # CWD (truncated from left) + git branch with status badge\n        # Degrade gracefully on narrow terminals: full → cwd-only → truncated cwd → skip\n        cwd = _truncate_left(_shorten_cwd(str(KaosPath.cwd())), _MAX_CWD_COLS)\n        branch = _get_git_branch()\n        if branch:\n            dirty, ahead, behind = _get_git_status()\n            branch = _truncate_right(branch, _MAX_BRANCH_COLS)\n            badge = _format_git_badge(branch, dirty, ahead, behind)\n            cwd_text = f\"{cwd}  {badge}\"\n        else:\n            cwd_text = cwd\n        cwd_w = _display_width(cwd_text)\n        if cwd_w > remaining - 2:\n            cwd_text = cwd  # drop badge\n            cwd_w = _display_width(cwd_text)\n        if cwd_w > remaining - 2:\n            cwd_text = _truncate_right(cwd, max(0, remaining - 2))\n            cwd_w = _display_width(cwd_text)\n        if cwd_text and remaining >= cwd_w + 2:\n            fragments.extend([(\"fg:#666666\", cwd_text), (\"\", \"  \")])\n            remaining -= cwd_w + 2\n\n        # Active background bash task count\n        bg_count = (\n            self._background_task_count_provider() if self._background_task_count_provider else 0\n        )\n        if bg_count > 0:\n            bg_text = f\"⚙ bash: {bg_count}\"\n            bg_width = _display_width(bg_text)\n            if remaining >= bg_width + 2:\n                fragments.extend([(\"fg:#888888\", bg_text), (\"\", \"  \")])\n                remaining -= bg_width + 2\n\n        # Tips fill remaining space on line 1\n        tip_text = self._get_two_rotating_tips()\n        if tip_text and _display_width(tip_text) > remaining:\n            tip_text = self._get_one_rotating_tip()\n        if tip_text and _display_width(tip_text) <= remaining:\n            fragments.append((\"fg:#555555\", tip_text))\n\n        # ── line 2: toast (left) + context (right) — always rendered ──────\n        fragments.append((\"\", \"\\n\"))\n\n        right_text = self._render_right_span(status)\n        right_width = _display_width(right_text)\n\n        left_toast = _current_toast(\"left\")\n        if left_toast is not None:\n            max_left = max(0, columns - right_width - 2)\n            if max_left > 0:\n                left_text = left_toast.message\n                if _display_width(left_text) > max_left:\n                    left_text = _truncate_right(left_text, max_left)\n                left_width = _display_width(left_text)\n                fragments.append((\"\", left_text))\n            else:\n                left_width = 0\n        else:\n            left_width = 0\n\n        fragments.append((\"\", \" \" * max(0, columns - left_width - right_width)))\n        fragments.append((\"\", right_text))\n\n        return FormattedText(fragments)\n\n    def _get_two_rotating_tips(self) -> str | None:\n        \"\"\"Return a string with exactly 2 tips from the rotation, or fewer if not enough.\"\"\"\n        n = len(self._tips)\n        if n == 0:\n            return None\n        if n == 1:\n            return self._tips[0]\n        offset = self._tip_rotation_index % n\n        tip1 = self._tips[offset]\n        tip2 = self._tips[(offset + 1) % n]\n        return f\"{tip1}{_TIP_SEPARATOR}{tip2}\"\n\n    def _get_one_rotating_tip(self) -> str | None:\n        \"\"\"Return the single leading tip for the current rotation.\"\"\"\n        if not self._tips:\n            return None\n        return self._tips[self._tip_rotation_index % len(self._tips)]\n\n    @staticmethod\n    def _render_right_span(status: StatusSnapshot) -> str:\n        current_toast = _current_toast(\"right\")\n        if current_toast is None:\n            return format_context_status(\n                status.context_usage,\n                status.context_tokens,\n                status.max_context_tokens,\n            )\n        return current_toast.message\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/replay.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nfrom collections import deque\nfrom collections.abc import Sequence\nfrom dataclasses import dataclass\nfrom typing import cast\n\nfrom kosong.message import ContentPart, Message\nfrom kosong.tooling import ToolError, ToolOk\n\nfrom kimi_cli.soul.message import is_system_reminder_message\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.echo import render_user_echo\nfrom kimi_cli.ui.shell.visualize import visualize\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.message import message_stringify\nfrom kimi_cli.utils.slashcmd import parse_slash_command_call\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.file import WireFile\nfrom kimi_cli.wire.types import (\n    Event,\n    StatusUpdate,\n    SteerInput,\n    StepBegin,\n    TextPart,\n    ToolResult,\n    TurnBegin,\n    is_event,\n)\n\nMAX_REPLAY_TURNS = 5\n\n\n@dataclass(slots=True)\nclass _ReplayTurn:\n    user_message: Message\n    events: list[Event]\n    n_steps: int = 0\n\n\nasync def replay_recent_history(\n    history: Sequence[Message],\n    *,\n    wire_file: WireFile | None = None,\n) -> None:\n    \"\"\"\n    Replay the most recent user-initiated turns from the provided message history or wire file.\n    \"\"\"\n    if not history:\n        # if the context history is empty,either this is a new session\n        # or the context has been cleared\n        return\n\n    start_idx = _find_replay_start(history)\n    history_turns = (\n        [] if start_idx is None else _build_replay_turns_from_history(history[start_idx:])\n    )\n    turns = await _build_replay_turns_from_wire(wire_file)\n    if not turns or (history_turns and not _same_user_turns(turns, history_turns)):\n        turns = history_turns\n    if not turns:\n        return\n\n    for turn in turns:\n        wire = Wire()\n        console.print(render_user_echo(turn.user_message))\n        ui_task = asyncio.create_task(\n            visualize(wire.ui_side(merge=False), initial_status=StatusUpdate())\n        )\n        for event in turn.events:\n            wire.soul_side.send(event)\n            await asyncio.sleep(0)  # yield to UI loop\n        wire.shutdown()\n        with contextlib.suppress(QueueShutDown):\n            await ui_task\n\n\nasync def _build_replay_turns_from_wire(wire_file: WireFile | None) -> list[_ReplayTurn]:\n    if wire_file is None or not wire_file.path.exists():\n        return []\n\n    size = wire_file.path.stat().st_size\n    if size > 20 * 1024 * 1024:\n        logger.info(\n            \"Wire file too large for replay, skipping: {file} ({size} bytes)\",\n            file=wire_file.path,\n            size=size,\n        )\n        return []\n\n    turns: deque[_ReplayTurn] = deque(maxlen=MAX_REPLAY_TURNS)\n    try:\n        async for record in wire_file.iter_records():\n            wire_msg = record.to_wire_message()\n\n            if isinstance(wire_msg, TurnBegin):\n                if _is_clear_command_input(wire_msg.user_input):\n                    turns.clear()\n                    continue\n                turns.append(\n                    _ReplayTurn(\n                        user_message=_message_from_user_input(wire_msg.user_input),\n                        events=[],\n                    )\n                )\n                continue\n\n            if isinstance(wire_msg, SteerInput):\n                turns.append(\n                    _ReplayTurn(\n                        user_message=_message_from_user_input(wire_msg.user_input),\n                        events=[],\n                    )\n                )\n                continue\n\n            if not is_event(wire_msg) or not turns:\n                continue\n\n            current_turn = turns[-1]\n            if isinstance(wire_msg, StepBegin):\n                current_turn.n_steps = wire_msg.n\n            current_turn.events.append(wire_msg)\n    except Exception:\n        logger.exception(\"Failed to build replay turns from wire file {file}:\", file=wire_file.path)\n        return []\n    return list(turns)\n\n\ndef _message_from_user_input(user_input: str | list[ContentPart]) -> Message:\n    content = cast(\n        list[ContentPart],\n        list(user_input) if isinstance(user_input, list) else [TextPart(text=user_input)],\n    )\n    return Message(role=\"user\", content=content)\n\n\ndef _same_user_turns(lhs: Sequence[_ReplayTurn], rhs: Sequence[_ReplayTurn]) -> bool:\n    return [message_stringify(turn.user_message) for turn in lhs] == [\n        message_stringify(turn.user_message) for turn in rhs\n    ]\n\n\ndef _is_clear_command_input(user_input: str | list[ContentPart]) -> bool:\n    if isinstance(user_input, list):\n        text = Message(role=\"user\", content=user_input).extract_text(\" \").strip()\n    else:\n        text = str(user_input).strip()\n    call = parse_slash_command_call(text)\n    if call is None:\n        return False\n    return call.name in {\"clear\", \"reset\"}\n\n\ndef _is_user_message(message: Message) -> bool:\n    # FIXME: should consider non-text tool call results which are sent as user messages\n    if message.role != \"user\":\n        return False\n    if message.extract_text().startswith(\"<system>CHECKPOINT\"):\n        return False\n    return not is_system_reminder_message(message)\n\n\ndef _find_replay_start(history: Sequence[Message]) -> int | None:\n    indices = [idx for idx, message in enumerate(history) if _is_user_message(message)]\n    if not indices:\n        return None\n    # only replay last MAX_REPLAY_TURNS messages\n    return indices[max(0, len(indices) - MAX_REPLAY_TURNS)]\n\n\ndef _build_replay_turns_from_history(history: Sequence[Message]) -> list[_ReplayTurn]:\n    turns: list[_ReplayTurn] = []\n    current_turn: _ReplayTurn | None = None\n    for message in history:\n        if _is_user_message(message):\n            # start a new turn\n            if current_turn is not None:\n                turns.append(current_turn)\n            current_turn = _ReplayTurn(user_message=message, events=[])\n        elif message.role == \"assistant\":\n            if current_turn is None:\n                continue\n            current_turn.n_steps += 1\n            current_turn.events.append(StepBegin(n=current_turn.n_steps))\n            current_turn.events.extend(message.content)\n            current_turn.events.extend(message.tool_calls or [])\n        elif message.role == \"tool\":\n            if current_turn is None:\n                continue\n            assert message.tool_call_id is not None\n            if any(\n                isinstance(part, TextPart) and part.text.startswith(\"<system>ERROR\")\n                for part in message.content\n            ):\n                result = ToolError(message=\"\", output=\"\", brief=\"\")\n            else:\n                result = ToolOk(output=message.content)\n            current_turn.events.append(\n                ToolResult(tool_call_id=message.tool_call_id, return_value=result)\n            )\n    if current_turn is not None:\n        turns.append(current_turn)\n    return turns\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/setup.py",
    "content": "from __future__ import annotations\n\nfrom typing import TYPE_CHECKING, NamedTuple\n\nimport aiohttp\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.shortcuts.choice_input import ChoiceInput\nfrom pydantic import SecretStr\n\nfrom kimi_cli import logger\nfrom kimi_cli.auth import KIMI_CODE_PLATFORM_ID\nfrom kimi_cli.auth.platforms import (\n    PLATFORMS,\n    ModelInfo,\n    Platform,\n    get_platform_by_name,\n    list_models,\n    managed_model_key,\n    managed_provider_key,\n)\nfrom kimi_cli.config import (\n    LLMModel,\n    LLMProvider,\n    MoonshotFetchConfig,\n    MoonshotSearchConfig,\n    load_config,\n    save_config,\n)\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.slash import registry\n\nif TYPE_CHECKING:\n    from kimi_cli.ui.shell import Shell\n\n\nasync def select_platform() -> Platform | None:\n    platform_name = await _prompt_choice(\n        header=\"Select a platform (↑↓ navigate, Enter select, Ctrl+C cancel):\",\n        choices=[platform.name for platform in PLATFORMS],\n    )\n    if not platform_name:\n        console.print(\"[red]No platform selected[/red]\")\n        return None\n\n    platform = get_platform_by_name(platform_name)\n    if platform is None:\n        console.print(\"[red]Unknown platform[/red]\")\n        return None\n    return platform\n\n\nasync def setup_platform(platform: Platform) -> bool:\n    result = await _setup_platform(platform)\n    if not result:\n        # error message already printed\n        return False\n\n    _apply_setup_result(result)\n    thinking_label = \"on\" if result.thinking else \"off\"\n    console.print(\"[green]✓ Setup complete![/green]\")\n    console.print(f\"  Platform: [bold]{result.platform.name}[/bold]\")\n    console.print(f\"  Model:    [bold]{result.selected_model.id}[/bold]\")\n    console.print(f\"  Thinking: [bold]{thinking_label}[/bold]\")\n    console.print(\"  Reloading...\")\n    return True\n\n\nclass _SetupResult(NamedTuple):\n    platform: Platform\n    api_key: SecretStr\n    selected_model: ModelInfo\n    models: list[ModelInfo]\n    thinking: bool\n\n\nasync def _setup_platform(platform: Platform) -> _SetupResult | None:\n    # enter the API key\n    api_key = await _prompt_text(\"Enter your API key\", is_password=True)\n    if not api_key:\n        return None\n\n    # list models\n    try:\n        with console.status(\"[cyan]Verifying API key...[/cyan]\"):\n            models = await list_models(platform, api_key)\n    except aiohttp.ClientResponseError as e:\n        logger.error(\"Failed to get models: {error}\", error=e)\n        console.print(f\"[red]Failed to get models: {e.message}[/red]\")\n        if e.status == 401 and platform.id != KIMI_CODE_PLATFORM_ID:\n            console.print(\n                \"[yellow]Hint: If your API key was obtained from Kimi Code, \"\n                'please select \"Kimi Code\" instead.[/yellow]'\n            )\n        return None\n    except Exception as e:\n        logger.error(\"Failed to get models: {error}\", error=e)\n        console.print(f\"[red]Failed to get models: {e}[/red]\")\n        return None\n\n    # select the model\n    if not models:\n        console.print(\"[red]No models available for the selected platform[/red]\")\n        return None\n\n    model_map = {model.id: model for model in models}\n    model_id = await _prompt_choice(\n        header=\"Select a model (↑↓ navigate, Enter select, Ctrl+C cancel):\",\n        choices=list(model_map),\n    )\n    if not model_id:\n        console.print(\"[red]No model selected[/red]\")\n        return None\n\n    selected_model = model_map[model_id]\n\n    # Determine thinking mode based on model capabilities\n    capabilities = selected_model.capabilities\n    thinking: bool\n\n    if \"always_thinking\" in capabilities:\n        thinking = True\n    elif \"thinking\" in capabilities:\n        thinking_selection = await _prompt_choice(\n            header=\"Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):\",\n            choices=[\"on\", \"off\"],\n        )\n        if not thinking_selection:\n            return None\n        thinking = thinking_selection == \"on\"\n    else:\n        thinking = False\n\n    return _SetupResult(\n        platform=platform,\n        api_key=SecretStr(api_key),\n        selected_model=selected_model,\n        models=models,\n        thinking=thinking,\n    )\n\n\ndef _apply_setup_result(result: _SetupResult) -> None:\n    config = load_config()\n    provider_key = managed_provider_key(result.platform.id)\n    model_key = managed_model_key(result.platform.id, result.selected_model.id)\n    config.providers[provider_key] = LLMProvider(\n        type=\"kimi\",\n        base_url=result.platform.base_url,\n        api_key=result.api_key,\n    )\n    for key, model in list(config.models.items()):\n        if model.provider == provider_key:\n            del config.models[key]\n    for model_info in result.models:\n        capabilities = model_info.capabilities or None\n        config.models[managed_model_key(result.platform.id, model_info.id)] = LLMModel(\n            provider=provider_key,\n            model=model_info.id,\n            max_context_size=model_info.context_length,\n            capabilities=capabilities,\n        )\n    config.default_model = model_key\n    config.default_thinking = result.thinking\n\n    if result.platform.search_url:\n        config.services.moonshot_search = MoonshotSearchConfig(\n            base_url=result.platform.search_url,\n            api_key=result.api_key,\n        )\n\n    if result.platform.fetch_url:\n        config.services.moonshot_fetch = MoonshotFetchConfig(\n            base_url=result.platform.fetch_url,\n            api_key=result.api_key,\n        )\n\n    save_config(config)\n\n\nasync def _prompt_choice(*, header: str, choices: list[str]) -> str | None:\n    if not choices:\n        return None\n\n    try:\n        return await ChoiceInput(\n            message=header,\n            options=[(choice, choice) for choice in choices],\n            default=choices[0],\n        ).prompt_async()\n    except (EOFError, KeyboardInterrupt):\n        return None\n\n\nasync def _prompt_text(prompt: str, *, is_password: bool = False) -> str | None:\n    session = PromptSession[str]()\n    try:\n        return str(\n            await session.prompt_async(\n                f\" {prompt}: \",\n                is_password=is_password,\n            )\n        ).strip()\n    except (EOFError, KeyboardInterrupt):\n        return None\n\n\n@registry.command\ndef reload(app: Shell, args: str):\n    \"\"\"Reload configuration\"\"\"\n    from kimi_cli.cli import Reload\n\n    raise Reload\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/slash.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom collections.abc import Awaitable, Callable\nfrom typing import TYPE_CHECKING, Any, cast\n\nfrom prompt_toolkit.shortcuts.choice_input import ChoiceInput\n\nfrom kimi_cli import logger\nfrom kimi_cli.auth.platforms import get_platform_name_for_provider, refresh_managed_models\nfrom kimi_cli.cli import Reload, SwitchToWeb\nfrom kimi_cli.config import load_config, save_config\nfrom kimi_cli.exception import ConfigError\nfrom kimi_cli.session import Session\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.mcp_status import render_mcp_console\nfrom kimi_cli.ui.shell.task_browser import TaskBrowserApp\nfrom kimi_cli.utils.changelog import CHANGELOG\nfrom kimi_cli.utils.datetime import format_relative_time\nfrom kimi_cli.utils.slashcmd import SlashCommand, SlashCommandRegistry\n\nif TYPE_CHECKING:\n    from kimi_cli.ui.shell import Shell\n\ntype ShellSlashCmdFunc = Callable[[Shell, str], None | Awaitable[None]]\n\"\"\"\nA function that runs as a Shell-level slash command.\n\nRaises:\n    Reload: When the configuration should be reloaded.\n\"\"\"\n\n\nregistry = SlashCommandRegistry[ShellSlashCmdFunc]()\nshell_mode_registry = SlashCommandRegistry[ShellSlashCmdFunc]()\n\n\ndef ensure_kimi_soul(app: Shell) -> KimiSoul | None:\n    if not isinstance(app.soul, KimiSoul):\n        console.print(\"[red]KimiSoul required[/red]\")\n        return None\n    return app.soul\n\n\n@registry.command(aliases=[\"quit\"])\n@shell_mode_registry.command(aliases=[\"quit\"])\ndef exit(app: Shell, args: str):\n    \"\"\"Exit the application\"\"\"\n    # should be handled by `Shell`\n    raise NotImplementedError\n\n\nSKILL_COMMAND_PREFIX = \"skill:\"\n\n_KEYBOARD_SHORTCUTS = [\n    (\"Ctrl-X\", \"Toggle agent/shell mode\"),\n    (\"Shift-Tab\", \"Toggle plan mode (read-only research)\"),\n    (\"Ctrl-O\", \"Edit in external editor ($VISUAL/$EDITOR)\"),\n    (\"Ctrl-J / Alt-Enter\", \"Insert newline\"),\n    (\"Ctrl-V\", \"Paste (supports images)\"),\n    (\"Ctrl-D\", \"Exit\"),\n    (\"Ctrl-C\", \"Interrupt\"),\n]\n\n\n@registry.command(aliases=[\"h\", \"?\"])\n@shell_mode_registry.command(aliases=[\"h\", \"?\"])\ndef help(app: Shell, args: str):\n    \"\"\"Show help information\"\"\"\n    from rich.console import Group, RenderableType\n    from rich.text import Text\n\n    from kimi_cli.utils.rich.columns import BulletColumns\n\n    def section(title: str, items: list[tuple[str, str]], color: str) -> BulletColumns:\n        lines: list[RenderableType] = [Text.from_markup(f\"[bold]{title}:[/bold]\")]\n        for name, desc in items:\n            lines.append(\n                BulletColumns(\n                    Text.from_markup(f\"[{color}]{name}[/{color}]: [grey50]{desc}[/grey50]\"),\n                    bullet_style=color,\n                )\n            )\n        return BulletColumns(Group(*lines))\n\n    renderables: list[RenderableType] = []\n    renderables.append(\n        BulletColumns(\n            Group(\n                Text.from_markup(\"[grey50]Help! I need somebody. Help! Not just anybody.[/grey50]\"),\n                Text.from_markup(\"[grey50]Help! You know I need someone. Help![/grey50]\"),\n                Text.from_markup(\"[grey50]\\u2015 The Beatles, [italic]Help![/italic][/grey50]\"),\n            ),\n            bullet_style=\"grey50\",\n        )\n    )\n    renderables.append(\n        BulletColumns(\n            Text(\n                \"Sure, Kimi is ready to help! \"\n                \"Just send me messages and I will help you get things done!\"\n            ),\n        )\n    )\n\n    commands: list[SlashCommand[Any]] = []\n    skills: list[SlashCommand[Any]] = []\n    for cmd in app.available_slash_commands.values():\n        if cmd.name.startswith(SKILL_COMMAND_PREFIX):\n            skills.append(cmd)\n        else:\n            commands.append(cmd)\n\n    renderables.append(section(\"Keyboard shortcuts\", _KEYBOARD_SHORTCUTS, \"yellow\"))\n    renderables.append(\n        section(\n            \"Slash commands\",\n            [(c.slash_name(), c.description) for c in sorted(commands, key=lambda c: c.name)],\n            \"blue\",\n        )\n    )\n    if skills:\n        renderables.append(\n            section(\n                \"Skills\",\n                [(c.slash_name(), c.description) for c in sorted(skills, key=lambda c: c.name)],\n                \"cyan\",\n            )\n        )\n\n    with console.pager(styles=True):\n        console.print(Group(*renderables))\n\n\n@registry.command\n@shell_mode_registry.command\ndef version(app: Shell, args: str):\n    \"\"\"Show version information\"\"\"\n    from kimi_cli.constant import VERSION\n\n    console.print(f\"kimi, version {VERSION}\")\n\n\n@registry.command\nasync def model(app: Shell, args: str):\n    \"\"\"Switch LLM model or thinking mode\"\"\"\n    from kimi_cli.llm import derive_model_capabilities\n\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n    config = soul.runtime.config\n\n    await refresh_managed_models(config)\n\n    if not config.models:\n        console.print('[yellow]No models configured, send \"/login\" to login.[/yellow]')\n        return\n\n    if not config.is_from_default_location:\n        console.print(\n            \"[yellow]Model switching requires the default config file; \"\n            \"restart without --config/--config-file.[/yellow]\"\n        )\n        return\n\n    # Find current model/thinking from runtime (may be overridden by --model/--thinking)\n    curr_model_cfg = soul.runtime.llm.model_config if soul.runtime.llm else None\n    curr_model_name: str | None = None\n    if curr_model_cfg is not None:\n        for name, model_cfg in config.models.items():\n            if model_cfg == curr_model_cfg:\n                curr_model_name = name\n                break\n    curr_thinking = soul.thinking\n\n    # Step 1: Select model\n    model_choices: list[tuple[str, str]] = []\n    for name in sorted(config.models):\n        model_cfg = config.models[name]\n        provider_label = get_platform_name_for_provider(model_cfg.provider) or model_cfg.provider\n        marker = \" (current)\" if name == curr_model_name else \"\"\n        label = f\"{model_cfg.model} ({provider_label}){marker}\"\n        model_choices.append((name, label))\n\n    try:\n        selected_model_name = await ChoiceInput(\n            message=\"Select a model (↑↓ navigate, Enter select, Ctrl+C cancel):\",\n            options=model_choices,\n            default=curr_model_name or model_choices[0][0],\n        ).prompt_async()\n    except (EOFError, KeyboardInterrupt):\n        return\n\n    if not selected_model_name:\n        return\n\n    selected_model_cfg = config.models[selected_model_name]\n    selected_provider = config.providers.get(selected_model_cfg.provider)\n    if selected_provider is None:\n        console.print(f\"[red]Provider not found: {selected_model_cfg.provider}[/red]\")\n        return\n\n    # Step 2: Determine thinking mode\n    capabilities = derive_model_capabilities(selected_model_cfg)\n    new_thinking: bool\n\n    if \"always_thinking\" in capabilities:\n        new_thinking = True\n    elif \"thinking\" in capabilities:\n        thinking_choices: list[tuple[str, str]] = [\n            (\"off\", \"off\" + (\" (current)\" if not curr_thinking else \"\")),\n            (\"on\", \"on\" + (\" (current)\" if curr_thinking else \"\")),\n        ]\n        try:\n            thinking_selection = await ChoiceInput(\n                message=\"Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):\",\n                options=thinking_choices,\n                default=\"on\" if curr_thinking else \"off\",\n            ).prompt_async()\n        except (EOFError, KeyboardInterrupt):\n            return\n\n        if not thinking_selection:\n            return\n\n        new_thinking = thinking_selection == \"on\"\n    else:\n        new_thinking = False\n\n    # Check if anything changed\n    model_changed = curr_model_name != selected_model_name\n    thinking_changed = curr_thinking != new_thinking\n\n    if not model_changed and not thinking_changed:\n        console.print(\n            f\"[yellow]Already using {selected_model_name} \"\n            f\"with thinking {'on' if new_thinking else 'off'}.[/yellow]\"\n        )\n        return\n\n    # Save and reload\n    prev_model = config.default_model\n    prev_thinking = config.default_thinking\n    config.default_model = selected_model_name\n    config.default_thinking = new_thinking\n    try:\n        config_for_save = load_config()\n        config_for_save.default_model = selected_model_name\n        config_for_save.default_thinking = new_thinking\n        save_config(config_for_save)\n    except (ConfigError, OSError) as exc:\n        config.default_model = prev_model\n        config.default_thinking = prev_thinking\n        console.print(f\"[red]Failed to save config: {exc}[/red]\")\n        return\n\n    console.print(\n        f\"[green]Switched to {selected_model_name} \"\n        f\"with thinking {'on' if new_thinking else 'off'}. \"\n        \"Reloading...[/green]\"\n    )\n    raise Reload(session_id=soul.runtime.session.id)\n\n\n@registry.command\n@shell_mode_registry.command\nasync def editor(app: Shell, args: str):\n    \"\"\"Set default external editor for Ctrl-O\"\"\"\n    from kimi_cli.utils.editor import get_editor_command\n\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n    config = soul.runtime.config\n    config_file = config.source_file\n    if config_file is None:\n        console.print(\n            \"[yellow]Editor switching is unavailable with inline --config; \"\n            \"use --config-file to persist this setting.[/yellow]\"\n        )\n        return\n\n    current_editor = config.default_editor\n\n    # If args provided directly, use as editor command\n    if args.strip():\n        new_editor = args.strip()\n    else:\n        options: list[tuple[str, str]] = [\n            (\"code --wait\", \"VS Code (code --wait)\"),\n            (\"vim\", \"Vim\"),\n            (\"nano\", \"Nano\"),\n            (\"\", \"Auto-detect (use $VISUAL/$EDITOR)\"),\n        ]\n        # Mark current selection\n        options = [\n            (val, label + (\" ← current\" if val == current_editor else \"\")) for val, label in options\n        ]\n\n        try:\n            choice = cast(\n                str | None,\n                await ChoiceInput(\n                    message=\"Select an editor (↑↓ navigate, Enter select, Ctrl+C cancel):\",\n                    options=options,\n                    default=(\n                        current_editor\n                        if current_editor in {v for v, _ in options}\n                        else \"code --wait\"\n                    ),\n                ).prompt_async(),\n            )\n        except (EOFError, KeyboardInterrupt):\n            return\n\n        if choice is None:\n            return\n        new_editor = choice\n\n    # Validate the editor binary is available\n    if new_editor:\n        import shlex\n        import shutil\n\n        try:\n            parts = shlex.split(new_editor)\n        except ValueError:\n            console.print(f\"[red]Invalid editor command: {new_editor}[/red]\")\n            return\n\n        binary = parts[0]\n        if not shutil.which(binary):\n            console.print(\n                f\"[yellow]Warning: '{binary}' not found in PATH. \"\n                f\"Saving anyway — make sure it's installed before using Ctrl-O.[/yellow]\"\n            )\n\n    if new_editor == current_editor:\n        console.print(f\"[yellow]Editor is already set to: {new_editor or 'auto-detect'}[/yellow]\")\n        return\n\n    # Save to disk\n    try:\n        config_for_save = load_config(config_file)\n        config_for_save.default_editor = new_editor\n        save_config(config_for_save, config_file)\n    except (ConfigError, OSError) as exc:\n        console.print(f\"[red]Failed to save config: {exc}[/red]\")\n        return\n\n    # Sync in-memory config so Ctrl-O picks it up immediately\n    config.default_editor = new_editor\n\n    if new_editor:\n        console.print(f\"[green]Editor set to: {new_editor}[/green]\")\n    else:\n        resolved = get_editor_command()\n        label = \" \".join(resolved) if resolved else \"none\"\n        console.print(f\"[green]Editor set to auto-detect (resolved: {label})[/green]\")\n\n\n@registry.command(aliases=[\"release-notes\"])\n@shell_mode_registry.command(aliases=[\"release-notes\"])\ndef changelog(app: Shell, args: str):\n    \"\"\"Show release notes\"\"\"\n    from rich.console import Group, RenderableType\n    from rich.text import Text\n\n    from kimi_cli.utils.rich.columns import BulletColumns\n\n    renderables: list[RenderableType] = []\n    for ver, entry in CHANGELOG.items():\n        title = f\"[bold]{ver}[/bold]\"\n        if entry.description:\n            title += f\": {entry.description}\"\n\n        lines: list[RenderableType] = [Text.from_markup(title)]\n        for item in entry.entries:\n            if item.lower().startswith(\"lib:\"):\n                continue\n            lines.append(\n                BulletColumns(\n                    Text.from_markup(f\"[grey50]{item}[/grey50]\"),\n                    bullet_style=\"grey50\",\n                ),\n            )\n        renderables.append(BulletColumns(Group(*lines)))\n\n    with console.pager(styles=True):\n        console.print(Group(*renderables))\n\n\n@registry.command\n@shell_mode_registry.command\ndef feedback(app: Shell, args: str):\n    \"\"\"Submit feedback to make Kimi Code CLI better\"\"\"\n    import webbrowser\n\n    ISSUE_URL = \"https://github.com/MoonshotAI/kimi-cli/issues\"\n    if webbrowser.open(ISSUE_URL):\n        return\n    console.print(f\"Please submit feedback at [underline]{ISSUE_URL}[/underline].\")\n\n\n@registry.command(aliases=[\"reset\"])\nasync def clear(app: Shell, args: str):\n    \"\"\"Clear the context\"\"\"\n    if ensure_kimi_soul(app) is None:\n        return\n    await app.run_soul_command(\"/clear\")\n    raise Reload()\n\n\n@registry.command\nasync def new(app: Shell, args: str):\n    \"\"\"Start a new session\"\"\"\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n    current_session = soul.runtime.session\n    work_dir = current_session.work_dir\n    # Clean up the current session if it has no content, so that chaining\n    # /new commands (or switching away before the first message) does not\n    # leave orphan empty session directories on disk.\n    if current_session.is_empty():\n        await current_session.delete()\n    session = await Session.create(work_dir)\n    console.print(\"[green]New session created. Switching...[/green]\")\n    raise Reload(session_id=session.id)\n\n\n@registry.command(name=\"sessions\", aliases=[\"resume\"])\nasync def list_sessions(app: Shell, args: str):\n    \"\"\"List sessions and resume optionally\"\"\"\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n\n    work_dir = soul.runtime.session.work_dir\n    current_session = soul.runtime.session\n    current_session_id = current_session.id\n    sessions = [\n        session for session in await Session.list(work_dir) if session.id != current_session_id\n    ]\n\n    await current_session.refresh()\n    sessions.insert(0, current_session)\n\n    choices: list[tuple[str, str]] = []\n    for session in sessions:\n        time_str = format_relative_time(session.updated_at)\n        marker = \" (current)\" if session.id == current_session_id else \"\"\n        label = f\"{session.title}, {time_str}{marker}\"\n        choices.append((session.id, label))\n\n    try:\n        selection = await ChoiceInput(\n            message=\"Select a session to switch to (↑↓ navigate, Enter select, Ctrl+C cancel):\",\n            options=choices,\n            default=choices[0][0],\n        ).prompt_async()\n    except (EOFError, KeyboardInterrupt):\n        return\n\n    if not selection:\n        return\n\n    if selection == current_session_id:\n        console.print(\"[yellow]You are already in this session.[/yellow]\")\n        return\n\n    console.print(f\"[green]Switching to session {selection}...[/green]\")\n    raise Reload(session_id=selection)\n\n\n@registry.command(name=\"task\")\n@shell_mode_registry.command(name=\"task\")\nasync def task(app: Shell, args: str):\n    \"\"\"Browse and manage background tasks\"\"\"\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n    if args.strip():\n        console.print('[yellow]Usage: \"/task\" opens the interactive task browser.[/yellow]')\n        return\n    if soul.runtime.role != \"root\":\n        console.print(\"[yellow]Background tasks are only available from the root agent.[/yellow]\")\n        return\n\n    await TaskBrowserApp(soul).run()\n\n\n@registry.command\ndef web(app: Shell, args: str):\n    \"\"\"Open Kimi Code Web UI in browser\"\"\"\n    soul = ensure_kimi_soul(app)\n    session_id = soul.runtime.session.id if soul else None\n    raise SwitchToWeb(session_id=session_id)\n\n\n@registry.command\nasync def mcp(app: Shell, args: str):\n    \"\"\"Show MCP servers and tools\"\"\"\n    from rich.live import Live\n\n    soul = ensure_kimi_soul(app)\n    if soul is None:\n        return\n    await soul.start_background_mcp_loading()\n    snapshot = soul.status.mcp_status\n    if snapshot is None:\n        console.print(\"[yellow]No MCP servers configured.[/yellow]\")\n        return\n\n    if not snapshot.loading:\n        console.print(render_mcp_console(snapshot))\n        return\n\n    with Live(\n        render_mcp_console(snapshot),\n        console=console,\n        refresh_per_second=8,\n        transient=False,\n    ) as live:\n        while True:\n            snapshot = soul.status.mcp_status\n            if snapshot is None:\n                break\n            live.update(render_mcp_console(snapshot), refresh=True)\n            if not snapshot.loading:\n                break\n            await asyncio.sleep(0.125)\n        try:\n            await soul.wait_for_background_mcp_loading()\n        except Exception as e:\n            logger.debug(\"MCP loading completed with error while rendering /mcp: {error}\", error=e)\n        snapshot = soul.status.mcp_status\n        if snapshot is not None:\n            live.update(render_mcp_console(snapshot), refresh=True)\n\n\nfrom . import (  # noqa: E402\n    debug,  # noqa: F401 # type: ignore[reportUnusedImport]\n    export_import,  # noqa: F401 # type: ignore[reportUnusedImport]\n    oauth,  # noqa: F401 # type: ignore[reportUnusedImport]\n    setup,  # noqa: F401 # type: ignore[reportUnusedImport]\n    update,  # noqa: F401 # type: ignore[reportUnusedImport]\n    usage,  # noqa: F401 # type: ignore[reportUnusedImport]\n)\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/startup.py",
    "content": "from __future__ import annotations\n\nfrom rich.status import Status\n\nfrom kimi_cli.ui.shell.console import console\n\n\nclass ShellStartupProgress:\n    \"\"\"Transient startup status shown while the shell is initializing.\"\"\"\n\n    def __init__(self, *, enabled: bool | None = None) -> None:\n        self._enabled = console.is_terminal if enabled is None else enabled\n        self._status: Status | None = None\n\n    def update(self, message: str) -> None:\n        if not self._enabled:\n            return\n\n        status_message = f\"[cyan]{message}[/cyan]\"\n        if self._status is None:\n            self._status = console.status(status_message, spinner=\"dots\")\n            self._status.start()\n            return\n\n        self._status.update(status_message)\n\n    def stop(self) -> None:\n        if self._status is None:\n            return\n\n        self._status.stop()\n        self._status = None\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/task_browser.py",
    "content": "import time\nfrom dataclasses import dataclass, field\nfrom typing import Literal\n\nfrom prompt_toolkit.application import Application\nfrom prompt_toolkit.application.run_in_terminal import run_in_terminal\nfrom prompt_toolkit.filters import Condition\nfrom prompt_toolkit.formatted_text import StyleAndTextTuples\nfrom prompt_toolkit.key_binding import KeyBindings, KeyPressEvent\nfrom prompt_toolkit.layout import HSplit, Layout, VSplit, Window\nfrom prompt_toolkit.layout.controls import FormattedTextControl\nfrom prompt_toolkit.styles import Style\nfrom prompt_toolkit.widgets import Box, Frame, RadioList\nfrom rich.console import Group\nfrom rich.panel import Panel\nfrom rich.text import Text\n\nfrom kimi_cli.background import TaskView, is_terminal_status\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.utils.datetime import format_duration, format_relative_time\n\nTaskBrowserFilter = Literal[\"all\", \"active\"]\n\n_EMPTY_TASK_ID = \"__empty__\"\n_PREVIEW_MAX_LINES = 6\n_PREVIEW_MAX_BYTES = 4_000\n_FULL_OUTPUT_MAX_BYTES = 200_000\n_FULL_OUTPUT_MAX_LINES = 4_000\n_AUTO_REFRESH_SECONDS = 1.0\n_FLASH_MESSAGE_SECONDS = 3.0\n\n\ndef format_task_choice(view: TaskView, *, now: float | None = None) -> str:\n    description = view.spec.description.strip() or \"(no description)\"\n    return \" · \".join(\n        [\n            f\"[{view.runtime.status}]\",\n            description,\n            view.spec.id,\n            view.spec.kind,\n            _task_timing_label(view, now=now) or \"updated just now\",\n        ]\n    )\n\n\n@dataclass(slots=True)\nclass TaskBrowserModel:\n    soul: KimiSoul\n    filter_mode: TaskBrowserFilter = \"all\"\n    message: str = \"\"\n    message_expires_at: float | None = None\n    pending_stop_task_id: str | None = None\n    all_views: list[TaskView] = field(default_factory=lambda: [])\n    visible_views: list[TaskView] = field(default_factory=lambda: [])\n\n    @property\n    def manager(self):\n        return self.soul.runtime.background_tasks\n\n    @property\n    def config(self):\n        return self.soul.runtime.config.background\n\n    def refresh(self, selected_task_id: str | None = None) -> tuple[list[tuple[str, str]], str]:\n        self.manager.reconcile()\n        self.all_views = self.manager.list_tasks(limit=None)\n        self.all_views.sort(key=_task_sort_key)\n\n        if self.filter_mode == \"active\":\n            self.visible_views = [\n                view for view in self.all_views if not is_terminal_status(view.runtime.status)\n            ]\n        else:\n            self.visible_views = list(self.all_views)\n\n        if not self.visible_views:\n            label = (\n                \"No active background tasks.\"\n                if self.filter_mode == \"active\"\n                else \"No background tasks in this session.\"\n            )\n            self.pending_stop_task_id = None\n            return [(_EMPTY_TASK_ID, label)], _EMPTY_TASK_ID\n\n        values = [(view.spec.id, format_task_choice(view)) for view in self.visible_views]\n        valid_ids = {task_id for task_id, _label in values}\n        selected = selected_task_id if selected_task_id in valid_ids else values[0][0]\n\n        if self.pending_stop_task_id not in valid_ids:\n            self.pending_stop_task_id = None\n        return values, selected\n\n    def view_for(self, task_id: str | None) -> TaskView | None:\n        if not task_id or task_id == _EMPTY_TASK_ID:\n            return None\n        for view in self.visible_views:\n            if view.spec.id == task_id:\n                return view\n        return self.manager.get_task(task_id)\n\n    def set_message(self, text: str, *, duration_s: float = _FLASH_MESSAGE_SECONDS) -> None:\n        self.message = text\n        self.message_expires_at = time.time() + duration_s\n\n    def current_message(self) -> str | None:\n        if not self.message:\n            return None\n        if self.message_expires_at is None:\n            return self.message\n        if time.time() > self.message_expires_at:\n            self.message = \"\"\n            self.message_expires_at = None\n            return None\n        return self.message\n\n    def summary_fragments(self) -> StyleAndTextTuples:\n        counts = {\n            \"running\": 0,\n            \"starting\": 0,\n            \"failed\": 0,\n            \"completed\": 0,\n            \"killed\": 0,\n            \"lost\": 0,\n        }\n        for view in self.all_views:\n            counts[view.runtime.status] = counts.get(view.runtime.status, 0) + 1\n\n        scope = \"ALL\" if self.filter_mode == \"all\" else \"ACTIVE\"\n        return [\n            (\"class:header.title\", \" TASK BROWSER \"),\n            (\"class:header.meta\", f\" filter={scope} \"),\n            (\"class:status.running\", f\" {counts['running']} running \"),\n            (\"class:status.info\", f\" {counts['starting']} starting \"),\n            (\"class:status.error\", f\" {counts['failed']} failed \"),\n            (\"class:status.success\", f\" {counts['completed']} completed \"),\n            (\"class:status.warning\", f\" {counts['killed'] + counts['lost']} interrupted \"),\n            (\"class:header.meta\", f\" {len(self.all_views)} total \"),\n        ]\n\n    def detail_text(self, task_id: str | None) -> str:\n        view = self.view_for(task_id)\n        if view is None:\n            return \"Select a task from the list.\"\n\n        terminal_reason = \"timed_out\" if view.runtime.timed_out else view.runtime.status\n        lines = [\n            f\"Task ID: {view.spec.id}\",\n            f\"Status: {view.runtime.status}\",\n            f\"Description: {view.spec.description}\",\n            f\"Kind: {view.spec.kind}\",\n        ]\n        timing = _task_timing_label(view)\n        if timing:\n            lines.append(f\"Time: {timing}\")\n        if view.spec.cwd:\n            lines.append(f\"Cwd: {view.spec.cwd}\")\n        if view.spec.command:\n            lines.append(f\"Command: {view.spec.command}\")\n        if view.runtime.exit_code is not None:\n            lines.append(f\"Exit code: {view.runtime.exit_code}\")\n        lines.append(f\"Terminal reason: {terminal_reason}\")\n        if view.runtime.failure_reason:\n            lines.append(f\"Reason: {view.runtime.failure_reason}\")\n        return \"\\n\".join(lines)\n\n    def preview_text(self, task_id: str | None) -> str:\n        view = self.view_for(task_id)\n        if view is None:\n            return \"No output to preview.\"\n\n        preview = self.manager.tail_output(\n            view.spec.id,\n            max_bytes=_PREVIEW_MAX_BYTES,\n            max_lines=_PREVIEW_MAX_LINES,\n        )\n        if not preview:\n            return \"[no output available]\"\n        return preview\n\n    def full_output(self, task_id: str | None) -> str:\n        view = self.view_for(task_id)\n        if view is None:\n            return \"[no output available]\"\n\n        path = self.manager.store.output_path(view.spec.id)\n        total_size = path.stat().st_size if path.exists() else 0\n        output = self.manager.tail_output(\n            view.spec.id,\n            max_bytes=max(self.config.read_max_bytes * 10, _FULL_OUTPUT_MAX_BYTES),\n            max_lines=_FULL_OUTPUT_MAX_LINES,\n        )\n        max_bytes = max(self.config.read_max_bytes * 10, _FULL_OUTPUT_MAX_BYTES)\n        if total_size > max_bytes:\n            return (\n                f\"[showing last {max_bytes} bytes of {total_size} bytes]\\n\\n\"\n                f\"{output or '[no output available]'}\"\n            )\n        return output or \"[no output available]\"\n\n    def footer_fragments(self, task_id: str | None) -> StyleAndTextTuples:\n        if self.pending_stop_task_id is not None:\n            label = self.pending_stop_task_id\n            return [\n                (\"class:footer.warning\", f\" Confirm stop {label}? \"),\n                (\"class:footer.key\", \"Y\"),\n                (\"class:footer.text\", \" confirm  \"),\n                (\"class:footer.key\", \"N\"),\n                (\"class:footer.text\", \" cancel \"),\n            ]\n\n        fragments: StyleAndTextTuples = [\n            (\"class:footer.key\", \" Enter \"),\n            (\"class:footer.text\", \"output  \"),\n            (\"class:footer.key\", \"S\"),\n            (\"class:footer.text\", \" stop  \"),\n            (\"class:footer.key\", \"R\"),\n            (\"class:footer.text\", \" refresh  \"),\n            (\"class:footer.key\", \"Tab\"),\n            (\"class:footer.text\", \" filter  \"),\n            (\"class:footer.key\", \"Q\"),\n            (\"class:footer.text\", \" exit \"),\n            (\"class:footer.meta\", f\" auto-refresh {_AUTO_REFRESH_SECONDS:.0f}s \"),\n        ]\n        if message := self.current_message():\n            fragments.extend(\n                [\n                    (\"class:footer.meta\", \" | \"),\n                    (\"class:footer.flash\", f\" {message} \"),\n                ]\n            )\n        return fragments\n\n\nclass TaskBrowserApp:\n    def __init__(self, soul: KimiSoul):\n        self._model = TaskBrowserModel(soul)\n        task_values, selected = self._model.refresh()\n        self._task_list = RadioList(\n            values=task_values,\n            default=selected,\n            show_numbers=False,\n            select_on_focus=True,\n            open_character=\"\",\n            select_character=\">\",\n            close_character=\"\",\n            show_cursor=False,\n            show_scrollbar=False,\n            container_style=\"class:task-list\",\n            checked_style=\"class:task-list.checked\",\n        )\n        self._app = self._build_app()\n\n    async def run(self) -> None:\n        await self._app.run_async()\n\n    @property\n    def _selected_task_id(self) -> str | None:\n        current = self._task_list.current_value\n        if current == _EMPTY_TASK_ID:\n            return None\n        return current\n\n    def _open_output(self, app: Application[object], task_id: str) -> None:\n        app.create_background_task(self._show_output_in_terminal(task_id))\n\n    async def _show_output_in_terminal(self, task_id: str) -> None:\n        def render() -> None:\n            view = self._model.view_for(task_id)\n            if view is None:\n                console.print(f\"[yellow]Task not found: {task_id}[/yellow]\")\n                return\n            with console.pager(styles=True):\n                console.print(_build_full_output_renderable(view, self._model.full_output(task_id)))\n\n        await run_in_terminal(render)\n\n    def _toggle_filter(self) -> None:\n        self._model.filter_mode = \"active\" if self._model.filter_mode == \"all\" else \"all\"\n        self._model.set_message(\n            \"Showing active tasks only.\"\n            if self._model.filter_mode == \"active\"\n            else \"Showing all tasks.\"\n        )\n        self._sync_views()\n\n    def _refresh_views(self) -> None:\n        self._model.set_message(\"Refreshed.\")\n        self._sync_views()\n\n    def _request_stop_for_selected_task(self) -> None:\n        view = self._model.view_for(self._selected_task_id)\n        if view is None:\n            self._model.set_message(\"No task selected.\")\n        elif is_terminal_status(view.runtime.status):\n            self._model.set_message(f\"Task {view.spec.id} is already {view.runtime.status}.\")\n        else:\n            self._model.pending_stop_task_id = view.spec.id\n            self._model.message = \"\"\n            self._model.message_expires_at = None\n\n    def _confirm_stop_request(self) -> None:\n        task_id = self._model.pending_stop_task_id\n        self._model.pending_stop_task_id = None\n        if task_id is None:\n            return\n        view = self._model.view_for(task_id)\n        if view is None:\n            self._model.set_message(f\"Task not found: {task_id}\")\n        elif is_terminal_status(view.runtime.status):\n            self._model.set_message(f\"Task {task_id} is already {view.runtime.status}.\")\n        else:\n            self._model.manager.kill(task_id)\n            self._model.set_message(f\"Stop requested for task {task_id}.\")\n        self._sync_views()\n\n    def _cancel_stop_request(self) -> None:\n        self._model.pending_stop_task_id = None\n        self._model.set_message(\"Stop cancelled.\")\n\n    def _build_app(self) -> Application[None]:\n        kb = KeyBindings()\n\n        @Condition\n        def stop_pending() -> bool:\n            return self._model.pending_stop_task_id is not None\n\n        @kb.add(\"q\")\n        @kb.add(\"escape\", filter=~stop_pending)\n        @kb.add(\"c-c\")\n        def _exit(event: KeyPressEvent) -> None:\n            event.app.exit()\n\n        @kb.add(\"tab\", filter=~stop_pending)\n        def _toggle_filter(event: KeyPressEvent) -> None:\n            self._toggle_filter()\n            event.app.invalidate()\n\n        @kb.add(\"r\", filter=~stop_pending)\n        def _refresh(event: KeyPressEvent) -> None:\n            self._refresh_views()\n            event.app.invalidate()\n\n        @kb.add(\"s\", filter=~stop_pending)\n        def _stop(event: KeyPressEvent) -> None:\n            self._request_stop_for_selected_task()\n            event.app.invalidate()\n\n        @kb.add(\"y\", filter=stop_pending)\n        def _confirm_stop(event: KeyPressEvent) -> None:\n            self._confirm_stop_request()\n            event.app.invalidate()\n\n        @kb.add(\"n\", filter=stop_pending)\n        @kb.add(\"escape\", filter=stop_pending)\n        def _cancel_stop(event: KeyPressEvent) -> None:\n            self._cancel_stop_request()\n            event.app.invalidate()\n\n        @kb.add(\"enter\", filter=~stop_pending, eager=True)\n        @kb.add(\"o\", filter=~stop_pending)\n        def _show_output(event: KeyPressEvent) -> None:\n            task_id = self._selected_task_id\n            if task_id is None:\n                self._model.set_message(\"No task selected.\")\n                event.app.invalidate()\n                return\n            self._open_output(event.app, task_id)\n\n        # Handlers are registered via @kb.add decorators above; mark as accessed.\n        _ = (_exit, _toggle_filter, _refresh, _stop, _confirm_stop, _cancel_stop, _show_output)\n\n        body = VSplit(\n            [\n                Frame(\n                    Box(self._task_list, padding=1),\n                    title=lambda: f\" Tasks [{self._model.filter_mode}] \",\n                ),\n                HSplit(\n                    [\n                        Frame(\n                            Window(\n                                FormattedTextControl(self._detail_fragments),\n                                wrap_lines=True,\n                            ),\n                            title=\" Detail \",\n                        ),\n                        Frame(\n                            Window(\n                                FormattedTextControl(self._preview_fragments),\n                                wrap_lines=True,\n                            ),\n                            title=\" Preview Output \",\n                        ),\n                    ]\n                ),\n            ]\n        )\n        footer = Window(\n            FormattedTextControl(self._footer_fragments),\n            height=1,\n            style=\"class:footer\",\n        )\n        header = Window(\n            FormattedTextControl(self._header_fragments),\n            height=1,\n            style=\"class:header\",\n        )\n\n        return Application(\n            layout=Layout(\n                HSplit(\n                    [\n                        header,\n                        body,\n                        footer,\n                    ]\n                ),\n                focused_element=self._task_list,\n            ),\n            key_bindings=kb,\n            full_screen=True,\n            erase_when_done=True,\n            style=_task_browser_style(),\n            refresh_interval=_AUTO_REFRESH_SECONDS,\n            before_render=lambda _app: self._sync_views(),\n        )\n\n    def _sync_views(self) -> None:\n        values, selected = self._model.refresh(self._selected_task_id)\n        self._task_list.values = values\n        self._task_list.current_value = selected\n        self._task_list.current_values = [selected]\n        for index, (value, _label) in enumerate(values):\n            if value == selected:\n                self._task_list._selected_index = index  # pyright: ignore[reportPrivateUsage]\n                break\n\n    def _header_fragments(self) -> StyleAndTextTuples:\n        return self._model.summary_fragments()\n\n    def _detail_fragments(self) -> StyleAndTextTuples:\n        return [(\"\", self._model.detail_text(self._selected_task_id))]\n\n    def _preview_fragments(self) -> StyleAndTextTuples:\n        return [(\"\", self._model.preview_text(self._selected_task_id))]\n\n    def _footer_fragments(self) -> StyleAndTextTuples:\n        return self._model.footer_fragments(self._selected_task_id)\n\n\ndef _build_full_output_renderable(view: TaskView, output: str) -> Panel:\n    return Panel(\n        Group(\n            Text(f\"Task ID: {view.spec.id}\", style=\"bold\"),\n            Text(f\"Status: {view.runtime.status}\"),\n            Text(f\"Description: {view.spec.description}\"),\n            Text(\"\"),\n            Text(output),\n        ),\n        title=\"Background Task Output\",\n        border_style=\"cyan\",\n    )\n\n\ndef _task_sort_key(view: TaskView) -> tuple[int, float]:\n    if not is_terminal_status(view.runtime.status):\n        return (0, view.spec.created_at)\n    finished_at = view.runtime.finished_at or view.runtime.updated_at or view.spec.created_at\n    return (1, -finished_at)\n\n\ndef _task_timing_label(view: TaskView, *, now: float | None = None) -> str | None:\n    current = now if now is not None else time.time()\n    if view.runtime.finished_at is not None:\n        return f\"finished {format_relative_time(view.runtime.finished_at)}\"\n    if view.runtime.started_at is not None:\n        seconds = max(0, int(current - view.runtime.started_at))\n        return f\"running {format_duration(seconds)}\"\n    return f\"updated {format_relative_time(view.runtime.updated_at)}\"\n\n\ndef _task_browser_style() -> Style:\n    return Style.from_dict(\n        {\n            \"header\": \"bg:#1f2937 #e5e7eb\",\n            \"header.title\": \"bg:#1f2937 #67e8f9 bold\",\n            \"header.meta\": \"bg:#1f2937 #9ca3af\",\n            \"status.running\": \"bg:#1f2937 #86efac bold\",\n            \"status.success\": \"bg:#1f2937 #86efac\",\n            \"status.warning\": \"bg:#1f2937 #fbbf24\",\n            \"status.error\": \"bg:#1f2937 #fca5a5\",\n            \"status.info\": \"bg:#1f2937 #93c5fd\",\n            \"task-list\": \"bg:#111827 #d1d5db\",\n            \"task-list.checked\": \"bg:#164e63 #ecfeff bold\",\n            \"frame.border\": \"#155e75\",\n            \"frame.label\": \"bg:#0f172a #67e8f9 bold\",\n            \"footer\": \"bg:#0f172a #cbd5e1\",\n            \"footer.key\": \"bg:#0f172a #67e8f9 bold\",\n            \"footer.text\": \"bg:#0f172a #cbd5e1\",\n            \"footer.warning\": \"bg:#7f1d1d #fecaca bold\",\n            \"footer.meta\": \"bg:#0f172a #94a3b8\",\n        }\n    )\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/update.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nimport platform\nimport re\nimport shutil\nimport stat\nimport tarfile\nimport tempfile\nfrom enum import Enum, auto\nfrom pathlib import Path\n\nimport aiohttp\n\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.utils.aiohttp import new_client_session\nfrom kimi_cli.utils.logging import logger\n\nBASE_URL = \"https://cdn.kimi.com/binaries/kimi-cli\"\nLATEST_VERSION_URL = f\"{BASE_URL}/latest\"\nINSTALL_DIR = Path.home() / \".local\" / \"bin\"\n\n# Upgrade command shown in toast notifications. Can be overridden by wrappers\nUPGRADE_COMMAND = \"uv tool upgrade kimi-cli\"\n\n\nclass UpdateResult(Enum):\n    UPDATE_AVAILABLE = auto()\n    UPDATED = auto()\n    UP_TO_DATE = auto()\n    FAILED = auto()\n    UNSUPPORTED = auto()\n\n\n_UPDATE_LOCK = asyncio.Lock()\n\n\ndef semver_tuple(version: str) -> tuple[int, int, int]:\n    v = version.strip()\n    if v.startswith(\"v\"):\n        v = v[1:]\n    match = re.match(r\"^(\\d+)\\.(\\d+)(?:\\.(\\d+))?\", v)\n    if not match:\n        return (0, 0, 0)\n    major = int(match.group(1))\n    minor = int(match.group(2))\n    patch = int(match.group(3) or 0)\n    return (major, minor, patch)\n\n\ndef _detect_target() -> str | None:\n    sys_name = platform.system()\n    mach = platform.machine()\n    if mach in (\"x86_64\", \"amd64\", \"AMD64\"):\n        arch = \"x86_64\"\n    elif mach in (\"arm64\", \"aarch64\"):\n        arch = \"aarch64\"\n    else:\n        logger.error(\"Unsupported architecture: {mach}\", mach=mach)\n        return None\n    if sys_name == \"Darwin\":\n        os_name = \"apple-darwin\"\n    elif sys_name == \"Linux\":\n        os_name = \"unknown-linux-gnu\"\n    else:\n        logger.error(\"Unsupported OS: {sys_name}\", sys_name=sys_name)\n        return None\n    return f\"{arch}-{os_name}\"\n\n\nasync def _get_latest_version(session: aiohttp.ClientSession) -> str | None:\n    try:\n        async with session.get(LATEST_VERSION_URL) as resp:\n            resp.raise_for_status()\n            data = await resp.text()\n            return data.strip()\n    except aiohttp.ClientError:\n        logger.exception(\"Failed to get latest version:\")\n        return None\n\n\nasync def do_update(*, print: bool = True, check_only: bool = False) -> UpdateResult:\n    async with _UPDATE_LOCK:\n        return await _do_update(print=print, check_only=check_only)\n\n\nLATEST_VERSION_FILE = get_share_dir() / \"latest_version.txt\"\n\n\nasync def _do_update(*, print: bool, check_only: bool) -> UpdateResult:\n    from kimi_cli.constant import VERSION as current_version\n\n    def _print(message: str) -> None:\n        if print:\n            console.print(message)\n\n    target = _detect_target()\n    if not target:\n        _print(\"[red]Failed to detect target platform.[/red]\")\n        return UpdateResult.UNSUPPORTED\n\n    async with new_client_session() as session:\n        logger.info(\"Checking for updates...\")\n        _print(\"Checking for updates...\")\n        latest_version = await _get_latest_version(session)\n        if not latest_version:\n            _print(\"[red]Failed to check for updates.[/red]\")\n            return UpdateResult.FAILED\n\n        logger.debug(\"Latest version: {latest_version}\", latest_version=latest_version)\n        LATEST_VERSION_FILE.write_text(latest_version, encoding=\"utf-8\")\n\n        cur_t = semver_tuple(current_version)\n        lat_t = semver_tuple(latest_version)\n\n        if cur_t >= lat_t:\n            logger.debug(\"Already up to date: {current_version}\", current_version=current_version)\n            _print(\"[green]Already up to date.[/green]\")\n            return UpdateResult.UP_TO_DATE\n\n        if check_only:\n            logger.info(\n                \"Update available: current={current_version}, latest={latest_version}\",\n                current_version=current_version,\n                latest_version=latest_version,\n            )\n            _print(f\"[yellow]Update available: {latest_version}[/yellow]\")\n            return UpdateResult.UPDATE_AVAILABLE\n\n        logger.info(\n            \"Updating from {current_version} to {latest_version}...\",\n            current_version=current_version,\n            latest_version=latest_version,\n        )\n        _print(f\"Updating from {current_version} to {latest_version}...\")\n\n        filename = f\"kimi-{latest_version}-{target}.tar.gz\"\n        download_url = f\"{BASE_URL}/{latest_version}/{filename}\"\n\n        with tempfile.TemporaryDirectory(prefix=\"kimi-cli-\") as tmpdir:\n            tar_path = os.path.join(tmpdir, filename)\n\n            logger.info(\"Downloading from {download_url}...\", download_url=download_url)\n            _print(\"[grey50]Downloading...[/grey50]\")\n            try:\n                async with session.get(download_url) as resp:\n                    resp.raise_for_status()\n                    with open(tar_path, \"wb\") as f:\n                        async for chunk in resp.content.iter_chunked(1024 * 64):\n                            if chunk:\n                                f.write(chunk)\n            except aiohttp.ClientError:\n                logger.exception(\n                    \"Failed to download update from {download_url}\",\n                    download_url=download_url,\n                )\n                _print(\"[red]Failed to download.[/red]\")\n                return UpdateResult.FAILED\n            except Exception:\n                logger.exception(\"Failed to download:\")\n                _print(\"[red]Failed to download.[/red]\")\n                return UpdateResult.FAILED\n\n            logger.info(\"Extracting archive {tar_path}...\", tar_path=tar_path)\n            _print(\"[grey50]Extracting...[/grey50]\")\n            try:\n                with tarfile.open(tar_path, \"r:gz\") as tar:\n                    tar.extractall(tmpdir)\n                binary_path = None\n                for root, _, files in os.walk(tmpdir):\n                    if \"kimi\" in files:\n                        binary_path = os.path.join(root, \"kimi\")\n                        break\n                if not binary_path:\n                    logger.error(\"Binary 'kimi' not found in archive.\")\n                    _print(\"[red]Binary 'kimi' not found in archive.[/red]\")\n                    return UpdateResult.FAILED\n            except Exception:\n                logger.exception(\"Failed to extract archive:\")\n                _print(\"[red]Failed to extract archive.[/red]\")\n                return UpdateResult.FAILED\n\n            INSTALL_DIR.mkdir(parents=True, exist_ok=True)\n            dest_path = INSTALL_DIR / \"kimi\"\n            logger.info(\"Installing to {dest_path}...\", dest_path=dest_path)\n            _print(\"[grey50]Installing...[/grey50]\")\n\n            try:\n                shutil.copy2(binary_path, dest_path)\n                os.chmod(\n                    dest_path,\n                    os.stat(dest_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,\n                )\n            except Exception:\n                logger.exception(\"Failed to install:\")\n                _print(\"[red]Failed to install.[/red]\")\n                return UpdateResult.FAILED\n\n    _print(\"[green]Updated successfully![/green]\")\n    _print(\"[yellow]Restart Kimi Code CLI to use the new version.[/yellow]\")\n    return UpdateResult.UPDATED\n\n\n# @meta_command\n# async def update(app: \"Shell\", args: list[str]):\n#     \"\"\"Check for updates\"\"\"\n#     await do_update(print=True)\n\n\n# @meta_command(name=\"check-update\")\n# async def check_update(app: \"Shell\", args: list[str]):\n#     \"\"\"Check for updates\"\"\"\n#     await do_update(print=True, check_only=True)\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/usage.py",
    "content": "\"\"\"This file is pure vibe-coded. If any bugs are found, let's just rewrite it...\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping, Sequence\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING, Any, cast\n\nimport aiohttp\nfrom rich.console import Group, RenderableType\nfrom rich.panel import Panel\nfrom rich.progress_bar import ProgressBar\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom kimi_cli.auth import KIMI_CODE_PLATFORM_ID\nfrom kimi_cli.auth.platforms import get_platform_by_id, parse_managed_provider_key\nfrom kimi_cli.config import LLMModel\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell.console import console\nfrom kimi_cli.ui.shell.slash import registry\nfrom kimi_cli.utils.aiohttp import new_client_session\nfrom kimi_cli.utils.datetime import format_duration\n\nif TYPE_CHECKING:\n    from kimi_cli.ui.shell import Shell\n\n\n@dataclass(slots=True, frozen=True)\nclass UsageRow:\n    label: str\n    used: int\n    limit: int\n    reset_hint: str | None = None\n\n\n@registry.command(aliases=[\"/status\"])\nasync def usage(app: Shell, args: str):\n    \"\"\"Display API usage and quota information\"\"\"\n    assert isinstance(app.soul, KimiSoul)\n    if app.soul.runtime.llm is None:\n        console.print(\"[red]LLM not set. Please run /login first.[/red]\")\n        return\n\n    provider = app.soul.runtime.llm.provider_config\n    if provider is None:\n        console.print(\"[red]LLM provider configuration not found.[/red]\")\n        return\n\n    usage_url = _usage_url(app.soul.runtime.llm.model_config)\n    if usage_url is None:\n        console.print(\"[yellow]Usage is available on Kimi Code platform only.[/yellow]\")\n        return\n\n    with console.status(\"[cyan]Fetching usage...[/cyan]\"):\n        api_key = app.soul.runtime.oauth.resolve_api_key(provider.api_key, provider.oauth)\n        try:\n            payload = await _fetch_usage(usage_url, api_key)\n        except aiohttp.ClientResponseError as e:\n            message = \"Failed to fetch usage.\"\n            if e.status == 401:\n                message = \"Authorization failed. Please check your API key.\"\n            elif e.status == 404:\n                message = \"Usage endpoint not available. Try Kimi For Coding.\"\n            console.print(f\"[red]{message}[/red]\")\n            return\n        except aiohttp.ClientError as e:\n            console.print(f\"[red]Failed to fetch usage: {e}[/red]\")\n            return\n\n    summary, limits = _parse_usage_payload(payload)\n    if summary is None and not limits:\n        console.print(\"[yellow]No usage data available.[/yellow]\")\n        return\n\n    console.print(_build_usage_panel(summary, limits))\n\n\ndef _usage_url(model: LLMModel | None) -> str | None:\n    if model is None:\n        return None\n    platform_id = parse_managed_provider_key(model.provider)\n    if platform_id is None:\n        return None\n    platform = get_platform_by_id(platform_id)\n    if platform is None or platform.id != KIMI_CODE_PLATFORM_ID:\n        return None\n    base_url = platform.base_url.rstrip(\"/\")\n    return f\"{base_url}/usages\"\n\n\nasync def _fetch_usage(url: str, api_key: str) -> Mapping[str, Any]:\n    async with (\n        new_client_session() as session,\n        session.get(\n            url,\n            headers={\"Authorization\": f\"Bearer {api_key}\"},\n            raise_for_status=True,\n        ) as resp,\n    ):\n        return await resp.json()\n\n\ndef _parse_usage_payload(\n    payload: Mapping[str, Any],\n) -> tuple[UsageRow | None, list[UsageRow]]:\n    summary: UsageRow | None = None\n    limits: list[UsageRow] = []\n\n    usage = payload.get(\"usage\")\n    if isinstance(usage, Mapping):\n        usage_map: Mapping[str, Any] = cast(Mapping[str, Any], usage)\n        summary = _to_usage_row(usage_map, default_label=\"Weekly limit\")\n\n    raw_limits_obj = payload.get(\"limits\")\n    if isinstance(raw_limits_obj, Sequence):\n        limits_seq: Sequence[Any] = cast(Sequence[Any], raw_limits_obj)\n        for idx, item in enumerate(limits_seq):\n            if not isinstance(item, Mapping):\n                continue\n            item_map: Mapping[str, Any] = cast(Mapping[str, Any], item)\n            detail_raw = item_map.get(\"detail\")\n            detail: Mapping[str, Any] = (\n                cast(Mapping[str, Any], detail_raw) if isinstance(detail_raw, Mapping) else item_map\n            )\n            # window may contain duration/timeUnit\n            window_raw = item_map.get(\"window\")\n            window: Mapping[str, Any] = (\n                cast(Mapping[str, Any], window_raw) if isinstance(window_raw, Mapping) else {}\n            )\n            label = _limit_label(item_map, detail, window, idx)\n            row = _to_usage_row(detail, default_label=label)\n            if row:\n                limits.append(row)\n\n    return summary, limits\n\n\ndef _to_usage_row(data: Mapping[str, Any], *, default_label: str) -> UsageRow | None:\n    limit = _to_int(data.get(\"limit\"))\n    # Support both \"used\" and \"remaining\" (used = limit - remaining)\n    used = _to_int(data.get(\"used\"))\n    if used is None:\n        remaining = _to_int(data.get(\"remaining\"))\n        if remaining is not None and limit is not None:\n            used = limit - remaining\n    if used is None and limit is None:\n        return None\n    return UsageRow(\n        label=str(data.get(\"name\") or data.get(\"title\") or default_label),\n        used=used or 0,\n        limit=limit or 0,\n        reset_hint=_reset_hint(data),\n    )\n\n\ndef _limit_label(\n    item: Mapping[str, Any],\n    detail: Mapping[str, Any],\n    window: Mapping[str, Any],\n    idx: int,\n) -> str:\n    # Try to extract a human-readable label\n    for key in (\"name\", \"title\", \"scope\"):\n        if val := (item.get(key) or detail.get(key)):\n            return str(val)\n\n    # Convert duration to readable format (e.g., 300 minutes -> \"5h quota\")\n    # Check window first, then item, then detail\n    duration = _to_int(window.get(\"duration\") or item.get(\"duration\") or detail.get(\"duration\"))\n    time_unit = window.get(\"timeUnit\") or item.get(\"timeUnit\") or detail.get(\"timeUnit\") or \"\"\n    if duration:\n        if \"MINUTE\" in time_unit:\n            if duration >= 60 and duration % 60 == 0:\n                return f\"{duration // 60}h limit\"\n            return f\"{duration}m limit\"\n        if \"HOUR\" in time_unit:\n            return f\"{duration}h limit\"\n        if \"DAY\" in time_unit:\n            return f\"{duration}d limit\"\n        return f\"{duration}s limit\"\n\n    return f\"Limit #{idx + 1}\"\n\n\ndef _reset_hint(data: Mapping[str, Any]) -> str | None:\n    for key in (\"reset_at\", \"resetAt\", \"reset_time\", \"resetTime\"):\n        if val := data.get(key):\n            return _format_reset_time(str(val))\n\n    for key in (\"reset_in\", \"resetIn\", \"ttl\", \"window\"):\n        seconds = _to_int(data.get(key))\n        if seconds:\n            return f\"resets in {format_duration(seconds)}\"\n\n    return None\n\n\ndef _format_reset_time(val: str) -> str:\n    \"\"\"Format ISO timestamp to a readable duration.\"\"\"\n    from datetime import UTC, datetime\n\n    try:\n        # Parse ISO format like \"2025-12-23T05:24:18.443553353Z\"\n        # Truncate nanoseconds to microseconds for Python compatibility\n        if \".\" in val and val.endswith(\"Z\"):\n            base, frac = val[:-1].split(\".\")\n            frac = frac[:6]  # Keep only microseconds\n            val = f\"{base}.{frac}Z\"\n        dt = datetime.fromisoformat(val.replace(\"Z\", \"+00:00\"))\n        now = datetime.now(UTC)\n        delta = dt - now\n\n        if delta.total_seconds() <= 0:\n            return \"reset\"\n        return f\"resets in {format_duration(int(delta.total_seconds()))}\"\n    except (ValueError, TypeError):\n        return f\"resets at {val}\"\n\n\ndef _to_int(value: Any) -> int | None:\n    try:\n        return int(value)\n    except (TypeError, ValueError):\n        return None\n\n\ndef _build_usage_panel(summary: UsageRow | None, limits: list[UsageRow]) -> Panel:\n    rows = ([summary] if summary else []) + limits\n    if not rows:\n        return Panel(\n            Text(\"No usage data\", style=\"grey50\"), title=\"API Usage\", border_style=\"wheat4\"\n        )\n\n    # Calculate label width for alignment\n    label_width = max(len(r.label) for r in rows)\n    label_width = max(label_width, 6)  # minimum width\n\n    lines: list[RenderableType] = []\n    for row in rows:\n        lines.append(_format_row(row, label_width))\n\n    return Panel(\n        Group(*lines),\n        title=\"API Usage\",\n        border_style=\"wheat4\",\n        padding=(0, 2),\n        expand=False,\n    )\n\n\ndef _format_row(row: UsageRow, label_width: int) -> RenderableType:\n    ratio = (row.limit - row.used) / row.limit if row.limit > 0 else 0\n    color = _ratio_color(ratio)\n\n    label = Text(f\"{row.label:<{label_width}}  \", style=\"cyan\")\n    bar = ProgressBar(total=row.limit or 1, completed=row.used, width=20, complete_style=color)\n\n    detail = Text()\n    percent = ratio * 100\n    detail.append(f\"  {percent:.0f}% left\", style=\"bold\")\n    if row.reset_hint:\n        detail.append(f\"  ({row.reset_hint})\", style=\"grey50\")\n\n    t = Table.grid(padding=0)\n    t.add_column(width=label_width + 2)\n    t.add_column(width=20)\n    t.add_column()\n    t.add_row(label, bar, detail)\n    return t\n\n\ndef _ratio_color(ratio: float) -> str:\n    if ratio >= 0.9:\n        return \"red\"\n    if ratio >= 0.7:\n        return \"yellow\"\n    return \"green\"\n"
  },
  {
    "path": "src/kimi_cli/ui/shell/visualize.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport json\nfrom collections import deque\nfrom collections.abc import Awaitable, Callable\nfrom contextlib import asynccontextmanager, suppress\nfrom io import StringIO\nfrom typing import Any, NamedTuple, cast\n\nimport streamingjson  # type: ignore[reportMissingTypeStubs]\nfrom kosong.message import Message\nfrom kosong.tooling import ToolError, ToolOk\nfrom prompt_toolkit.application.run_in_terminal import run_in_terminal\nfrom prompt_toolkit.buffer import Buffer\nfrom prompt_toolkit.document import Document\nfrom prompt_toolkit.formatted_text import ANSI\nfrom prompt_toolkit.key_binding import KeyPressEvent\nfrom rich.console import Console as RichConsole\nfrom rich.console import Group, RenderableType\nfrom rich.live import Live\nfrom rich.markup import escape\nfrom rich.padding import Padding\nfrom rich.panel import Panel\nfrom rich.spinner import Spinner\nfrom rich.style import Style\nfrom rich.text import Text\n\nfrom kimi_cli.soul import format_context_status\nfrom kimi_cli.tools import extract_key_argument\nfrom kimi_cli.ui.shell.console import NEUTRAL_MARKDOWN_THEME, console\nfrom kimi_cli.ui.shell.echo import render_user_echo, render_user_echo_text\nfrom kimi_cli.ui.shell.keyboard import KeyboardListener, KeyEvent\nfrom kimi_cli.ui.shell.prompt import (\n    CustomPromptSession,\n    UserInput,\n)\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.utils.diff import format_unified_diff\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.rich.columns import BulletColumns\nfrom kimi_cli.utils.rich.markdown import Markdown\nfrom kimi_cli.utils.rich.syntax import KimiSyntax\nfrom kimi_cli.wire import WireUISide\nfrom kimi_cli.wire.types import (\n    ApprovalRequest,\n    ApprovalResponse,\n    BackgroundTaskDisplayBlock,\n    BriefDisplayBlock,\n    CompactionBegin,\n    CompactionEnd,\n    ContentPart,\n    DiffDisplayBlock,\n    MCPLoadingBegin,\n    MCPLoadingEnd,\n    Notification,\n    QuestionRequest,\n    ShellDisplayBlock,\n    StatusUpdate,\n    SteerInput,\n    StepBegin,\n    StepInterrupted,\n    SubagentEvent,\n    TextPart,\n    ThinkPart,\n    TodoDisplayBlock,\n    ToolCall,\n    ToolCallPart,\n    ToolCallRequest,\n    ToolResult,\n    ToolReturnValue,\n    TurnBegin,\n    TurnEnd,\n    WireMessage,\n)\n\nMAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4\nMAX_LIVE_NOTIFICATIONS = 4\n\n# Truncation limits for approval request display\nMAX_PREVIEW_LINES = 4\n\n\nasync def visualize(\n    wire: WireUISide,\n    *,\n    initial_status: StatusUpdate,\n    cancel_event: asyncio.Event | None = None,\n    prompt_session: CustomPromptSession | None = None,\n    steer: Callable[[str | list[ContentPart]], None] | None = None,\n    bind_running_input: Callable[[Callable[[UserInput], None], Callable[[], None]], None]\n    | None = None,\n    unbind_running_input: Callable[[], None] | None = None,\n):\n    \"\"\"\n    A loop to consume agent events and visualize the agent behavior.\n\n    Args:\n        wire: Communication channel with the agent\n        initial_status: Initial status snapshot\n        cancel_event: Event that can be set (e.g., by ESC key) to cancel the run\n    \"\"\"\n    if prompt_session is not None and steer is not None:\n        view = _PromptLiveView(\n            initial_status,\n            prompt_session=prompt_session,\n            steer=steer,\n            cancel_event=cancel_event,\n        )\n        prompt_session.attach_running_prompt(view)\n\n        def _cancel_running_input() -> None:\n            if cancel_event is not None:\n                cancel_event.set()\n\n        if bind_running_input is not None:\n            bind_running_input(view.handle_local_input, _cancel_running_input)\n    else:\n        view = _LiveView(initial_status, cancel_event)\n    try:\n        await view.visualize_loop(wire)\n    finally:\n        if prompt_session is not None and steer is not None:\n            if unbind_running_input is not None:\n                unbind_running_input()\n            assert isinstance(view, _PromptLiveView)\n            prompt_session.detach_running_prompt(view)\n\n\nclass _ContentBlock:\n    def __init__(self, is_think: bool):\n        self.is_think = is_think\n        self._spinner = Spinner(\"dots\", \"Thinking...\" if is_think else \"Composing...\")\n        self.raw_text = \"\"\n\n    def compose(self) -> RenderableType:\n        return self._spinner\n\n    def compose_final(self) -> RenderableType:\n        return BulletColumns(\n            Markdown(\n                self.raw_text,\n                style=\"grey50 italic\" if self.is_think else \"\",\n            ),\n            bullet_style=\"grey50\" if self.is_think else None,\n        )\n\n    def append(self, content: str) -> None:\n        self.raw_text += content\n\n\nclass _ToolCallBlock:\n    class FinishedSubCall(NamedTuple):\n        call: ToolCall\n        result: ToolReturnValue\n\n    def __init__(self, tool_call: ToolCall):\n        self._tool_name = tool_call.function.name\n        self._lexer = streamingjson.Lexer()\n        if tool_call.function.arguments is not None:\n            self._lexer.append_string(tool_call.function.arguments)\n\n        self._argument = extract_key_argument(self._lexer, self._tool_name)\n        self._full_url = self._extract_full_url(tool_call.function.arguments, self._tool_name)\n        self._result: ToolReturnValue | None = None\n\n        self._ongoing_subagent_tool_calls: dict[str, ToolCall] = {}\n        self._last_subagent_tool_call: ToolCall | None = None\n        self._n_finished_subagent_tool_calls = 0\n        self._finished_subagent_tool_calls = deque[_ToolCallBlock.FinishedSubCall](\n            maxlen=MAX_SUBAGENT_TOOL_CALLS_TO_SHOW\n        )\n\n        self._spinning_dots = Spinner(\"dots\", text=\"\")\n        self._renderable: RenderableType = self._compose()\n\n    def compose(self) -> RenderableType:\n        return self._renderable\n\n    @property\n    def finished(self) -> bool:\n        return self._result is not None\n\n    def append_args_part(self, args_part: str):\n        if self.finished:\n            return\n        self._lexer.append_string(args_part)\n        # TODO: maybe don't extract detail if it's already stable\n        argument = extract_key_argument(self._lexer, self._tool_name)\n        if argument and argument != self._argument:\n            self._argument = argument\n            self._full_url = self._extract_full_url(self._lexer.complete_json(), self._tool_name)\n            self._renderable = BulletColumns(\n                self._build_headline_text(),\n                bullet=self._spinning_dots,\n            )\n\n    def finish(self, result: ToolReturnValue):\n        self._result = result\n        self._renderable = self._compose()\n\n    def append_sub_tool_call(self, tool_call: ToolCall):\n        self._ongoing_subagent_tool_calls[tool_call.id] = tool_call\n        self._last_subagent_tool_call = tool_call\n\n    def append_sub_tool_call_part(self, tool_call_part: ToolCallPart):\n        if self._last_subagent_tool_call is None:\n            return\n        if not tool_call_part.arguments_part:\n            return\n        if self._last_subagent_tool_call.function.arguments is None:\n            self._last_subagent_tool_call.function.arguments = tool_call_part.arguments_part\n        else:\n            self._last_subagent_tool_call.function.arguments += tool_call_part.arguments_part\n\n    def finish_sub_tool_call(self, tool_result: ToolResult):\n        self._last_subagent_tool_call = None\n        sub_tool_call = self._ongoing_subagent_tool_calls.pop(tool_result.tool_call_id, None)\n        if sub_tool_call is None:\n            return\n\n        self._finished_subagent_tool_calls.append(\n            _ToolCallBlock.FinishedSubCall(\n                call=sub_tool_call,\n                result=tool_result.return_value,\n            )\n        )\n        self._n_finished_subagent_tool_calls += 1\n        self._renderable = self._compose()\n\n    def _compose(self) -> RenderableType:\n        lines: list[RenderableType] = [\n            self._build_headline_text(),\n        ]\n\n        if self._n_finished_subagent_tool_calls > MAX_SUBAGENT_TOOL_CALLS_TO_SHOW:\n            n_hidden = self._n_finished_subagent_tool_calls - MAX_SUBAGENT_TOOL_CALLS_TO_SHOW\n            lines.append(\n                BulletColumns(\n                    Text(\n                        f\"{n_hidden} more tool call{'s' if n_hidden > 1 else ''} ...\",\n                        style=\"grey50 italic\",\n                    ),\n                    bullet_style=\"grey50\",\n                )\n            )\n        for sub_call, sub_result in self._finished_subagent_tool_calls:\n            argument = extract_key_argument(\n                sub_call.function.arguments or \"\", sub_call.function.name\n            )\n            sub_url = self._extract_full_url(sub_call.function.arguments, sub_call.function.name)\n            sub_text = Text()\n            sub_text.append(\"Used \")\n            sub_text.append(sub_call.function.name, style=\"blue\")\n            if argument:\n                sub_text.append(\" (\", style=\"grey50\")\n                arg_style = Style(color=\"grey50\", link=sub_url) if sub_url else \"grey50\"\n                sub_text.append(argument, style=arg_style)\n                sub_text.append(\")\", style=\"grey50\")\n            lines.append(\n                BulletColumns(\n                    sub_text,\n                    bullet_style=\"green\" if not sub_result.is_error else \"red\",\n                )\n            )\n\n        if self._result is not None:\n            for block in self._result.display:\n                if isinstance(block, BriefDisplayBlock):\n                    style = \"grey50\" if not self._result.is_error else \"red\"\n                    if block.text:\n                        lines.append(Markdown(block.text, style=style))\n                elif isinstance(block, TodoDisplayBlock):\n                    markdown = self._render_todo_markdown(block)\n                    if markdown:\n                        lines.append(Markdown(markdown, style=\"grey50\"))\n                elif isinstance(block, BackgroundTaskDisplayBlock):\n                    lines.append(\n                        Markdown(\n                            (f\"`{block.task_id}` [{block.status}] {block.description}\"),\n                            style=\"grey50\",\n                        )\n                    )\n\n        if self.finished:\n            assert self._result is not None\n            return BulletColumns(\n                Group(*lines),\n                bullet_style=\"green\" if not self._result.is_error else \"red\",\n            )\n        else:\n            return BulletColumns(\n                Group(*lines),\n                bullet=self._spinning_dots,\n            )\n\n    @staticmethod\n    def _extract_full_url(arguments: str | None, tool_name: str) -> str | None:\n        \"\"\"Extract the full URL from FetchURL tool arguments.\"\"\"\n        if tool_name != \"FetchURL\" or not arguments:\n            return None\n        try:\n            args = json.loads(arguments)\n        except (json.JSONDecodeError, TypeError):\n            return None\n        if isinstance(args, dict):\n            url = cast(dict[str, Any], args).get(\"url\")\n            if url:\n                return str(url)\n        return None\n\n    def _build_headline_text(self) -> Text:\n        text = Text()\n        text.append(\"Used \" if self.finished else \"Using \")\n        text.append(self._tool_name, style=\"blue\")\n        if self._argument:\n            text.append(\" (\", style=\"grey50\")\n            arg_style = Style(color=\"grey50\", link=self._full_url) if self._full_url else \"grey50\"\n            text.append(self._argument, style=arg_style)\n            text.append(\")\", style=\"grey50\")\n        return text\n\n    def _render_todo_markdown(self, block: TodoDisplayBlock) -> str:\n        lines: list[str] = []\n        for todo in block.items:\n            normalized = todo.status.replace(\"_\", \" \").lower()\n            match normalized:\n                case \"pending\":\n                    lines.append(f\"- {todo.title}\")\n                case \"in progress\":\n                    lines.append(f\"- {todo.title} ←\")\n                case \"done\":\n                    lines.append(f\"- ~~{todo.title}~~\")\n                case _:\n                    lines.append(f\"- {todo.title}\")\n        return \"\\n\".join(lines)\n\n\nclass _ApprovalContentBlock(NamedTuple):\n    \"\"\"A pre-rendered content block for approval request with line count.\"\"\"\n\n    text: str\n    lines: int\n    style: str = \"\"\n    lexer: str = \"\"\n\n\nclass _NotificationBlock:\n    _SEVERITY_STYLE = {\n        \"info\": \"cyan\",\n        \"success\": \"green\",\n        \"warning\": \"yellow\",\n        \"error\": \"red\",\n    }\n\n    def __init__(self, notification: Notification):\n        self.notification = notification\n\n    def compose(self) -> RenderableType:\n        style = self._SEVERITY_STYLE.get(self.notification.severity, \"cyan\")\n        lines: list[RenderableType] = [Text(self.notification.title, style=f\"bold {style}\")]\n        body = self.notification.body.strip()\n        if body:\n            body_lines = body.splitlines()\n            preview = \"\\n\".join(body_lines[:2])\n            if len(body_lines) > 2:\n                preview += \"\\n...\"\n            lines.append(Text(preview, style=\"grey50\"))\n        return BulletColumns(Group(*lines), bullet_style=style)\n\n\nclass _ApprovalRequestPanel:\n    def __init__(self, request: ApprovalRequest):\n        self.request = request\n        self.options: list[tuple[str, ApprovalResponse.Kind]] = [\n            (\"Approve once\", \"approve\"),\n            (\"Approve for this session\", \"approve_for_session\"),\n            (\"Reject, tell Kimi what to do instead\", \"reject\"),\n        ]\n        self.selected_index = 0\n\n        # Pre-render all content blocks with line counts\n        self._content_blocks: list[_ApprovalContentBlock] = []\n        last_diff_path: str | None = None\n\n        # Handle description (only if no display blocks)\n        if request.description and not request.display:\n            text = request.description.rstrip(\"\\n\")\n            self._content_blocks.append(\n                _ApprovalContentBlock(text=text, lines=text.count(\"\\n\") + 1)\n            )\n\n        # Handle display blocks\n        for block in request.display:\n            if isinstance(block, DiffDisplayBlock):\n                # File path or ellipsis\n                if block.path != last_diff_path:\n                    self._content_blocks.append(\n                        _ApprovalContentBlock(text=block.path, lines=1, style=\"bold\")\n                    )\n                    last_diff_path = block.path\n                else:\n                    self._content_blocks.append(\n                        _ApprovalContentBlock(text=\"⋮\", lines=1, style=\"dim\")\n                    )\n                # Diff content\n                diff_text = format_unified_diff(\n                    block.old_text,\n                    block.new_text,\n                    block.path,\n                    include_file_header=False,\n                ).rstrip(\"\\n\")\n                self._content_blocks.append(\n                    _ApprovalContentBlock(\n                        text=diff_text, lines=diff_text.count(\"\\n\") + 1, lexer=\"diff\"\n                    )\n                )\n            elif isinstance(block, ShellDisplayBlock):\n                text = block.command.rstrip(\"\\n\")\n                self._content_blocks.append(\n                    _ApprovalContentBlock(\n                        text=text, lines=text.count(\"\\n\") + 1, lexer=block.language\n                    )\n                )\n                last_diff_path = None\n            elif isinstance(block, BriefDisplayBlock) and block.text:\n                text = block.text.rstrip(\"\\n\")\n                self._content_blocks.append(\n                    _ApprovalContentBlock(text=text, lines=text.count(\"\\n\") + 1, style=\"grey50\")\n                )\n                last_diff_path = None\n\n        self._total_lines = sum(b.lines for b in self._content_blocks)\n        self.has_expandable_content = self._total_lines > MAX_PREVIEW_LINES\n\n    def render(self) -> RenderableType:\n        \"\"\"Render the approval menu as a bordered panel.\"\"\"\n        content_lines: list[RenderableType] = [\n            Text.from_markup(\n                \"[yellow]\"\n                f\"{escape(self.request.sender)} is requesting approval to \"\n                f\"{escape(self.request.action)}:[/yellow]\"\n            )\n        ]\n        content_lines.append(Text(\"\"))\n\n        # Render content with line budget\n        remaining = MAX_PREVIEW_LINES\n        for block in self._content_blocks:\n            if remaining <= 0:\n                break\n            content_lines.append(self._render_block(block, remaining))\n            remaining -= min(block.lines, remaining)\n\n        if self.has_expandable_content:\n            content_lines.append(Text(\"... (truncated, ctrl-e to expand)\", style=\"dim italic\"))\n\n        lines: list[RenderableType] = []\n        if content_lines:\n            lines.append(Padding(Group(*content_lines), (0, 0, 0, 1)))\n\n        # Add menu options with number key labels\n        if lines:\n            lines.append(Text(\"\"))\n        for i, (option_text, _) in enumerate(self.options):\n            num = i + 1\n            if i == self.selected_index:\n                lines.append(Text(f\"\\u2192 [{num}] {option_text}\", style=\"cyan\"))\n            else:\n                lines.append(Text(f\"  [{num}] {option_text}\", style=\"grey50\"))\n\n        # Keyboard hints\n        lines.append(Text(\"\"))\n        hint = \"  \\u25b2/\\u25bc select  1/2/3 choose  \\u21b5 confirm\"\n        if self.has_expandable_content:\n            hint += \"  ctrl-e expand\"\n        lines.append(Text(hint, style=\"dim\"))\n\n        return Panel(\n            Group(*lines),\n            border_style=\"bold yellow\",\n            title=\"[bold yellow]\\u26a0 ACTION REQUIRED[/bold yellow]\",\n            title_align=\"left\",\n            padding=(0, 1),\n        )\n\n    def _render_block(\n        self, block: _ApprovalContentBlock, max_lines: int | None = None\n    ) -> RenderableType:\n        \"\"\"Render a content block, optionally truncated.\"\"\"\n        text = block.text\n        if max_lines is not None and block.lines > max_lines:\n            # Truncate to max_lines\n            text = \"\\n\".join(text.split(\"\\n\")[:max_lines])\n\n        if block.lexer:\n            return KimiSyntax(text, block.lexer)\n        return Text(text, style=block.style)\n\n    def render_full(self) -> list[RenderableType]:\n        \"\"\"Render full content for pager (no truncation).\"\"\"\n        return [self._render_block(block) for block in self._content_blocks]\n\n    def move_up(self):\n        \"\"\"Move selection up.\"\"\"\n        self.selected_index = (self.selected_index - 1) % len(self.options)\n\n    def move_down(self):\n        \"\"\"Move selection down.\"\"\"\n        self.selected_index = (self.selected_index + 1) % len(self.options)\n\n    def get_selected_response(self) -> ApprovalResponse.Kind:\n        \"\"\"Get the approval response based on selected option.\"\"\"\n        return self.options[self.selected_index][1]\n\n\ndef _show_approval_in_pager(panel: _ApprovalRequestPanel) -> None:\n    \"\"\"Show the full approval request content in a pager.\"\"\"\n    with console.screen(), console.pager(styles=True):\n        # Header: matches the style in _ApprovalRequestPanel.render()\n        console.print(\n            Text.from_markup(\n                \"[yellow]⚠ \"\n                f\"{escape(panel.request.sender)} is requesting approval to \"\n                f\"{escape(panel.request.action)}:[/yellow]\"\n            )\n        )\n        console.print()\n\n        # Render full content (no truncation)\n        for renderable in panel.render_full():\n            console.print(renderable)\n\n\nOTHER_OPTION_LABEL = \"Other\"\n\n\nclass _QuestionRequestPanel:\n    \"\"\"Renders structured questions for the user to answer interactively.\"\"\"\n\n    def __init__(self, request: QuestionRequest):\n        self.request = request\n        self._current_question_index = 0\n        self._answers: dict[str, str] = {}\n        self._saved_selections: dict[int, tuple[int, set[int]]] = {}\n        self._selected_index = 0\n        self._multi_selected: set[int] = set()\n        self._body_text: str = \"\"\n        self.has_expandable_content: bool = False\n        self._setup_current_question()\n\n    def _setup_current_question(self) -> None:\n        q = self._current_question\n        self._options = [(o.label, o.description) for o in q.options]\n        other_label = q.other_label or OTHER_OPTION_LABEL\n        other_desc = q.other_description or \"\"\n        self._options.append((other_label, other_desc))\n        idx = self._current_question_index\n        if idx in self._saved_selections:\n            saved_idx, saved_multi = self._saved_selections[idx]\n            self._selected_index = min(saved_idx, len(self._options) - 1)\n            self._multi_selected = saved_multi\n        elif q.question in self._answers:\n            answer = self._answers[q.question]\n            if q.multi_select:\n                answer_labels = [a.strip() for a in answer.split(\", \")]\n                known_labels = {label for label, _ in self._options[:-1]}\n                self._multi_selected = set()\n                for i, (label, _) in enumerate(self._options[:-1]):\n                    if label in answer_labels:\n                        self._multi_selected.add(i)\n                # Unmatched labels = Other text\n                if any(answer_label not in known_labels for answer_label in answer_labels):\n                    self._multi_selected.add(len(self._options) - 1)\n                self._selected_index = min(self._multi_selected) if self._multi_selected else 0\n            else:\n                for i, (label, _) in enumerate(self._options):\n                    if label == answer:\n                        self._selected_index = i\n                        break\n                else:\n                    # Unknown submitted label should map to the synthetic \"Other\" option.\n                    self._selected_index = len(self._options) - 1\n                self._multi_selected = set()\n        else:\n            self._selected_index = 0\n            self._multi_selected = set()\n        self._recompute_body()\n\n    def _recompute_body(self) -> None:\n        \"\"\"Recompute body content state for the current question.\"\"\"\n        body = self._current_question.body\n        self._body_text = body.rstrip(\"\\n\") if body else \"\"\n        self.has_expandable_content = bool(self._body_text)\n\n    @property\n    def _current_question(self):\n        return self.request.questions[self._current_question_index]\n\n    @property\n    def is_other_selected(self) -> bool:\n        return self._selected_index == len(self._options) - 1\n\n    @property\n    def is_multi_select(self) -> bool:\n        return self._current_question.multi_select\n\n    @property\n    def current_question_text(self) -> str:\n        return self._current_question.question\n\n    def should_prompt_other_input(self) -> bool:\n        \"\"\"Whether pressing ENTER should open free-text input for the current question.\"\"\"\n        if not self.is_multi_select:\n            return self.is_other_selected\n        other_idx = len(self._options) - 1\n        return other_idx in self._multi_selected\n\n    def select_index(self, index: int) -> bool:\n        \"\"\"Select an option by index. Returns False when index is out of range.\"\"\"\n        if not (0 <= index < len(self._options)):\n            return False\n        self._selected_index = index\n        return True\n\n    def render(self) -> RenderableType:\n        q = self._current_question\n        lines: list[RenderableType] = []\n\n        # Tab bar for multi-question navigation\n        if len(self.request.questions) > 1:\n            tab_parts: list[str] = []\n            for i, qi in enumerate(self.request.questions):\n                label = escape(qi.header or f\"Q{i + 1}\")\n                if i == self._current_question_index:\n                    icon, style = \"\\u25cf\", \"bold cyan\"\n                elif qi.question in self._answers:\n                    icon, style = \"\\u2713\", \"green\"\n                else:\n                    icon, style = \"\\u25cb\", \"grey50\"\n                tab_parts.append(f\"[{style}]({icon}) {label}[/{style}]\")\n            lines.append(Text.from_markup(\"  \".join(tab_parts)))\n            lines.append(Text(\"\"))\n\n        # Question text (header is now shown in the tab bar)\n        lines.append(Text.from_markup(f\"[yellow]? {escape(q.question)}[/yellow]\"))\n        if q.multi_select:\n            lines.append(Text(\"  (SPACE to toggle, ENTER to submit)\", style=\"dim italic\"))\n        lines.append(Text(\"\"))\n\n        # Body hint: prompt user to view full content\n        if self._body_text:\n            lines.append(\n                Text.from_markup(\n                    \"[bold cyan]  \\u25b6 Press ctrl-e to view full content[/bold cyan]\"\n                )\n            )\n            lines.append(Text(\"\"))\n\n        # Options with number key labels\n        for i, (label, description) in enumerate(self._options):\n            num = i + 1\n            if q.multi_select:\n                checked = \"\\u2713\" if i in self._multi_selected else \" \"\n                prefix = f\"\\\\[{checked}]\"\n                if i == self._selected_index:\n                    option_line = Text.from_markup(f\"[cyan]{prefix} {escape(label)}[/cyan]\")\n                else:\n                    option_line = Text.from_markup(f\"[grey50]{prefix} {escape(label)}[/grey50]\")\n            else:\n                if i == self._selected_index:\n                    option_line = Text.from_markup(f\"[cyan]\\u2192 \\\\[{num}] {escape(label)}[/cyan]\")\n                else:\n                    option_line = Text.from_markup(f\"[grey50]  \\\\[{num}] {escape(label)}[/grey50]\")\n            lines.append(option_line)\n\n            if description:\n                lines.append(Text(f\"      {description}\", style=\"dim\"))\n\n        # Keyboard hints\n        if len(self.request.questions) > 1:\n            lines.append(Text(\"\"))\n            lines.append(\n                Text(\n                    \"  \\u25c4/\\u25ba switch question  \"\n                    \"\\u25b2/\\u25bc select  \\u21b5 submit  esc exit\",\n                    style=\"dim\",\n                )\n            )\n\n        return Panel(\n            Group(*lines),\n            border_style=\"bold cyan\",\n            title=\"[bold cyan]? QUESTION[/bold cyan]\",\n            title_align=\"left\",\n            padding=(0, 1),\n        )\n\n    def go_to(self, index: int) -> None:\n        \"\"\"Jump to a specific question by index, saving current UI state first.\"\"\"\n        if index == self._current_question_index:\n            return\n        if not (0 <= index < len(self.request.questions)):\n            return\n        # Save current cursor state (not as an answer — only submit() writes answers)\n        self._saved_selections[self._current_question_index] = (\n            self._selected_index,\n            set(self._multi_selected),\n        )\n        self._current_question_index = index\n        self._setup_current_question()\n\n    def next_tab(self) -> None:\n        \"\"\"Switch to the next question tab (no wrap).\"\"\"\n        if self._current_question_index < len(self.request.questions) - 1:\n            self.go_to(self._current_question_index + 1)\n\n    def prev_tab(self) -> None:\n        \"\"\"Switch to the previous question tab (no wrap).\"\"\"\n        if self._current_question_index > 0:\n            self.go_to(self._current_question_index - 1)\n\n    def move_up(self) -> None:\n        self._selected_index = (self._selected_index - 1) % len(self._options)\n\n    def move_down(self) -> None:\n        self._selected_index = (self._selected_index + 1) % len(self._options)\n\n    def toggle_select(self) -> None:\n        \"\"\"Toggle selection for multi-select mode.\"\"\"\n        if not self.is_multi_select:\n            return\n        if self._selected_index in self._multi_selected:\n            self._multi_selected.discard(self._selected_index)\n        else:\n            self._multi_selected.add(self._selected_index)\n\n    def submit(self) -> bool:\n        \"\"\"Submit the current answer and advance. Returns True if all questions are answered.\"\"\"\n        q = self._current_question\n        if q.multi_select:\n            # Check if \"Other\" is among the selected\n            other_idx = len(self._options) - 1\n            if other_idx in self._multi_selected:\n                return False  # caller should handle Other input\n            selected_labels = [\n                self._options[i][0] for i in sorted(self._multi_selected) if i < len(q.options)\n            ]\n            if not selected_labels:\n                return False  # don't allow empty multi-select submission\n            self._answers[q.question] = \", \".join(selected_labels)\n        else:\n            if self.is_other_selected:\n                return False  # caller should handle Other input\n            self._answers[q.question] = self._options[self._selected_index][0]\n        # Clear stale draft so returning to this question uses the submitted answer\n        self._saved_selections.pop(self._current_question_index, None)\n        return self._advance()\n\n    def submit_other(self, text: str) -> bool:\n        \"\"\"Submit 'Other' text for the current question. Returns True if all done.\"\"\"\n        q = self._current_question\n        if q.multi_select:\n            # Include both selected options and the custom text\n            other_idx = len(self._options) - 1\n            selected_labels = [\n                self._options[i][0]\n                for i in sorted(self._multi_selected)\n                if i < len(q.options) and i != other_idx\n            ]\n            if text:\n                selected_labels.append(text)\n            self._answers[q.question] = \", \".join(selected_labels) if selected_labels else text\n        else:\n            self._answers[q.question] = text\n        # Clear stale draft so returning to this question uses the submitted answer\n        self._saved_selections.pop(self._current_question_index, None)\n        return self._advance()\n\n    def _advance(self) -> bool:\n        \"\"\"Move to the next unanswered question. Returns True if all questions are done.\"\"\"\n        total = len(self.request.questions)\n        # Check if all questions have been answered\n        if len(self._answers) >= total:\n            return True\n        # Find the next unanswered question (starting from current + 1, wrapping)\n        for offset in range(1, total + 1):\n            idx = (self._current_question_index + offset) % total\n            if self.request.questions[idx].question not in self._answers:\n                self._current_question_index = idx\n                self._setup_current_question()\n                return False\n        return True\n\n    def get_answers(self) -> dict[str, str]:\n        return self._answers\n\n    def render_full_body(self) -> list[RenderableType]:\n        \"\"\"Render full body content for pager display (no truncation).\"\"\"\n        if not self._body_text:\n            return []\n        return [Markdown(self._body_text)]\n\n\ndef _show_question_body_in_pager(panel: _QuestionRequestPanel) -> None:\n    \"\"\"Show the full question body content in a pager.\"\"\"\n    with console.screen(), console.pager(styles=True):\n        console.print(Text.from_markup(f\"[yellow]? {escape(panel.current_question_text)}[/yellow]\"))\n        console.print()\n        for renderable in panel.render_full_body():\n            console.print(renderable)\n\n\nasync def _prompt_other_input(question_text: str) -> str:\n    \"\"\"Prompt the user for free-text input when 'Other' is selected.\"\"\"\n    from prompt_toolkit import PromptSession\n\n    console.print(Text.from_markup(f\"\\n[yellow]? {escape(question_text)}[/yellow]\"))\n    console.print(Text(\"  Enter your answer:\", style=\"dim\"))\n    try:\n        session: PromptSession[str] = PromptSession()\n        return (await session.prompt_async(\"  > \")).strip()\n    except (EOFError, KeyboardInterrupt):\n        return \"\"\n\n\nclass _StatusBlock:\n    def __init__(self, initial: StatusUpdate) -> None:\n        self.text = Text(\"\", justify=\"right\")\n        self._context_usage: float = 0.0\n        self._context_tokens: int = 0\n        self._max_context_tokens: int = 0\n        self.update(initial)\n\n    def render(self) -> RenderableType:\n        return self.text\n\n    def update(self, status: StatusUpdate) -> None:\n        if status.context_usage is not None:\n            self._context_usage = status.context_usage\n        if status.context_tokens is not None:\n            self._context_tokens = status.context_tokens\n        if status.max_context_tokens is not None:\n            self._max_context_tokens = status.max_context_tokens\n        if status.context_usage is not None:\n            self.text.plain = format_context_status(\n                self._context_usage,\n                self._context_tokens,\n                self._max_context_tokens,\n            )\n\n\ndef _render_renderable_to_ansi(renderable: RenderableType, *, columns: int) -> str:\n    width = max(20, columns)\n    buf = StringIO()\n    render_console = RichConsole(\n        file=buf,\n        force_terminal=True,\n        color_system=\"truecolor\",\n        width=width,\n        theme=NEUTRAL_MARKDOWN_THEME,\n        highlight=False,\n    )\n    render_console.print(renderable, end=\"\")\n    return buf.getvalue()\n\n\n@asynccontextmanager\nasync def _keyboard_listener(\n    handler: Callable[[KeyboardListener, KeyEvent], Awaitable[None]],\n):\n    listener = KeyboardListener()\n    await listener.start()\n\n    async def _keyboard():\n        while True:\n            event = await listener.get()\n            await handler(listener, event)\n\n    task = asyncio.create_task(_keyboard())\n    try:\n        yield\n    finally:\n        task.cancel()\n        with suppress(asyncio.CancelledError):\n            await task\n        await listener.stop()\n\n\nclass _LiveView:\n    def __init__(self, initial_status: StatusUpdate, cancel_event: asyncio.Event | None = None):\n        self._cancel_event = cancel_event\n\n        self._mooning_spinner: Spinner | None = None\n        self._compacting_spinner: Spinner | None = None\n        self._mcp_loading_spinner: Spinner | None = None\n\n        self._current_content_block: _ContentBlock | None = None\n        self._tool_call_blocks: dict[str, _ToolCallBlock] = {}\n        self._last_tool_call_block: _ToolCallBlock | None = None\n        self._approval_request_queue = deque[ApprovalRequest]()\n        \"\"\"\n        It is possible that multiple subagents request approvals at the same time,\n        in which case we will have to queue them up and show them one by one.\n        \"\"\"\n        self._current_approval_request_panel: _ApprovalRequestPanel | None = None\n        self._reject_all_following = False\n        self._question_request_queue = deque[QuestionRequest]()\n        self._current_question_panel: _QuestionRequestPanel | None = None\n        self._notification_blocks = deque[_NotificationBlock]()\n        self._live_notification_blocks = deque[_NotificationBlock](maxlen=MAX_LIVE_NOTIFICATIONS)\n        self._status_block = _StatusBlock(initial_status)\n\n        self._need_recompose = False\n\n    def _reset_live_shape(self, live: Live) -> None:\n        # Rich doesn't expose a public API to clear Live's cached render height.\n        # After leaving the pager, stale height causes cursor restores to jump,\n        # so we reset the private _shape to re-anchor the next refresh.\n        live._live_render._shape = None  # type: ignore[reportPrivateUsage]\n\n    async def visualize_loop(self, wire: WireUISide):\n        with Live(\n            self.compose(),\n            console=console,\n            refresh_per_second=10,\n            transient=True,\n            vertical_overflow=\"visible\",\n        ) as live:\n\n            async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None:\n                # Handle Ctrl+E specially - pause Live while the pager is active\n                if event == KeyEvent.CTRL_E:\n                    if self.has_expandable_panel():\n                        await listener.pause()\n                        live.stop()\n                        try:\n                            self._show_expandable_panel_content()\n                        finally:\n                            # Reset live render shape so the next refresh re-anchors cleanly.\n                            self._reset_live_shape(live)\n                            live.start()\n                            live.update(self.compose(), refresh=True)\n                            await listener.resume()\n                    return\n\n                # Handle ENTER/SPACE on question panel when \"Other\" is selected\n                if self._should_prompt_question_other_for_key(event):\n                    panel = self._current_question_panel\n                    assert panel is not None\n                    question_text = panel.current_question_text\n                    await listener.pause()\n                    live.stop()\n                    try:\n                        text = await _prompt_other_input(question_text)\n                    finally:\n                        self._reset_live_shape(live)\n                        live.start()\n                        await listener.resume()\n\n                    self._submit_question_other_text(text)\n                    live.update(self.compose(), refresh=True)\n                    return\n\n                self.dispatch_keyboard_event(event)\n                if self._need_recompose:\n                    live.update(self.compose(), refresh=True)\n                    self._need_recompose = False\n\n            async with _keyboard_listener(keyboard_handler):\n                while True:\n                    try:\n                        msg = await wire.receive()\n                    except QueueShutDown:\n                        self.cleanup(is_interrupt=False)\n                        live.update(self.compose(), refresh=True)\n                        break\n\n                    if isinstance(msg, StepInterrupted):\n                        self.cleanup(is_interrupt=True)\n                        live.update(self.compose(), refresh=True)\n                        break\n\n                    self.dispatch_wire_message(msg)\n                    if self._need_recompose:\n                        live.update(self.compose(), refresh=True)\n                        self._need_recompose = False\n\n    def refresh_soon(self) -> None:\n        self._need_recompose = True\n\n    def has_expandable_panel(self) -> bool:\n        return (\n            self._expandable_approval_panel() is not None\n            or self._expandable_question_panel() is not None\n        )\n\n    def _expandable_approval_panel(self) -> _ApprovalRequestPanel | None:\n        panel = self._current_approval_request_panel\n        if panel is not None and panel.has_expandable_content:\n            return panel\n        return None\n\n    def _expandable_question_panel(self) -> _QuestionRequestPanel | None:\n        panel = self._current_question_panel\n        if panel is not None and panel.has_expandable_content:\n            return panel\n        return None\n\n    def _show_expandable_panel_content(self) -> bool:\n        if approval_panel := self._expandable_approval_panel():\n            _show_approval_in_pager(approval_panel)\n            return True\n        if question_panel := self._expandable_question_panel():\n            _show_question_body_in_pager(question_panel)\n            return True\n        return False\n\n    def _should_prompt_question_other_for_key(self, key: KeyEvent) -> bool:\n        panel = self._current_question_panel\n        if panel is None or not panel.should_prompt_other_input():\n            return False\n        return key == KeyEvent.ENTER or (key == KeyEvent.SPACE and not panel.is_multi_select)\n\n    def _submit_question_other_text(self, text: str) -> None:\n        panel = self._current_question_panel\n        if panel is None:\n            return\n\n        all_done = panel.submit_other(text)\n        if all_done:\n            panel.request.resolve(panel.get_answers())\n            self.show_next_question_request()\n        self.refresh_soon()\n\n    def compose(self, *, include_status: bool = True) -> RenderableType:\n        \"\"\"Compose the live view display content.\"\"\"\n        blocks: list[RenderableType] = []\n        if self._mcp_loading_spinner is not None:\n            blocks.append(self._mcp_loading_spinner)\n        elif self._mooning_spinner is not None:\n            blocks.append(self._mooning_spinner)\n        elif self._compacting_spinner is not None:\n            blocks.append(self._compacting_spinner)\n        else:\n            if self._current_content_block is not None:\n                blocks.append(self._current_content_block.compose())\n            for tool_call in self._tool_call_blocks.values():\n                blocks.append(tool_call.compose())\n        if self._current_approval_request_panel:\n            blocks.append(self._current_approval_request_panel.render())\n        if self._current_question_panel:\n            blocks.append(self._current_question_panel.render())\n        for notification in self._live_notification_blocks:\n            blocks.append(notification.compose())\n\n        if include_status:\n            blocks.append(self._status_block.render())\n        return Group(*blocks)\n\n    def dispatch_wire_message(self, msg: WireMessage) -> None:\n        \"\"\"Dispatch the Wire message to UI components.\"\"\"\n        assert not isinstance(msg, StepInterrupted)  # handled in visualize_loop\n\n        if isinstance(msg, StepBegin):\n            self.cleanup(is_interrupt=False)\n            self._mcp_loading_spinner = None\n            self._mooning_spinner = Spinner(\"moon\", \"\")\n            self.refresh_soon()\n            return\n\n        if self._mooning_spinner is not None:\n            # any message other than StepBegin should end the mooning state\n            self._mooning_spinner = None\n            self.refresh_soon()\n\n        match msg:\n            case TurnBegin():\n                self.flush_content()\n            case SteerInput(user_input=user_input):\n                self.cleanup(is_interrupt=False)\n                content: list[ContentPart]\n                if isinstance(user_input, list):\n                    content = list(user_input)\n                else:\n                    content = [TextPart(text=user_input)]\n                console.print(render_user_echo(Message(role=\"user\", content=content)))\n            case TurnEnd():\n                pass\n            case CompactionBegin():\n                self._compacting_spinner = Spinner(\"balloon\", \"Compacting...\")\n                self.refresh_soon()\n            case CompactionEnd():\n                self._compacting_spinner = None\n                self.refresh_soon()\n            case MCPLoadingBegin():\n                self._mcp_loading_spinner = Spinner(\"dots\", \"Connecting to MCP servers...\")\n                self.refresh_soon()\n            case MCPLoadingEnd():\n                self._mcp_loading_spinner = None\n                self.refresh_soon()\n            case StatusUpdate():\n                self._status_block.update(msg)\n            case Notification():\n                self.append_notification(msg)\n            case ContentPart():\n                self.append_content(msg)\n            case ToolCall():\n                self.append_tool_call(msg)\n            case ToolCallPart():\n                self.append_tool_call_part(msg)\n            case ToolResult():\n                self.append_tool_result(msg)\n            case ApprovalResponse():\n                # we don't need to handle this because the request is resolved on UI\n                pass\n            case SubagentEvent():\n                self.handle_subagent_event(msg)\n            case ApprovalRequest():\n                self.request_approval(msg)\n            case QuestionRequest():\n                self.request_question(msg)\n            case ToolCallRequest():\n                logger.warning(\"Unexpected ToolCallRequest in shell UI: {msg}\", msg=msg)\n\n    def _try_submit_question(self) -> None:\n        \"\"\"Submit the current question answer; if all done, resolve and advance.\"\"\"\n        panel = self._current_question_panel\n        if panel is None:\n            return\n        all_done = panel.submit()\n        if all_done:\n            panel.request.resolve(panel.get_answers())\n            self.show_next_question_request()\n\n    def dispatch_keyboard_event(self, event: KeyEvent) -> None:\n        # Handle question panel keyboard events\n        if self._current_question_panel is not None:\n            match event:\n                case KeyEvent.UP:\n                    self._current_question_panel.move_up()\n                case KeyEvent.DOWN:\n                    self._current_question_panel.move_down()\n                case KeyEvent.LEFT:\n                    self._current_question_panel.prev_tab()\n                case KeyEvent.RIGHT | KeyEvent.TAB:\n                    self._current_question_panel.next_tab()\n                case KeyEvent.SPACE:\n                    if self._current_question_panel.is_multi_select:\n                        self._current_question_panel.toggle_select()\n                    else:\n                        self._try_submit_question()\n                case KeyEvent.ENTER:\n                    # \"Other\" is handled in keyboard_handler (async context)\n                    self._try_submit_question()\n                case KeyEvent.ESCAPE:\n                    self._current_question_panel.request.resolve({})\n                    self.show_next_question_request()\n                case (\n                    KeyEvent.NUM_1\n                    | KeyEvent.NUM_2\n                    | KeyEvent.NUM_3\n                    | KeyEvent.NUM_4\n                    | KeyEvent.NUM_5\n                ):\n                    # Number keys select option in question panel\n                    num_map = {\n                        KeyEvent.NUM_1: 0,\n                        KeyEvent.NUM_2: 1,\n                        KeyEvent.NUM_3: 2,\n                        KeyEvent.NUM_4: 3,\n                        KeyEvent.NUM_5: 4,\n                    }\n                    idx = num_map[event]\n                    panel = self._current_question_panel\n                    if panel.select_index(idx):\n                        if panel.is_multi_select:\n                            panel.toggle_select()\n                        elif not panel.is_other_selected:\n                            # Auto-submit for single-select (unless \"Other\")\n                            self._try_submit_question()\n                case _:\n                    pass\n            self.refresh_soon()\n            return\n\n        # handle ESC key to cancel the run\n        if event == KeyEvent.ESCAPE and self._cancel_event is not None:\n            self._cancel_event.set()\n            return\n\n        # Handle approval panel keyboard events\n        if self._current_approval_request_panel is not None:\n            match event:\n                case KeyEvent.UP:\n                    self._current_approval_request_panel.move_up()\n                    self.refresh_soon()\n                case KeyEvent.DOWN:\n                    self._current_approval_request_panel.move_down()\n                    self.refresh_soon()\n                case KeyEvent.ENTER:\n                    self._submit_approval()\n                case KeyEvent.NUM_1 | KeyEvent.NUM_2 | KeyEvent.NUM_3:\n                    # Number keys directly select and submit approval option\n                    num_map = {\n                        KeyEvent.NUM_1: 0,\n                        KeyEvent.NUM_2: 1,\n                        KeyEvent.NUM_3: 2,\n                    }\n                    idx = num_map[event]\n                    if idx < len(self._current_approval_request_panel.options):\n                        self._current_approval_request_panel.selected_index = idx\n                        self._submit_approval()\n                case _:\n                    pass\n            return\n\n    def _submit_approval(self) -> None:\n        \"\"\"Submit the currently selected approval response.\"\"\"\n        assert self._current_approval_request_panel is not None\n        resp = self._current_approval_request_panel.get_selected_response()\n        self._current_approval_request_panel.request.resolve(resp)\n        if resp == \"approve_for_session\":\n            to_remove_from_queue: list[ApprovalRequest] = []\n            for request in self._approval_request_queue:\n                # approve all queued requests with the same action\n                if request.action == self._current_approval_request_panel.request.action:\n                    request.resolve(\"approve_for_session\")\n                    to_remove_from_queue.append(request)\n            for request in to_remove_from_queue:\n                self._approval_request_queue.remove(request)\n        elif resp == \"reject\":\n            # one rejection should stop the step immediately\n            while self._approval_request_queue:\n                self._approval_request_queue.popleft().resolve(\"reject\")\n            self._reject_all_following = True\n        self.show_next_approval_request()\n\n    def cleanup(self, is_interrupt: bool) -> None:\n        \"\"\"Cleanup the live view on step end or interruption.\"\"\"\n        self.flush_content()\n\n        for block in self._tool_call_blocks.values():\n            if not block.finished:\n                # this should not happen, but just in case\n                block.finish(\n                    ToolError(message=\"\", brief=\"Interrupted\")\n                    if is_interrupt\n                    else ToolOk(output=\"\")\n                )\n        self._last_tool_call_block = None\n        self.flush_finished_tool_calls()\n        self.flush_notifications()\n\n        while self._approval_request_queue:\n            # should not happen, but just in case\n            self._approval_request_queue.popleft().resolve(\"reject\")\n        self._current_approval_request_panel = None\n        self._reject_all_following = False\n\n        while self._question_request_queue:\n            self._question_request_queue.popleft().resolve({})\n        self._current_question_panel = None\n\n    def flush_content(self) -> None:\n        \"\"\"Flush the current content block.\"\"\"\n        if self._current_content_block is not None:\n            console.print(self._current_content_block.compose_final())\n            self._current_content_block = None\n            self.refresh_soon()\n\n    def flush_finished_tool_calls(self) -> None:\n        \"\"\"Flush all leading finished tool call blocks.\"\"\"\n        tool_call_ids = list(self._tool_call_blocks.keys())\n        for tool_call_id in tool_call_ids:\n            block = self._tool_call_blocks[tool_call_id]\n            if not block.finished:\n                break\n\n            self._tool_call_blocks.pop(tool_call_id)\n            console.print(block.compose())\n            if self._last_tool_call_block == block:\n                self._last_tool_call_block = None\n            self.refresh_soon()\n\n    def flush_notifications(self) -> None:\n        \"\"\"Flush rendered notifications to terminal history.\"\"\"\n        self._live_notification_blocks.clear()\n        while self._notification_blocks:\n            console.print(self._notification_blocks.popleft().compose())\n            self.refresh_soon()\n\n    def append_content(self, part: ContentPart) -> None:\n        match part:\n            case ThinkPart(think=text) | TextPart(text=text):\n                if not text:\n                    return\n                is_think = isinstance(part, ThinkPart)\n                if self._current_content_block is None:\n                    self._current_content_block = _ContentBlock(is_think)\n                    self.refresh_soon()\n                elif self._current_content_block.is_think != is_think:\n                    self.flush_content()\n                    self._current_content_block = _ContentBlock(is_think)\n                    self.refresh_soon()\n                self._current_content_block.append(text)\n            case _:\n                # TODO: support more content part types\n                pass\n\n    def append_tool_call(self, tool_call: ToolCall) -> None:\n        self.flush_content()\n        self._tool_call_blocks[tool_call.id] = _ToolCallBlock(tool_call)\n        self._last_tool_call_block = self._tool_call_blocks[tool_call.id]\n        self.refresh_soon()\n\n    def append_tool_call_part(self, part: ToolCallPart) -> None:\n        if not part.arguments_part:\n            return\n        if self._last_tool_call_block is None:\n            return\n        self._last_tool_call_block.append_args_part(part.arguments_part)\n        self.refresh_soon()\n\n    def append_tool_result(self, result: ToolResult) -> None:\n        if block := self._tool_call_blocks.get(result.tool_call_id):\n            block.finish(result.return_value)\n            self.flush_finished_tool_calls()\n            self.refresh_soon()\n\n    def append_notification(self, notification: Notification) -> None:\n        block = _NotificationBlock(notification)\n        self._notification_blocks.append(block)\n        self._live_notification_blocks.append(block)\n        self.refresh_soon()\n\n    def request_approval(self, request: ApprovalRequest) -> None:\n        # If we're rejecting all following requests, reject immediately\n        if self._reject_all_following:\n            request.resolve(\"reject\")\n            return\n\n        self._approval_request_queue.append(request)\n\n        if self._current_approval_request_panel is None:\n            console.bell()\n            self.show_next_approval_request()\n\n    def show_next_approval_request(self) -> None:\n        \"\"\"\n        Show the next approval request from the queue.\n        If there are no pending requests, clear the current approval panel.\n        \"\"\"\n        if not self._approval_request_queue:\n            if self._current_approval_request_panel is not None:\n                self._current_approval_request_panel = None\n                self.refresh_soon()\n            return\n\n        while self._approval_request_queue:\n            request = self._approval_request_queue.popleft()\n            if request.resolved:\n                # skip resolved requests\n                continue\n            self._current_approval_request_panel = _ApprovalRequestPanel(request)\n            self.refresh_soon()\n            break\n        else:\n            # All queued requests were already resolved\n            if self._current_approval_request_panel is not None:\n                self._current_approval_request_panel = None\n                self.refresh_soon()\n\n    def request_question(self, request: QuestionRequest) -> None:\n        self._question_request_queue.append(request)\n        if self._current_question_panel is None:\n            console.bell()\n            self.show_next_question_request()\n\n    def show_next_question_request(self) -> None:\n        \"\"\"Show the next question request from the queue.\"\"\"\n        if not self._question_request_queue:\n            if self._current_question_panel is not None:\n                self._current_question_panel = None\n                self.refresh_soon()\n            return\n\n        while self._question_request_queue:\n            request = self._question_request_queue.popleft()\n            if request.resolved:\n                continue\n            self._current_question_panel = _QuestionRequestPanel(request)\n            self.refresh_soon()\n            break\n        else:\n            # All queued requests were already resolved\n            if self._current_question_panel is not None:\n                self._current_question_panel = None\n                self.refresh_soon()\n\n    def handle_subagent_event(self, event: SubagentEvent) -> None:\n        block = self._tool_call_blocks.get(event.task_tool_call_id)\n        if block is None:\n            return\n\n        match event.event:\n            case ToolCall() as tool_call:\n                block.append_sub_tool_call(tool_call)\n            case ToolCallPart() as tool_call_part:\n                block.append_sub_tool_call_part(tool_call_part)\n            case ToolResult() as tool_result:\n                block.finish_sub_tool_call(tool_result)\n                self.refresh_soon()\n            case _:\n                # ignore other events for now\n                # TODO: may need to handle multi-level nested subagents\n                pass\n\n\nclass _PromptLiveView(_LiveView):\n    _KEY_MAP: dict[str, KeyEvent] = {\n        \"up\": KeyEvent.UP,\n        \"down\": KeyEvent.DOWN,\n        \"left\": KeyEvent.LEFT,\n        \"right\": KeyEvent.RIGHT,\n        \"tab\": KeyEvent.TAB,\n        \"enter\": KeyEvent.ENTER,\n        \"space\": KeyEvent.SPACE,\n        \"escape\": KeyEvent.ESCAPE,\n        \"1\": KeyEvent.NUM_1,\n        \"2\": KeyEvent.NUM_2,\n        \"3\": KeyEvent.NUM_3,\n        \"4\": KeyEvent.NUM_4,\n        \"5\": KeyEvent.NUM_5,\n    }\n\n    def __init__(\n        self,\n        initial_status: StatusUpdate,\n        *,\n        prompt_session: CustomPromptSession,\n        steer: Callable[[str | list[ContentPart]], None],\n        cancel_event: asyncio.Event | None = None,\n    ) -> None:\n        super().__init__(initial_status, cancel_event)\n        self._prompt_session = prompt_session\n        self._steer = steer\n        self._awaiting_question_other_input = False\n        self._pending_local_steers: deque[str | list[ContentPart]] = deque()\n        self._turn_ended = False\n\n    async def visualize_loop(self, wire: WireUISide):\n        try:\n            while True:\n                try:\n                    msg = await wire.receive()\n                except QueueShutDown:\n                    self.cleanup(is_interrupt=False)\n                    self._flush_prompt_refresh()\n                    break\n\n                if isinstance(msg, StepInterrupted):\n                    self.cleanup(is_interrupt=True)\n                    self._flush_prompt_refresh()\n                    break\n\n                if isinstance(msg, TurnEnd):\n                    self._turn_ended = True\n                    self.cleanup(is_interrupt=False)\n                    self._flush_prompt_refresh()\n                    break\n\n                self.dispatch_wire_message(msg)\n                self._flush_prompt_refresh()\n        finally:\n            self._awaiting_question_other_input = False\n            self._pending_local_steers.clear()\n            self._turn_ended = False\n            self._prompt_session.invalidate()\n\n    def handle_local_input(self, user_input: UserInput) -> None:\n        if not user_input or self._turn_ended:\n            return\n\n        console.print(render_user_echo_text(user_input.command))\n        self._pending_local_steers.append(list(user_input.content))\n        self._steer(user_input.content)\n        self._flush_prompt_refresh()\n\n    def dispatch_wire_message(self, msg: WireMessage) -> None:\n        if isinstance(msg, SteerInput) and self._pending_local_steers:\n            pending = self._pending_local_steers[0]\n            if pending == msg.user_input:\n                self._pending_local_steers.popleft()\n                return\n        super().dispatch_wire_message(msg)\n\n    def render_running_prompt_body(self, columns: int) -> ANSI:\n        if self._turn_ended:\n            return ANSI(\"\")\n        renderable = self.compose(include_status=False)\n        body = _render_renderable_to_ansi(renderable, columns=columns).rstrip(\"\\n\")\n        lines = [body] if body else [\"\"]\n        if self._awaiting_question_other_input:\n            lines.append(\"\\x1b[2mEnter the custom answer, then press Enter.\\x1b[0m\")\n        return ANSI(\"\\n\".join(lines))\n\n    def running_prompt_placeholder(self) -> str | None:\n        return None\n\n    def should_handle_running_prompt_key(self, key: str) -> bool:\n        if self._turn_ended:\n            return False\n        if key == \"c-e\":\n            return self.has_expandable_panel()\n        if self._awaiting_question_other_input:\n            return key in {\"enter\", \"escape\"}\n        if key == \"escape\":\n            return self._cancel_event is not None or self._current_question_panel is not None\n        if self._current_question_panel is not None:\n            return key in {\n                \"up\",\n                \"down\",\n                \"left\",\n                \"right\",\n                \"tab\",\n                \"space\",\n                \"enter\",\n                \"1\",\n                \"2\",\n                \"3\",\n                \"4\",\n                \"5\",\n            }\n        if self._current_approval_request_panel is not None:\n            return key in {\"up\", \"down\", \"enter\", \"1\", \"2\", \"3\"}\n        return False\n\n    def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None:\n        if key == \"c-e\":\n            event.app.create_background_task(self._show_panel_in_pager())\n            return\n\n        if self._awaiting_question_other_input:\n            if key == \"enter\":\n                self._submit_question_other_input(event.current_buffer)\n            elif key == \"escape\":\n                self._clear_buffer(event.current_buffer)\n                self._awaiting_question_other_input = False\n                self.refresh_soon()\n            self._flush_prompt_refresh()\n            return\n\n        mapped = self._KEY_MAP.get(key)\n        if mapped is not None and self._should_prompt_question_other_for_key(mapped):\n            text = event.current_buffer.text.strip()\n            if text:\n                self._submit_question_other_input(event.current_buffer)\n            else:\n                self._clear_buffer(event.current_buffer)\n                self._awaiting_question_other_input = True\n                self.refresh_soon()\n                self._flush_prompt_refresh()\n            return\n\n        if mapped is None:\n            return\n        if (\n            self._current_question_panel is not None\n            or self._current_approval_request_panel is not None\n        ):\n            self._clear_buffer(event.current_buffer)\n        self.dispatch_keyboard_event(mapped)\n        self._flush_prompt_refresh()\n\n    async def _show_panel_in_pager(self) -> None:\n        await run_in_terminal(self._show_expandable_panel_content)\n        self._prompt_session.invalidate()\n\n    def _submit_question_other_input(self, buffer: Buffer) -> None:\n        panel = self._current_question_panel\n        if panel is None:\n            self._clear_buffer(buffer)\n            self._awaiting_question_other_input = False\n            return\n\n        text = buffer.text.strip()\n        self._clear_buffer(buffer)\n        self._awaiting_question_other_input = False\n        self._submit_question_other_text(text)\n\n    @staticmethod\n    def _clear_buffer(buffer: Buffer) -> None:\n        if buffer.text:\n            buffer.document = Document(text=\"\", cursor_position=0)\n\n    def _flush_prompt_refresh(self) -> None:\n        if self._need_recompose:\n            self._prompt_session.invalidate()\n            self._need_recompose = False\n\n    def cleanup(self, is_interrupt: bool) -> None:\n        self._awaiting_question_other_input = False\n        super().cleanup(is_interrupt)\n"
  },
  {
    "path": "src/kimi_cli/utils/__init__.py",
    "content": ""
  },
  {
    "path": "src/kimi_cli/utils/aiohttp.py",
    "content": "from __future__ import annotations\n\nimport ssl\n\nimport aiohttp\nimport certifi\n\n_ssl_context = ssl.create_default_context(cafile=certifi.where())\n\n\ndef new_client_session() -> aiohttp.ClientSession:\n    return aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=_ssl_context))\n"
  },
  {
    "path": "src/kimi_cli/utils/aioqueue.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport sys\n\nif sys.version_info >= (3, 13):\n    QueueShutDown = asyncio.QueueShutDown  # type: ignore[assignment]\n\n    class Queue[T](asyncio.Queue[T]):\n        \"\"\"Asyncio Queue with shutdown support.\"\"\"\n\nelse:\n\n    class QueueShutDown(Exception):\n        \"\"\"Raised when operating on a shut down queue.\"\"\"\n\n    class _Shutdown:\n        \"\"\"Sentinel for queue shutdown.\"\"\"\n\n    _SHUTDOWN = _Shutdown()\n\n    class Queue[T](asyncio.Queue[T | _Shutdown]):\n        \"\"\"Asyncio Queue with shutdown support for Python < 3.13.\"\"\"\n\n        def __init__(self) -> None:\n            super().__init__()\n            self._shutdown = False\n\n        def shutdown(self, immediate: bool = False) -> None:\n            if self._shutdown:\n                return\n            self._shutdown = True\n            if immediate:\n                self._queue.clear()\n\n            getters = list(getattr(self, \"_getters\", []))\n            count = max(1, len(getters))\n            self._enqueue_shutdown(count)\n\n        def _enqueue_shutdown(self, count: int) -> None:\n            for _ in range(count):\n                try:\n                    super().put_nowait(_SHUTDOWN)\n                except asyncio.QueueFull:\n                    self._queue.clear()\n                    super().put_nowait(_SHUTDOWN)\n\n        async def get(self) -> T:\n            if self._shutdown and self.empty():\n                raise QueueShutDown\n            item = await super().get()\n            if isinstance(item, _Shutdown):\n                raise QueueShutDown\n            return item\n\n        def get_nowait(self) -> T:\n            if self._shutdown and self.empty():\n                raise QueueShutDown\n            item = super().get_nowait()\n            if isinstance(item, _Shutdown):\n                raise QueueShutDown\n            return item\n\n        async def put(self, item: T) -> None:\n            if self._shutdown:\n                raise QueueShutDown\n            await super().put(item)\n\n        def put_nowait(self, item: T) -> None:\n            if self._shutdown:\n                raise QueueShutDown\n            super().put_nowait(item)\n"
  },
  {
    "path": "src/kimi_cli/utils/broadcast.py",
    "content": "import asyncio\n\nfrom kimi_cli.utils.aioqueue import Queue\n\n\nclass BroadcastQueue[T]:\n    \"\"\"\n    A broadcast queue that allows multiple subscribers to receive published items.\n    \"\"\"\n\n    def __init__(self) -> None:\n        self._queues: set[Queue[T]] = set()\n\n    def subscribe(self) -> Queue[T]:\n        \"\"\"Create a new subscription queue.\"\"\"\n        queue: Queue[T] = Queue()\n        self._queues.add(queue)\n        return queue\n\n    def unsubscribe(self, queue: Queue[T]) -> None:\n        \"\"\"Remove a subscription queue.\"\"\"\n        self._queues.discard(queue)\n\n    async def publish(self, item: T) -> None:\n        \"\"\"Publish an item to all subscription queues.\"\"\"\n        await asyncio.gather(*(queue.put(item) for queue in self._queues))\n\n    def publish_nowait(self, item: T) -> None:\n        \"\"\"Publish an item to all subscription queues without waiting.\"\"\"\n        for queue in self._queues:\n            queue.put_nowait(item)\n\n    def shutdown(self, immediate: bool = False) -> None:\n        \"\"\"Close all subscription queues.\"\"\"\n        for queue in self._queues:\n            queue.shutdown(immediate=immediate)\n        self._queues.clear()\n"
  },
  {
    "path": "src/kimi_cli/utils/changelog.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import NamedTuple\n\n\nclass ReleaseEntry(NamedTuple):\n    description: str\n    entries: list[str]\n\n\ndef parse_changelog(md_text: str) -> dict[str, ReleaseEntry]:\n    \"\"\"Parse a subset of Keep a Changelog-style markdown into a map:\n    version -> (description, entries)\n\n    Parsing rules:\n    - Versions are denoted by level-2 headings starting with '## ['\n      Example: `## [v0.10.1] - 2025-09-18` or `## [Unreleased]`\n    - For each version section, description is the first contiguous block of\n      non-empty lines that do not start with '-' or '#'.\n    - Entries are all markdown list items starting with '- ' under that version\n      (across any subheadings like '### Added').\n    \"\"\"\n    lines = md_text.splitlines()\n    result: dict[str, ReleaseEntry] = {}\n\n    current_ver: str | None = None\n    collecting_desc = False\n    desc_lines: list[str] = []\n    bullet_lines: list[str] = []\n    seen_content_after_header = False\n\n    def commit():\n        nonlocal current_ver, desc_lines, bullet_lines, result\n        if current_ver is None:\n            return\n        description = \"\\n\".join([line.strip() for line in desc_lines]).strip()\n        # Deduplicate and normalize entries\n        norm_entries = [\n            line.strip()[2:].strip() for line in bullet_lines if line.strip().startswith(\"- \")\n        ]\n        result[current_ver] = ReleaseEntry(description=description, entries=norm_entries)\n\n    for raw in lines:\n        line = raw.rstrip()\n        # Format: `## 0.75 (2026-01-09)` or `## Unreleased`\n        if line.startswith(\"## \"):\n            commit()\n            ver = line[3:].strip()\n            # Remove trailing date in parentheses if present\n            if \"(\" in ver:\n                ver = ver[: ver.find(\"(\")].strip()\n            current_ver = ver\n            desc_lines = []\n            bullet_lines = []\n            collecting_desc = True\n            seen_content_after_header = False\n            continue\n\n        if current_ver is None:\n            # Skip until first version section\n            continue\n\n        if not line.strip():\n            # blank line ends initial description block only after we've seen content\n            if collecting_desc and seen_content_after_header:\n                collecting_desc = False\n            continue\n\n        seen_content_after_header = True\n\n        if line.lstrip().startswith(\"### \"):\n            collecting_desc = False\n            continue\n\n        if line.lstrip().startswith(\"- \"):\n            collecting_desc = False\n            bullet_lines.append(line.strip())\n            continue\n\n        if collecting_desc:\n            # Accumulate description until a blank line or bullets/subheadings\n            desc_lines.append(line.strip())\n        # else: ignore any other free-form text after description block\n\n    # Final flush\n    commit()\n    return result\n\n\ndef format_release_notes(changelog: dict[str, ReleaseEntry], include_lib_changes: bool) -> str:\n    parts: list[str] = []\n    for ver, entry in changelog.items():\n        s = f\"[bold]{ver}[/bold]\"\n        if entry.description:\n            s += f\": {entry.description}\"\n        if entry.entries:\n            for it in entry.entries:\n                if it.lower().startswith(\"lib:\") and not include_lib_changes:\n                    continue\n                s += \"\\n[markdown.item.bullet]• [/]\" + it\n        parts.append(s + \"\\n\")\n    return \"\\n\".join(parts).strip()\n\n\nCHANGELOG = parse_changelog(\n    (Path(__file__).parent.parent / \"CHANGELOG.md\").read_text(encoding=\"utf-8\")\n)\n"
  },
  {
    "path": "src/kimi_cli/utils/clipboard.py",
    "content": "from __future__ import annotations\n\nimport importlib\nimport os\nimport sys\nfrom collections.abc import Iterable\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Any, cast\n\nimport pyperclip\nfrom PIL import Image, ImageGrab\n\n# Video file extensions recognized for clipboard paste.\n_VIDEO_SUFFIXES: frozenset[str] = frozenset(\n    {\".mp4\", \".mkv\", \".avi\", \".mov\", \".wmv\", \".webm\", \".m4v\", \".flv\", \".3gp\", \".3g2\"}\n)\n\n\n@dataclass(frozen=True, slots=True)\nclass ClipboardResult:\n    \"\"\"Result of reading media from the clipboard.\n\n    Both fields may be non-empty when the clipboard contains a mix of\n    image files and non-image files (videos, PDFs, etc.).\n    \"\"\"\n\n    images: tuple[Image.Image, ...]\n    file_paths: tuple[Path, ...]\n\n\ndef is_clipboard_available() -> bool:\n    \"\"\"Check if the Pyperclip clipboard is available.\"\"\"\n    try:\n        pyperclip.paste()\n        return True\n    except Exception:\n        return False\n\n\ndef grab_media_from_clipboard() -> ClipboardResult | None:\n    \"\"\"Read media from the clipboard.\n\n    Inspects the clipboard once and returns all detected media.\n    Image files are returned as loaded PIL images; non-image files\n    (videos, PDFs, etc.) are returned as file paths.\n\n    On macOS the native pasteboard API is tried first to avoid\n    misidentifying a file's thumbnail as clipboard image data.\n    \"\"\"\n    # 1. Try macOS native API for file paths (most reliable for Finder copies).\n    if sys.platform == \"darwin\":\n        file_paths = _read_clipboard_file_paths_macos_native()\n        images, non_image_paths = _classify_file_paths(file_paths)\n        if images or non_image_paths:\n            return ClipboardResult(\n                images=tuple(images),\n                file_paths=tuple(non_image_paths),\n            )\n\n    # 2. Try PIL ImageGrab as fallback.\n    #    - On macOS this uses AppleScript «class furl» for file paths,\n    #      or reads raw image data (TIFF/PNG) from the pasteboard.\n    #    - On other platforms this is the primary clipboard access method.\n    payload = ImageGrab.grabclipboard()\n    if payload is None:\n        return None\n    if isinstance(payload, Image.Image):\n        # Raw image data (screenshot or thumbnail).\n        # If we reach here, the macOS native path lookup did not find any\n        # file paths, so this is safe to treat as a real image.\n        return ClipboardResult(images=(payload,), file_paths=())\n    # payload is a list of file path strings.\n    images, non_image_paths = _classify_file_paths(payload)\n    if images or non_image_paths:\n        return ClipboardResult(\n            images=tuple(images),\n            file_paths=tuple(non_image_paths),\n        )\n    return None\n\n\ndef _classify_file_paths(\n    paths: Iterable[os.PathLike[str] | str],\n) -> tuple[list[Image.Image], list[Path]]:\n    \"\"\"Classify clipboard file paths into images and non-image files.\n\n    Returns ``(images, non_image_paths)`` where *images* contains loaded\n    PIL images and *non_image_paths* contains paths to videos, documents,\n    and other non-image files.\n    \"\"\"\n    resolved: list[Path] = []\n    for item in paths:\n        try:\n            path = Path(item)\n        except (TypeError, ValueError):\n            continue\n        if not path.is_file():\n            continue\n        resolved.append(path)\n\n    images: list[Image.Image] = []\n    non_image_paths: list[Path] = []\n\n    for path in resolved:\n        # Video files are never opened as images.\n        if path.suffix.lower() in _VIDEO_SUFFIXES:\n            non_image_paths.append(path)\n            continue\n        try:\n            with Image.open(path) as img:\n                img.load()\n                images.append(img.copy())\n        except Exception:\n            non_image_paths.append(path)\n\n    return images, non_image_paths\n\n\ndef _read_clipboard_file_paths_macos_native() -> list[Path]:\n    try:\n        appkit = cast(Any, importlib.import_module(\"AppKit\"))\n        foundation = cast(Any, importlib.import_module(\"Foundation\"))\n    except Exception:\n        return []\n\n    NSPasteboard = appkit.NSPasteboard\n    NSURL = foundation.NSURL\n    options_key = getattr(\n        appkit,\n        \"NSPasteboardURLReadingFileURLsOnlyKey\",\n        \"NSPasteboardURLReadingFileURLsOnlyKey\",\n    )\n\n    pb = NSPasteboard.generalPasteboard()\n    options = {options_key: True}\n    try:\n        urls: list[Any] | None = pb.readObjectsForClasses_options_([NSURL], options)\n    except Exception:\n        urls = None\n\n    paths: list[Path] = []\n    if urls:\n        for url in urls:\n            try:\n                path = url.path()\n            except Exception:\n                continue\n            if path:\n                paths.append(Path(str(path)))\n\n    if paths:\n        return paths\n\n    try:\n        file_list = cast(list[str] | str | None, pb.propertyListForType_(\"NSFilenamesPboardType\"))\n    except Exception:\n        return []\n\n    if not file_list:\n        return []\n\n    file_items: list[str] = []\n    if isinstance(file_list, list):\n        file_items.extend(item for item in file_list if item)\n    else:\n        file_items.append(file_list)\n\n    return [Path(item) for item in file_items]\n"
  },
  {
    "path": "src/kimi_cli/utils/datetime.py",
    "content": "from datetime import datetime, timedelta\n\n\ndef format_relative_time(timestamp: float) -> str:\n    \"\"\"Format a timestamp as a relative time string.\"\"\"\n    now = datetime.now()\n    dt = datetime.fromtimestamp(timestamp)\n    diff = now - dt\n    if diff < timedelta(minutes=5):\n        return \"just now\"\n    if diff < timedelta(hours=1):\n        minutes = int(diff.total_seconds() / 60)\n        return f\"{minutes}m ago\"\n    if diff < timedelta(days=1):\n        hours = int(diff.total_seconds() / 3600)\n        return f\"{hours}h ago\"\n    if diff < timedelta(days=7):\n        return f\"{diff.days}d ago\"\n    return dt.strftime(\"%m-%d\")\n\n\ndef format_duration(seconds: int) -> str:\n    \"\"\"Format a duration in seconds using short units.\"\"\"\n    delta = timedelta(seconds=seconds)\n    parts: list[str] = []\n    days = delta.days\n    if days:\n        parts.append(f\"{days}d\")\n    hours, remainder = divmod(delta.seconds, 3600)\n    minutes, secs = divmod(remainder, 60)\n    if hours:\n        parts.append(f\"{hours}h\")\n    if minutes:\n        parts.append(f\"{minutes}m\")\n    if secs and not parts:\n        parts.append(f\"{secs}s\")\n    return \" \".join(parts) or \"0s\"\n"
  },
  {
    "path": "src/kimi_cli/utils/diff.py",
    "content": "from __future__ import annotations\n\nimport difflib\nfrom difflib import SequenceMatcher\n\nfrom kimi_cli.tools.display import DiffDisplayBlock\n\nN_CONTEXT_LINES = 3\n\n\ndef format_unified_diff(\n    old_text: str,\n    new_text: str,\n    path: str = \"\",\n    *,\n    include_file_header: bool = True,\n) -> str:\n    \"\"\"\n    Format a unified diff between old_text and new_text.\n\n    Args:\n        old_text: The original text.\n        new_text: The new text.\n        path: Optional file path for the diff header.\n        include_file_header: Whether to include the ---/+++ file header lines.\n\n    Returns:\n        A unified diff string.\n    \"\"\"\n    old_lines = old_text.splitlines(keepends=True)\n    new_lines = new_text.splitlines(keepends=True)\n\n    # Ensure lines end with newline for proper diff formatting\n    if old_lines and not old_lines[-1].endswith(\"\\n\"):\n        old_lines[-1] += \"\\n\"\n    if new_lines and not new_lines[-1].endswith(\"\\n\"):\n        new_lines[-1] += \"\\n\"\n\n    fromfile = f\"a/{path}\" if path else \"a/file\"\n    tofile = f\"b/{path}\" if path else \"b/file\"\n\n    diff = list(\n        difflib.unified_diff(\n            old_lines,\n            new_lines,\n            fromfile=fromfile,\n            tofile=tofile,\n            lineterm=\"\\n\",\n        )\n    )\n\n    if (\n        not include_file_header\n        and len(diff) >= 2\n        and diff[0].startswith(\"--- \")\n        and diff[1].startswith(\"+++ \")\n    ):\n        diff = diff[2:]\n\n    return \"\".join(diff)\n\n\ndef build_diff_blocks(\n    path: str,\n    old_text: str,\n    new_text: str,\n) -> list[DiffDisplayBlock]:\n    \"\"\"Build diff display blocks grouped with small context windows.\"\"\"\n    old_lines = old_text.splitlines()\n    new_lines = new_text.splitlines()\n    matcher = SequenceMatcher(None, old_lines, new_lines, autojunk=False)\n    blocks: list[DiffDisplayBlock] = []\n    for group in matcher.get_grouped_opcodes(n=N_CONTEXT_LINES):\n        if not group:\n            continue\n        i1 = group[0][1]\n        i2 = group[-1][2]\n        j1 = group[0][3]\n        j2 = group[-1][4]\n        blocks.append(\n            DiffDisplayBlock(\n                path=path,\n                old_text=\"\\n\".join(old_lines[i1:i2]),\n                new_text=\"\\n\".join(new_lines[j1:j2]),\n            )\n        )\n    return blocks\n"
  },
  {
    "path": "src/kimi_cli/utils/editor.py",
    "content": "\"\"\"External editor utilities for editing text in $VISUAL/$EDITOR.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport os\nimport shlex\nimport shutil\nimport subprocess\nimport tempfile\nfrom pathlib import Path\n\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.subprocess_env import get_clean_env\n\n# VSCode needs --wait to block until the file is closed.\n_EDITOR_CANDIDATES = [\n    ([\"code\", \"--wait\"], \"code\"),\n    ([\"vim\"], \"vim\"),\n    ([\"vi\"], \"vi\"),\n    ([\"nano\"], \"nano\"),\n]\n\n\ndef get_editor_command(configured: str = \"\") -> list[str] | None:\n    \"\"\"Determine the editor command to use.\n\n    Priority: *configured* (from config) -> $VISUAL -> $EDITOR -> auto-detect.\n    Auto-detect order: code --wait -> vim -> vi -> nano.\n    \"\"\"\n    if configured:\n        try:\n            return shlex.split(configured)\n        except ValueError:\n            logger.warning(\"Invalid configured editor value: {}\", configured)\n\n    for var in (\"VISUAL\", \"EDITOR\"):\n        value = os.environ.get(var)\n        if value:\n            try:\n                return shlex.split(value)\n            except ValueError:\n                logger.warning(\"Invalid {} value: {}\", var, value)\n                continue\n\n    for cmd, binary in _EDITOR_CANDIDATES:\n        if shutil.which(binary):\n            return cmd\n\n    return None\n\n\ndef edit_text_in_editor(text: str, configured: str = \"\") -> str | None:\n    \"\"\"Open *text* in an external editor and return the edited result.\n\n    Returns ``None`` if the editor failed or the user quit without saving.\n    \"\"\"\n    editor_cmd = get_editor_command(configured)\n    if editor_cmd is None:\n        logger.warning(\"No editor found. Set $VISUAL or $EDITOR.\")\n        return None\n\n    fd, tmpfile = tempfile.mkstemp(suffix=\".md\", prefix=\"kimi-edit-\")\n    try:\n        with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n            f.write(text)\n\n        mtime_before = os.path.getmtime(tmpfile)\n\n        try:\n            returncode = subprocess.call(editor_cmd + [tmpfile], env=get_clean_env())\n        except OSError as exc:\n            logger.warning(\"Failed to launch editor {}: {}\", editor_cmd, exc)\n            return None\n\n        if returncode != 0:\n            logger.warning(\"Editor exited with non-zero return code: {}\", returncode)\n            return None\n\n        mtime_after = os.path.getmtime(tmpfile)\n        if mtime_after == mtime_before:\n            return None\n\n        edited = Path(tmpfile).read_text(encoding=\"utf-8\")\n        if edited.endswith(\"\\n\"):\n            edited = edited[:-1]\n\n        return edited\n    finally:\n        with contextlib.suppress(OSError):\n            os.unlink(tmpfile)\n"
  },
  {
    "path": "src/kimi_cli/utils/environment.py",
    "content": "from __future__ import annotations\n\nimport platform\nfrom dataclasses import dataclass\nfrom typing import Literal\n\nfrom kaos.path import KaosPath\n\n\n@dataclass(slots=True, frozen=True, kw_only=True)\nclass Environment:\n    os_kind: Literal[\"Windows\", \"Linux\", \"macOS\"] | str\n    os_arch: str\n    os_version: str\n    shell_name: Literal[\"bash\", \"sh\", \"Windows PowerShell\"]\n    shell_path: KaosPath\n\n    @staticmethod\n    async def detect() -> Environment:\n        match platform.system():\n            case \"Darwin\":\n                os_kind = \"macOS\"\n            case \"Windows\":\n                os_kind = \"Windows\"\n            case \"Linux\":\n                os_kind = \"Linux\"\n            case system:\n                os_kind = system\n\n        os_arch = platform.machine()\n        os_version = platform.version()\n\n        if os_kind == \"Windows\":\n            shell_name = \"Windows PowerShell\"\n            shell_path = KaosPath(\"powershell.exe\")\n        else:\n            possible_paths = [\n                KaosPath(\"/bin/bash\"),\n                KaosPath(\"/usr/bin/bash\"),\n                KaosPath(\"/usr/local/bin/bash\"),\n            ]\n            fallback_path = KaosPath(\"/bin/sh\")\n            for path in possible_paths:\n                if await path.is_file():\n                    shell_name = \"bash\"\n                    shell_path = path\n                    break\n            else:\n                shell_name = \"sh\"\n                shell_path = fallback_path\n\n        return Environment(\n            os_kind=os_kind,\n            os_arch=os_arch,\n            os_version=os_version,\n            shell_name=shell_name,\n            shell_path=shell_path,\n        )\n"
  },
  {
    "path": "src/kimi_cli/utils/envvar.py",
    "content": "from __future__ import annotations\n\nimport os\n\n_TRUE_VALUES = {\"1\", \"true\", \"t\", \"yes\", \"y\"}\n\n\ndef get_env_bool(name: str, default: bool = False) -> bool:\n    value = os.getenv(name)\n    if value is None:\n        return default\n    return value.strip().lower() in _TRUE_VALUES\n"
  },
  {
    "path": "src/kimi_cli/utils/export.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom collections.abc import Sequence\nfrom datetime import datetime\nfrom pathlib import Path\nfrom textwrap import shorten\nfrom typing import TYPE_CHECKING, cast\n\nimport aiofiles\nfrom kaos.path import KaosPath\nfrom kosong.message import Message\n\nfrom kimi_cli.soul.message import is_system_reminder_message, system\nfrom kimi_cli.utils.message import message_stringify\nfrom kimi_cli.utils.path import sanitize_cli_path\nfrom kimi_cli.wire.types import (\n    AudioURLPart,\n    ContentPart,\n    ImageURLPart,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    VideoURLPart,\n)\n\nif TYPE_CHECKING:\n    from kimi_cli.soul.context import Context\n\n# ---------------------------------------------------------------------------\n# Export helpers\n# ---------------------------------------------------------------------------\n\n_HINT_KEYS = (\"path\", \"file_path\", \"command\", \"query\", \"url\", \"name\", \"pattern\")\n\"\"\"Common tool-call argument keys whose values make good one-line hints.\"\"\"\n\n\ndef _is_checkpoint_message(msg: Message) -> bool:\n    \"\"\"Check if a message is an internal checkpoint marker.\"\"\"\n    if msg.role != \"user\" or len(msg.content) != 1:\n        return False\n    part = msg.content[0]\n    return isinstance(part, TextPart) and part.text.strip().startswith(\"<system>CHECKPOINT\")\n\n\ndef _is_internal_user_message(msg: Message) -> bool:\n    \"\"\"Check if a user message is internal bookkeeping rather than real user input.\"\"\"\n    return _is_checkpoint_message(msg) or is_system_reminder_message(msg)\n\n\ndef _extract_tool_call_hint(args_json: str) -> str:\n    \"\"\"Extract a brief human-readable hint from tool-call arguments.\n\n    Looks for well-known keys (path, command, …) and falls back to the first\n    short string value.  Returns ``\"\"`` when nothing useful is found.\n    \"\"\"\n    try:\n        parsed: object = json.loads(args_json)\n    except (json.JSONDecodeError, TypeError):\n        return \"\"\n    if not isinstance(parsed, dict):\n        return \"\"\n    args = cast(dict[str, object], parsed)\n\n    # Prefer well-known keys\n    for key in _HINT_KEYS:\n        val = args.get(key)\n        if isinstance(val, str) and val.strip():\n            return shorten(val, width=60, placeholder=\"…\")\n\n    # Fallback: first short string value\n    for val in args.values():\n        if isinstance(val, str) and 0 < len(val) <= 80:\n            return shorten(val, width=60, placeholder=\"…\")\n\n    return \"\"\n\n\ndef _format_content_part_md(part: ContentPart) -> str:\n    \"\"\"Convert a single ContentPart to markdown text.\"\"\"\n    match part:\n        case TextPart(text=text):\n            return text\n        case ThinkPart(think=think):\n            if not think.strip():\n                return \"\"\n            return f\"<details><summary>Thinking</summary>\\n\\n{think}\\n\\n</details>\"\n        case ImageURLPart():\n            return \"[image]\"\n        case AudioURLPart():\n            return \"[audio]\"\n        case VideoURLPart():\n            return \"[video]\"\n        case _:\n            return f\"[{part.type}]\"\n\n\ndef _format_tool_call_md(tool_call: ToolCall) -> str:\n    \"\"\"Convert a ToolCall to a markdown sub-section with a readable title.\"\"\"\n    args_raw = tool_call.function.arguments or \"{}\"\n    hint = _extract_tool_call_hint(args_raw)\n    title = f\"#### Tool Call: {tool_call.function.name}\"\n    if hint:\n        title += f\" (`{hint}`)\"\n\n    try:\n        args_formatted = json.dumps(json.loads(args_raw), indent=2, ensure_ascii=False)\n    except json.JSONDecodeError:\n        args_formatted = args_raw\n\n    return f\"{title}\\n<!-- call_id: {tool_call.id} -->\\n```json\\n{args_formatted}\\n```\"\n\n\ndef _format_tool_result_md(msg: Message, tool_name: str, hint: str) -> str:\n    \"\"\"Format a tool result message as a collapsible markdown block.\"\"\"\n    call_id = msg.tool_call_id or \"unknown\"\n\n    # Use _format_content_part_md for consistency with the rest of the module\n    # (message_stringify loses ThinkPart and leaks <system> tags)\n    result_parts: list[str] = []\n    for part in msg.content:\n        text = _format_content_part_md(part)\n        if text.strip():\n            result_parts.append(text)\n    result_text = \"\\n\".join(result_parts)\n\n    summary = f\"Tool Result: {tool_name}\"\n    if hint:\n        summary += f\" (`{hint}`)\"\n\n    return (\n        f\"<details><summary>{summary}</summary>\\n\\n\"\n        f\"<!-- call_id: {call_id} -->\\n\"\n        f\"{result_text}\\n\\n\"\n        \"</details>\"\n    )\n\n\ndef _group_into_turns(history: Sequence[Message]) -> list[list[Message]]:\n    \"\"\"Group messages into logical turns, each starting at a real user message.\"\"\"\n    turns: list[list[Message]] = []\n    current: list[Message] = []\n\n    for msg in history:\n        if _is_internal_user_message(msg):\n            continue\n        if msg.role == \"user\" and current:\n            turns.append(current)\n            current = []\n        current.append(msg)\n\n    if current:\n        turns.append(current)\n    return turns\n\n\ndef _format_turn_md(messages: list[Message], turn_number: int) -> str:\n    \"\"\"Format a logical turn as a markdown section.\n\n    A turn typically contains:\n      user message -> assistant (thinking + text + tool_calls) -> tool results\n      -> assistant (more text + tool_calls) -> tool results -> assistant (final)\n    All assistant/tool messages are grouped under a single ``### Assistant`` heading.\n    \"\"\"\n    lines: list[str] = [f\"## Turn {turn_number}\", \"\"]\n\n    # tool_call_id -> (function_name, hint)\n    tool_call_info: dict[str, tuple[str, str]] = {}\n    assistant_header_written = False\n\n    for msg in messages:\n        if _is_internal_user_message(msg):\n            continue\n\n        if msg.role == \"user\":\n            lines.append(\"### User\")\n            lines.append(\"\")\n            for part in msg.content:\n                text = _format_content_part_md(part)\n                if text.strip():\n                    lines.append(text)\n                    lines.append(\"\")\n\n        elif msg.role == \"assistant\":\n            if not assistant_header_written:\n                lines.append(\"### Assistant\")\n                lines.append(\"\")\n                assistant_header_written = True\n\n            # Content parts (thinking, text, media)\n            for part in msg.content:\n                text = _format_content_part_md(part)\n                if text.strip():\n                    lines.append(text)\n                    lines.append(\"\")\n\n            # Tool calls\n            if msg.tool_calls:\n                for tc in msg.tool_calls:\n                    hint = _extract_tool_call_hint(tc.function.arguments or \"{}\")\n                    tool_call_info[tc.id] = (tc.function.name, hint)\n                    lines.append(_format_tool_call_md(tc))\n                    lines.append(\"\")\n\n        elif msg.role == \"tool\":\n            tc_id = msg.tool_call_id or \"\"\n            name, hint = tool_call_info.get(tc_id, (\"unknown\", \"\"))\n            lines.append(_format_tool_result_md(msg, name, hint))\n            lines.append(\"\")\n\n        elif msg.role in (\"system\", \"developer\"):\n            lines.append(f\"### {msg.role.capitalize()}\")\n            lines.append(\"\")\n            for part in msg.content:\n                text = _format_content_part_md(part)\n                if text.strip():\n                    lines.append(text)\n                    lines.append(\"\")\n\n    return \"\\n\".join(lines)\n\n\ndef _build_overview(\n    history: Sequence[Message],\n    turns: list[list[Message]],\n    token_count: int,\n) -> str:\n    \"\"\"Build the Overview section from existing data (no LLM call).\"\"\"\n    # Topic: first real user message text, truncated\n    topic = \"\"\n    for msg in history:\n        if msg.role == \"user\" and not _is_internal_user_message(msg):\n            topic = shorten(message_stringify(msg), width=80, placeholder=\"…\")\n            break\n\n    # Count tool calls across all messages\n    n_tool_calls = sum(len(msg.tool_calls) for msg in history if msg.tool_calls)\n\n    lines = [\n        \"## Overview\",\n        \"\",\n        f\"- **Topic**: {topic}\" if topic else \"- **Topic**: (empty)\",\n        f\"- **Conversation**: {len(turns)} turns | \"\n        f\"{n_tool_calls} tool calls | {token_count:,} tokens\",\n        \"\",\n        \"---\",\n    ]\n    return \"\\n\".join(lines)\n\n\ndef build_export_markdown(\n    session_id: str,\n    work_dir: str,\n    history: Sequence[Message],\n    token_count: int,\n    now: datetime,\n) -> str:\n    \"\"\"Build the full export markdown string.\"\"\"\n    lines: list[str] = [\n        \"---\",\n        f\"session_id: {session_id}\",\n        f\"exported_at: {now.isoformat(timespec='seconds')}\",\n        f\"work_dir: {work_dir}\",\n        f\"message_count: {len(history)}\",\n        f\"token_count: {token_count}\",\n        \"---\",\n        \"\",\n        \"# Kimi Session Export\",\n        \"\",\n    ]\n\n    turns = _group_into_turns(history)\n    lines.append(_build_overview(history, turns, token_count))\n    lines.append(\"\")\n\n    for idx, turn_messages in enumerate(turns):\n        lines.append(_format_turn_md(turn_messages, idx + 1))\n\n    return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# Import helpers\n# ---------------------------------------------------------------------------\n\n_IMPORTABLE_EXTENSIONS: frozenset[str] = frozenset(\n    {\n        # Markdown / plain text\n        \".md\",\n        \".markdown\",\n        \".txt\",\n        \".text\",\n        \".rst\",\n        # Data / config\n        \".json\",\n        \".jsonl\",\n        \".yaml\",\n        \".yml\",\n        \".toml\",\n        \".ini\",\n        \".cfg\",\n        \".conf\",\n        \".csv\",\n        \".tsv\",\n        \".xml\",\n        \".env\",\n        \".properties\",\n        # Source code\n        \".py\",\n        \".js\",\n        \".ts\",\n        \".jsx\",\n        \".tsx\",\n        \".java\",\n        \".kt\",\n        \".go\",\n        \".rs\",\n        \".c\",\n        \".cpp\",\n        \".h\",\n        \".hpp\",\n        \".cs\",\n        \".rb\",\n        \".php\",\n        \".swift\",\n        \".scala\",\n        \".sh\",\n        \".bash\",\n        \".zsh\",\n        \".fish\",\n        \".ps1\",\n        \".bat\",\n        \".cmd\",\n        \".r\",\n        \".R\",\n        \".lua\",\n        \".pl\",\n        \".pm\",\n        \".ex\",\n        \".exs\",\n        \".erl\",\n        \".hs\",\n        \".ml\",\n        \".sql\",\n        \".graphql\",\n        \".proto\",\n        # Web\n        \".html\",\n        \".htm\",\n        \".css\",\n        \".scss\",\n        \".sass\",\n        \".less\",\n        \".svg\",\n        # Logs\n        \".log\",\n        # Documentation\n        \".tex\",\n        \".bib\",\n        \".org\",\n        \".adoc\",\n        \".wiki\",\n    }\n)\n\"\"\"File extensions accepted by ``/import``.  Only text-based formats are\nsupported — importing binary files (images, PDFs, archives, …) is rejected\nwith a friendly message.\"\"\"\n\n\ndef is_importable_file(path_str: str) -> bool:\n    \"\"\"Return True if *path_str* has an extension in the importable whitelist.\n\n    Files with no extension are also accepted (could be READMEs, Makefiles, …).\n    \"\"\"\n    suffix = Path(path_str).suffix.lower()\n    return suffix == \"\" or suffix in _IMPORTABLE_EXTENSIONS\n\n\ndef _stringify_content_parts(parts: Sequence[ContentPart]) -> str:\n    \"\"\"Serialize a list of ContentParts to readable text, preserving ThinkPart.\"\"\"\n    segments: list[str] = []\n    for part in parts:\n        match part:\n            case TextPart(text=text):\n                if text.strip():\n                    segments.append(text)\n            case ThinkPart(think=think):\n                if think.strip():\n                    segments.append(f\"<thinking>\\n{think}\\n</thinking>\")\n            case ImageURLPart():\n                segments.append(\"[image]\")\n            case AudioURLPart():\n                segments.append(\"[audio]\")\n            case VideoURLPart():\n                segments.append(\"[video]\")\n            case _:\n                segments.append(f\"[{part.type}]\")\n    return \"\\n\".join(segments)\n\n\ndef _stringify_tool_calls(tool_calls: Sequence[ToolCall]) -> str:\n    \"\"\"Serialize tool calls to readable text.\"\"\"\n    lines: list[str] = []\n    for tc in tool_calls:\n        args_raw = tc.function.arguments or \"{}\"\n        try:\n            args = json.loads(args_raw)\n            args_str = json.dumps(args, ensure_ascii=False)\n        except (json.JSONDecodeError, TypeError):\n            args_str = args_raw\n        lines.append(f\"Tool Call: {tc.function.name}({args_str})\")\n    return \"\\n\".join(lines)\n\n\ndef stringify_context_history(history: Sequence[Message]) -> str:\n    \"\"\"Convert a sequence of Messages to a readable text transcript.\n\n    Preserves ThinkPart content, tool call information, and tool results\n    so that an AI receiving the imported context has a complete picture.\n    \"\"\"\n    parts: list[str] = []\n    for msg in history:\n        if _is_internal_user_message(msg):\n            continue\n\n        role_label = msg.role.upper()\n        segments: list[str] = []\n\n        # Content parts (text, thinking, media)\n        content_text = _stringify_content_parts(msg.content)\n        if content_text.strip():\n            segments.append(content_text)\n\n        # Tool calls (only on assistant messages)\n        if msg.tool_calls:\n            segments.append(_stringify_tool_calls(msg.tool_calls))\n\n        if not segments:\n            continue\n\n        header = f\"[{role_label}]\"\n        if msg.role == \"tool\" and msg.tool_call_id:\n            header = f\"[{role_label}] (call_id: {msg.tool_call_id})\"\n\n        parts.append(f\"{header}\\n\" + \"\\n\".join(segments))\n    return \"\\n\\n\".join(parts)\n\n\n# ---------------------------------------------------------------------------\n# Shared command logic\n# ---------------------------------------------------------------------------\n\n\nasync def perform_export(\n    history: Sequence[Message],\n    session_id: str,\n    work_dir: str,\n    token_count: int,\n    args: str,\n    default_dir: Path,\n) -> tuple[Path, int] | str:\n    \"\"\"Perform the full export operation.\n\n    Returns ``(output_path, message_count)`` on success, or an error message\n    string on failure.\n    \"\"\"\n    if not history:\n        return \"No messages to export.\"\n\n    now = datetime.now().astimezone()\n    short_id = session_id[:8]\n    default_name = f\"kimi-export-{short_id}-{now.strftime('%Y%m%d-%H%M%S')}.md\"\n\n    cleaned = sanitize_cli_path(args)\n    if cleaned:\n        # sanitize_cli_path only strips quotes; it preserves trailing separators.\n        directory_hint = cleaned.endswith((\"/\", \"\\\\\"))\n        output = Path(cleaned).expanduser()\n        if not output.is_absolute():\n            output = default_dir / output\n        # Keep explicit \"directory intent\" even when the directory does not exist yet.\n        if directory_hint or output.is_dir():\n            output = output / default_name\n    else:\n        output = default_dir / default_name\n\n    content = build_export_markdown(\n        session_id=session_id,\n        work_dir=work_dir,\n        history=history,\n        token_count=token_count,\n        now=now,\n    )\n\n    try:\n        output.parent.mkdir(parents=True, exist_ok=True)\n        async with aiofiles.open(output, \"w\", encoding=\"utf-8\") as f:\n            await f.write(content)\n    except OSError as e:\n        return f\"Failed to write export file: {e}\"\n\n    return (output, len(history))\n\n\nMAX_IMPORT_SIZE = 10 * 1024 * 1024  # 10 MB\n\"\"\"Maximum size (in bytes) of a file that can be imported via ``/import``.\"\"\"\n\n_SENSITIVE_FILE_PATTERNS: tuple[str, ...] = (\n    \".env\",\n    \"credentials\",\n    \"secrets\",\n    \".pem\",\n    \".key\",\n    \".p12\",\n    \".pfx\",\n    \".keystore\",\n)\n\"\"\"File-name substrings that indicate potentially sensitive content.\"\"\"\n\n\ndef is_sensitive_file(filename: str) -> bool:\n    \"\"\"Return True if *filename* looks like it may contain secrets.\"\"\"\n    name = filename.lower()\n    return any(pat in name for pat in _SENSITIVE_FILE_PATTERNS)\n\n\ndef _validate_import_token_budget(\n    estimated_tokens: int,\n    current_token_count: int,\n    max_context_size: int | None,\n) -> str | None:\n    \"\"\"Return an error if importing would push the session over the context budget.\n\n    *estimated_tokens* is the pre-computed token estimate for the import\n    message.  The check is ``current_token_count + estimated_tokens <=\n    max_context_size``.\n    \"\"\"\n    if max_context_size is None or max_context_size <= 0:\n        return None\n\n    total_after_import = current_token_count + estimated_tokens\n    if total_after_import <= max_context_size:\n        return None\n\n    return (\n        \"Imported content is too large for the current model context \"\n        f\"(~{estimated_tokens:,} import tokens + {current_token_count:,} existing \"\n        f\"= ~{total_after_import:,} total > {max_context_size:,} token limit). \"\n        \"Please import a smaller file or session.\"\n    )\n\n\nasync def resolve_import_source(\n    target: str,\n    current_session_id: str,\n    work_dir: KaosPath,\n) -> tuple[str, str] | str:\n    \"\"\"Resolve the import source to ``(content, source_desc)`` or an error message.\n\n    This function handles I/O and source-level validation (file type, encoding,\n    byte-size cap).  Session-level concerns like token budget are checked by\n    :func:`perform_import`.\n    \"\"\"\n    from kimi_cli.session import Session\n    from kimi_cli.soul.context import Context\n\n    target_path = Path(target).expanduser()\n    if not target_path.is_absolute():\n        target_path = Path(str(work_dir)) / target_path\n\n    if target_path.exists() and target_path.is_dir():\n        return \"The specified path is a directory; please provide a file to import.\"\n\n    if target_path.exists() and target_path.is_file():\n        if not is_importable_file(target_path.name):\n            return (\n                f\"Unsupported file type '{target_path.suffix}'. \"\n                \"/import only supports text-based files \"\n                \"(e.g. .md, .txt, .json, .py, .log, …).\"\n            )\n\n        try:\n            file_size = target_path.stat().st_size\n        except OSError as e:\n            return f\"Failed to read file: {e}\"\n        if file_size > MAX_IMPORT_SIZE:\n            limit_mb = MAX_IMPORT_SIZE // (1024 * 1024)\n            return (\n                f\"File is too large ({file_size / 1024 / 1024:.1f} MB). \"\n                f\"Maximum import size is {limit_mb} MB.\"\n            )\n\n        try:\n            async with aiofiles.open(target_path, encoding=\"utf-8\") as f:\n                content = await f.read()\n        except UnicodeDecodeError:\n            return (\n                f\"Cannot import '{target_path.name}': \"\n                \"the file does not appear to be valid UTF-8 text.\"\n            )\n        except OSError as e:\n            return f\"Failed to read file: {e}\"\n\n        if not content.strip():\n            return \"The file is empty, nothing to import.\"\n\n        return (content, f\"file '{target_path.name}'\")\n\n    # Not a file on disk — try as session ID\n    if target == current_session_id:\n        return \"Cannot import the current session into itself.\"\n\n    source_session = await Session.find(work_dir, target)\n    if source_session is None:\n        return f\"'{target}' is not a valid file path or session ID.\"\n\n    source_context = Context(source_session.context_file)\n    try:\n        restored = await source_context.restore()\n    except Exception as e:\n        return f\"Failed to load source session: {e}\"\n    if not restored or not source_context.history:\n        return \"The source session has no messages.\"\n\n    content = stringify_context_history(source_context.history)\n    content_bytes = len(content.encode(\"utf-8\"))\n    if content_bytes > MAX_IMPORT_SIZE:\n        limit_mb = MAX_IMPORT_SIZE // (1024 * 1024)\n        actual_mb = content_bytes / 1024 / 1024\n        return (\n            f\"Session content is too large ({actual_mb:.1f} MB). \"\n            f\"Maximum import size is {limit_mb} MB.\"\n        )\n    return (content, f\"session '{target}'\")\n\n\ndef build_import_message(content: str, source_desc: str) -> Message:\n    \"\"\"Build the ``Message`` to append to context for an import operation.\"\"\"\n    import_text = f'<imported_context source=\"{source_desc}\">\\n{content}\\n</imported_context>'\n    return Message(\n        role=\"user\",\n        content=[\n            system(\n                f\"The user has imported context from {source_desc}. \"\n                \"This is a prior conversation history that may be relevant \"\n                \"to the current session. \"\n                \"Please review this context and use it to inform your responses.\"\n            ),\n            TextPart(text=import_text),\n        ],\n    )\n\n\nasync def perform_import(\n    target: str,\n    current_session_id: str,\n    work_dir: KaosPath,\n    context: Context,\n    max_context_size: int | None = None,\n) -> tuple[str, int] | str:\n    \"\"\"High-level import operation: resolve source, validate, build message, update context.\n\n    Returns ``(source_desc, content_len)`` on success, or an error message\n    string.  *content_len* is the raw imported content length in characters\n    (excluding wrapper markup), suitable for user-facing display.\n    The caller is responsible for any additional side-effects (wire file writes,\n    UI output, etc.).\n    \"\"\"\n    from kimi_cli.soul.compaction import estimate_text_tokens\n\n    result = await resolve_import_source(\n        target=target,\n        current_session_id=current_session_id,\n        work_dir=work_dir,\n    )\n    if isinstance(result, str):\n        return result\n\n    content, source_desc = result\n    message = build_import_message(content, source_desc)\n\n    # Token budget check — reject before mutating context.\n    estimated = estimate_text_tokens([message])\n    if error := _validate_import_token_budget(estimated, context.token_count, max_context_size):\n        return error\n\n    await context.append_message(message)\n    await context.update_token_count(context.token_count + estimated)\n\n    return (source_desc, len(content))\n"
  },
  {
    "path": "src/kimi_cli/utils/frontmatter.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any, cast\n\nimport yaml\n\n\ndef parse_frontmatter(text: str) -> dict[str, Any] | None:\n    \"\"\"\n    Parse YAML frontmatter from a text blob.\n\n    Raises:\n        ValueError: If the frontmatter YAML is invalid.\n    \"\"\"\n    lines = text.splitlines()\n    if not lines or lines[0].strip() != \"---\":\n        return None\n\n    frontmatter_lines: list[str] = []\n    for line in lines[1:]:\n        if line.strip() == \"---\":\n            break\n        frontmatter_lines.append(line)\n    else:\n        return None\n\n    frontmatter = \"\\n\".join(frontmatter_lines).strip()\n    if not frontmatter:\n        return None\n\n    try:\n        raw_data: Any = yaml.safe_load(frontmatter)\n    except yaml.YAMLError as exc:\n        raise ValueError(\"Invalid frontmatter YAML.\") from exc\n\n    if not isinstance(raw_data, dict):\n        raise ValueError(\"Frontmatter YAML must be a mapping.\")\n\n    return cast(dict[str, Any], raw_data)\n\n\ndef read_frontmatter(path: Path) -> dict[str, Any] | None:\n    \"\"\"\n    Read the YAML frontmatter at the start of a file.\n\n    Args:\n        path: Path to an existing file that may contain frontmatter.\n    \"\"\"\n    return parse_frontmatter(path.read_text(encoding=\"utf-8\", errors=\"replace\"))\n"
  },
  {
    "path": "src/kimi_cli/utils/io.py",
    "content": "from __future__ import annotations\n\nimport contextlib\nimport json\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom typing import Any\n\n\ndef atomic_json_write(data: Any, path: Path) -> None:\n    \"\"\"Write JSON data to a file atomically using tmp-file + os.replace.\n\n    This prevents data corruption if the process crashes mid-write: either the\n    old file is kept intact or the new file is fully committed.\n    \"\"\"\n    fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=\".tmp\")\n    try:\n        with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n            json.dump(data, f, indent=2, ensure_ascii=False)\n            f.flush()\n            os.fsync(f.fileno())\n        os.replace(tmp_path, path)\n    except BaseException:\n        with contextlib.suppress(OSError):\n            os.unlink(tmp_path)\n        raise\n"
  },
  {
    "path": "src/kimi_cli/utils/logging.py",
    "content": "from __future__ import annotations\n\nimport codecs\nimport contextlib\nimport locale\nimport os\nimport sys\nimport threading\nfrom collections.abc import Iterator\nfrom typing import IO\n\nfrom kimi_cli import logger\n\n\nclass StderrRedirector:\n    def __init__(self, level: str = \"ERROR\") -> None:\n        self._level = level\n        self._encoding: str | None = None\n        self._installed = False\n        self._lock = threading.Lock()\n        self._original_fd: int | None = None\n        self._read_fd: int | None = None\n        self._thread: threading.Thread | None = None\n\n    def install(self) -> None:\n        with self._lock:\n            if self._installed:\n                return\n            with contextlib.suppress(Exception):\n                sys.stderr.flush()\n            if self._original_fd is None:\n                with contextlib.suppress(OSError):\n                    self._original_fd = os.dup(2)\n            if self._encoding is None:\n                self._encoding = (\n                    sys.stderr.encoding or locale.getpreferredencoding(False) or \"utf-8\"\n                )\n            read_fd, write_fd = os.pipe()\n            os.dup2(write_fd, 2)\n            os.close(write_fd)\n            self._read_fd = read_fd\n            self._thread = threading.Thread(\n                target=self._drain, name=\"kimi-stderr-redirect\", daemon=True\n            )\n            self._thread.start()\n            self._installed = True\n\n    def uninstall(self) -> None:\n        with self._lock:\n            if not self._installed:\n                return\n            if self._original_fd is not None:\n                os.dup2(self._original_fd, 2)\n            self._installed = False\n        if self._thread is not None:\n            self._thread.join(timeout=2.0)\n            self._thread = None\n\n    def _drain(self) -> None:\n        buffer = \"\"\n        read_fd = self._read_fd\n        if read_fd is None:\n            return\n        encoding = self._encoding or \"utf-8\"\n        decoder = codecs.getincrementaldecoder(encoding)(errors=\"replace\")\n        try:\n            while True:\n                chunk = os.read(read_fd, 4096)\n                if not chunk:\n                    break\n                buffer += decoder.decode(chunk)\n                while \"\\n\" in buffer:\n                    line, buffer = buffer.split(\"\\n\", 1)\n                    self._log_line(line)\n        except Exception:\n            logger.exception(\"Failed to read redirected stderr\")\n        finally:\n            buffer += decoder.decode(b\"\", final=True)\n            if buffer:\n                self._log_line(buffer)\n            with contextlib.suppress(OSError):\n                os.close(read_fd)\n\n    def _log_line(self, line: str) -> None:\n        text = line.rstrip(\"\\r\")\n        if not text:\n            return\n        logger.opt(depth=2).log(self._level, text)\n\n    def open_original_stderr_handle(self) -> IO[bytes] | None:\n        if self._original_fd is None:\n            return None\n        dup_fd = os.dup(self._original_fd)\n        os.set_inheritable(dup_fd, True)\n        return os.fdopen(dup_fd, \"wb\", closefd=True)\n\n\n_stderr_redirector: StderrRedirector | None = None\n\n\ndef redirect_stderr_to_logger(level: str = \"ERROR\") -> None:\n    global _stderr_redirector\n    if _stderr_redirector is None:\n        _stderr_redirector = StderrRedirector(level=level)\n    _stderr_redirector.install()\n\n\ndef restore_stderr() -> None:\n    if _stderr_redirector is not None:\n        _stderr_redirector.uninstall()\n\n\n@contextlib.contextmanager\ndef open_original_stderr() -> Iterator[IO[bytes] | None]:\n    redirector = _stderr_redirector\n    if redirector is None:\n        yield None\n        return\n    stream = redirector.open_original_stderr_handle()\n    try:\n        yield stream\n    finally:\n        if stream is not None:\n            stream.close()\n"
  },
  {
    "path": "src/kimi_cli/utils/media_tags.py",
    "content": "from __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom html import escape\n\nfrom kimi_cli.wire.types import ContentPart, TextPart\n\n\ndef _format_tag(tag: str, attrs: Mapping[str, str | None] | None = None) -> str:\n    if not attrs:\n        return f\"<{tag}>\"\n    rendered: list[str] = []\n    for key, value in sorted(attrs.items()):\n        if not value:\n            continue\n        rendered.append(f'{key}=\"{escape(str(value), quote=True)}\"')\n    if not rendered:\n        return f\"<{tag}>\"\n    return f\"<{tag} \" + \" \".join(rendered) + \">\"\n\n\ndef wrap_media_part(\n    part: ContentPart, *, tag: str, attrs: Mapping[str, str | None] | None = None\n) -> list[ContentPart]:\n    return [\n        TextPart(text=_format_tag(tag, attrs)),\n        part,\n        TextPart(text=f\"</{tag}>\"),\n    ]\n"
  },
  {
    "path": "src/kimi_cli/utils/message.py",
    "content": "from __future__ import annotations\n\nfrom kosong.message import Message\n\nfrom kimi_cli.wire.types import AudioURLPart, ImageURLPart, TextPart, VideoURLPart\n\n\ndef message_stringify(message: Message) -> str:\n    \"\"\"Get a string representation of a message.\"\"\"\n    # TODO: this should be merged into `kosong.message.Message.extract_text`\n    parts: list[str] = []\n    for part in message.content:\n        if isinstance(part, TextPart):\n            parts.append(part.text)\n        elif isinstance(part, ImageURLPart):\n            parts.append(\"[image]\")\n        elif isinstance(part, AudioURLPart):\n            suffix = f\":{part.audio_url.id}\" if part.audio_url.id else \"\"\n            parts.append(f\"[audio{suffix}]\")\n        elif isinstance(part, VideoURLPart):\n            parts.append(\"[video]\")\n        else:\n            parts.append(f\"[{part.type}]\")\n    return \"\".join(parts)\n"
  },
  {
    "path": "src/kimi_cli/utils/path.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nimport re\nfrom collections.abc import Sequence\nfrom pathlib import Path, PurePath\nfrom stat import S_ISDIR\n\nimport aiofiles.os\nfrom kaos.path import KaosPath\n\n_ROTATION_OPEN_FLAGS = os.O_CREAT | os.O_EXCL | os.O_WRONLY\n_ROTATION_FILE_MODE = 0o600\n\n\nasync def _reserve_rotation_path(path: Path) -> bool:\n    \"\"\"Atomically create an empty file as a reservation for *path*.\"\"\"\n\n    def _create() -> None:\n        fd = os.open(str(path), _ROTATION_OPEN_FLAGS, _ROTATION_FILE_MODE)\n        os.close(fd)\n\n    try:\n        await asyncio.to_thread(_create)\n    except FileExistsError:\n        return False\n    return True\n\n\nasync def next_available_rotation(path: Path) -> Path | None:\n    \"\"\"Return a reserved rotation path for *path* or ``None`` if parent is missing.\n\n    The caller must overwrite/reuse the returned path immediately because this helper\n    commits an empty placeholder file to guarantee uniqueness. It is therefore suited\n    for rotating *files* (like history logs) but **not** directory creation.\n    \"\"\"\n\n    if not path.parent.exists():\n        return None\n\n    base_name = path.stem\n    suffix = path.suffix\n    pattern = re.compile(rf\"^{re.escape(base_name)}_(\\d+){re.escape(suffix)}$\")\n    max_num = 0\n    for entry in await aiofiles.os.listdir(path.parent):\n        if match := pattern.match(entry):\n            max_num = max(max_num, int(match.group(1)))\n\n    next_num = max_num + 1\n    while True:\n        next_path = path.parent / f\"{base_name}_{next_num}{suffix}\"\n        if await _reserve_rotation_path(next_path):\n            return next_path\n        next_num += 1\n\n\nasync def list_directory(work_dir: KaosPath) -> str:\n    \"\"\"Return an ``ls``-like listing of *work_dir*.\n\n    This helper is used mainly to provide context to the LLM (for example\n    ``KIMI_WORK_DIR_LS``) and to show top-level directory contents in tools.\n    It should therefore be robust against per-entry filesystem issues such as\n    broken symlinks or permission errors: a single bad entry must not crash\n    the whole CLI.\n    \"\"\"\n\n    entries: list[str] = []\n    # Iterate entries; tolerate per-entry stat failures (broken symlinks, permissions, etc.).\n    async for entry in work_dir.iterdir():\n        try:\n            st = await entry.stat()\n        except OSError:\n            # Broken symlink, permission error, etc. – keep listing other entries.\n            entries.append(f\"?--------- {'?':>10} {entry.name} [stat failed]\")\n            continue\n        mode = \"d\" if S_ISDIR(st.st_mode) else \"-\"\n        mode += \"r\" if st.st_mode & 0o400 else \"-\"\n        mode += \"w\" if st.st_mode & 0o200 else \"-\"\n        mode += \"x\" if st.st_mode & 0o100 else \"-\"\n        mode += \"r\" if st.st_mode & 0o040 else \"-\"\n        mode += \"w\" if st.st_mode & 0o020 else \"-\"\n        mode += \"x\" if st.st_mode & 0o010 else \"-\"\n        mode += \"r\" if st.st_mode & 0o004 else \"-\"\n        mode += \"w\" if st.st_mode & 0o002 else \"-\"\n        mode += \"x\" if st.st_mode & 0o001 else \"-\"\n        entries.append(f\"{mode} {st.st_size:>10} {entry.name}\")\n    return \"\\n\".join(entries)\n\n\ndef shorten_home(path: KaosPath) -> KaosPath:\n    \"\"\"\n    Convert absolute path to use `~` for home directory.\n    \"\"\"\n    try:\n        home = KaosPath.home()\n        p = path.relative_to(home)\n        return KaosPath(\"~\") / p\n    except Exception:\n        return path\n\n\ndef sanitize_cli_path(raw: str) -> str:\n    \"\"\"Strip surrounding quotes from a CLI path argument.\n\n    On macOS, dragging a file into the terminal wraps the path in single\n    quotes (e.g. ``'/path/to/file'``).  This helper strips matching outer\n    quotes (single or double) so downstream path handling works correctly.\n    \"\"\"\n    raw = raw.strip()\n    if len(raw) >= 2 and ((raw[0] == \"'\" and raw[-1] == \"'\") or (raw[0] == '\"' and raw[-1] == '\"')):\n        raw = raw[1:-1]\n    return raw\n\n\ndef is_within_directory(path: KaosPath, directory: KaosPath) -> bool:\n    \"\"\"\n    Check whether *path* is contained within *directory* using pure path semantics.\n    Both arguments should already be canonicalized (e.g. via KaosPath.canonical()).\n    \"\"\"\n    candidate = PurePath(str(path))\n    base = PurePath(str(directory))\n    try:\n        candidate.relative_to(base)\n        return True\n    except ValueError:\n        return False\n\n\ndef is_within_workspace(\n    path: KaosPath,\n    work_dir: KaosPath,\n    additional_dirs: Sequence[KaosPath] = (),\n) -> bool:\n    \"\"\"\n    Check whether *path* is within the workspace (work_dir or any additional directory).\n    \"\"\"\n    if is_within_directory(path, work_dir):\n        return True\n    return any(is_within_directory(path, d) for d in additional_dirs)\n"
  },
  {
    "path": "src/kimi_cli/utils/proctitle.py",
    "content": "from __future__ import annotations\n\nimport sys\n\n\ndef set_process_title(title: str) -> None:\n    \"\"\"Set the OS-level process title visible in ps/top/terminal panels.\"\"\"\n    try:\n        import setproctitle\n\n        setproctitle.setproctitle(title)\n    except ImportError:\n        pass\n\n\ndef set_terminal_title(title: str) -> None:\n    \"\"\"Set the terminal tab/window title via ANSI OSC escape sequence.\n\n    Only writes when stderr is a TTY to avoid polluting piped output.\n    \"\"\"\n    if not sys.stderr.isatty():\n        return\n    try:\n        sys.stderr.write(f\"\\033]0;{title}\\007\")\n        sys.stderr.flush()\n    except OSError:\n        pass\n\n\ndef init_process_name(name: str = \"Kimi Code\") -> None:\n    \"\"\"Initialize process name: OS process title + terminal tab title.\"\"\"\n    set_process_title(name)\n    set_terminal_title(name)\n"
  },
  {
    "path": "src/kimi_cli/utils/pyinstaller.py",
    "content": "from __future__ import annotations\n\nfrom PyInstaller.utils.hooks import collect_data_files, collect_submodules\n\nhiddenimports = collect_submodules(\"kimi_cli.tools\") + [\"setproctitle\"]\ndatas = (\n    collect_data_files(\n        \"kimi_cli\",\n        includes=[\n            \"agents/**/*.yaml\",\n            \"agents/**/*.md\",\n            \"deps/bin/**\",\n            \"prompts/**/*.md\",\n            \"skills/**\",\n            \"tools/**/*.md\",\n            \"web/static/**\",\n            \"vis/static/**\",\n            \"CHANGELOG.md\",\n        ],\n        excludes=[\n            \"tools/*.md\",\n        ],\n    )\n    + collect_data_files(\n        \"dateparser\",\n        includes=[\"**/*.pkl\"],\n    )\n    + collect_data_files(\n        \"fastmcp\",\n        includes=[\"../fastmcp-*.dist-info/*\"],\n    )\n)\n"
  },
  {
    "path": "src/kimi_cli/utils/rich/__init__.py",
    "content": "\"\"\"Project-wide Rich configuration helpers.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom typing import Final\n\nfrom rich import _wrap\n\n# Regex used by Rich to compute break opportunities during wrapping.\n_DEFAULT_WRAP_PATTERN: Final[re.Pattern[str]] = re.compile(r\"\\s*\\S+\\s*\")\n_CHAR_WRAP_PATTERN: Final[re.Pattern[str]] = re.compile(r\".\", re.DOTALL)\n\n\ndef enable_character_wrap() -> None:\n    \"\"\"Switch Rich's wrapping logic to break on every character.\n\n    Rich's default behavior tries to preserve whole words; we override the\n    internal regex so markdown rendering can fold text at any column once it\n    exceeds the terminal width.\n    \"\"\"\n\n    _wrap.re_word = _CHAR_WRAP_PATTERN\n\n\ndef restore_word_wrap() -> None:\n    \"\"\"Restore Rich's default word-based wrapping.\"\"\"\n\n    _wrap.re_word = _DEFAULT_WRAP_PATTERN\n\n\n# Apply character-based wrapping globally for the CLI.\nenable_character_wrap()\n"
  },
  {
    "path": "src/kimi_cli/utils/rich/columns.py",
    "content": "from __future__ import annotations\n\nfrom rich.columns import Columns\nfrom rich.console import Console, ConsoleOptions, RenderableType, RenderResult\nfrom rich.measure import Measurement\nfrom rich.segment import Segment\nfrom rich.text import Text\n\n\nclass _ShrinkToWidth:\n    def __init__(self, renderable: RenderableType, max_width: int) -> None:\n        self._renderable = renderable\n        self._max_width = max(max_width, 1)\n\n    def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:\n        width = self._resolve_width(options)\n        return Measurement(0, width)\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        width = self._resolve_width(options)\n        child_options = options.update(width=width)\n        yield from console.render(self._renderable, child_options)\n\n    def _resolve_width(self, options: ConsoleOptions) -> int:\n        return max(1, min(self._max_width, options.max_width))\n\n\ndef _strip_trailing_spaces(segments: list[Segment]) -> list[Segment]:\n    lines = list(Segment.split_lines(segments))\n    trimmed: list[Segment] = []\n    n_lines = len(lines)\n    for index, line in enumerate(lines):\n        line_segments = list(line)\n        while line_segments:\n            segment = line_segments[-1]\n            if segment.control is not None:\n                break\n            trimmed_text = segment.text.rstrip(\" \")\n            if trimmed_text != segment.text:\n                if trimmed_text:\n                    line_segments[-1] = Segment(trimmed_text, segment.style, segment.control)\n                    break\n                line_segments.pop()\n                continue\n            break\n        trimmed.extend(line_segments)\n        if index != n_lines - 1:\n            trimmed.append(Segment.line())\n    if trimmed:\n        trimmed.append(Segment.line())\n    return trimmed\n\n\nclass BulletColumns:\n    def __init__(\n        self,\n        renderable: RenderableType,\n        *,\n        bullet_style: str | None = None,\n        bullet: RenderableType | None = None,\n        padding: int = 1,\n    ) -> None:\n        self._renderable = renderable\n        self._bullet = bullet\n        self._bullet_style = bullet_style\n        self._padding = padding\n\n    def _bullet_renderable(self) -> RenderableType:\n        if self._bullet is not None:\n            return self._bullet\n        return Text(\"•\", style=self._bullet_style or \"\")\n\n    def _available_width(self, console: Console, options: ConsoleOptions, bullet_width: int) -> int:\n        max_width = options.max_width or console.width or (bullet_width + self._padding + 1)\n        available = max_width - bullet_width - self._padding\n        return max(available, 1)\n\n    def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:\n        bullet = self._bullet_renderable()\n        bullet_measure = Measurement.get(console, options, bullet)\n        bullet_width = max(bullet_measure.maximum, 1)\n        available = self._available_width(console, options, bullet_width)\n        constrained = _ShrinkToWidth(self._renderable, available)\n        columns = Columns([bullet, constrained], expand=False, padding=(0, self._padding))\n        return Measurement.get(console, options, columns)\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        bullet = self._bullet_renderable()\n        bullet_measure = Measurement.get(console, options, bullet)\n        bullet_width = max(bullet_measure.maximum, 1)\n        available = self._available_width(console, options, bullet_width)\n        columns = Columns(\n            [bullet, _ShrinkToWidth(self._renderable, available)],\n            expand=False,\n            padding=(0, self._padding),\n        )\n        segments = list(console.render(columns, options))\n        trimmed = _strip_trailing_spaces(segments)\n        yield from trimmed\n"
  },
  {
    "path": "src/kimi_cli/utils/rich/markdown.py",
    "content": "# This file is modified from https://github.com/Textualize/rich/blob/4d6d631a3d2deddf8405522d4b8c976a6d35726c/rich/markdown.py\n# pyright: standard\n\nfrom __future__ import annotations\n\nimport sys\nfrom collections.abc import Iterable, Mapping\nfrom typing import ClassVar, get_args\n\nfrom markdown_it import MarkdownIt\nfrom markdown_it.token import Token\nfrom rich import box\nfrom rich._loop import loop_first\nfrom rich._stack import Stack\nfrom rich.console import Console, ConsoleOptions, JustifyMethod, RenderResult\nfrom rich.containers import Renderables\nfrom rich.jupyter import JupyterMixin\nfrom rich.rule import Rule\nfrom rich.segment import Segment\nfrom rich.style import Style, StyleStack\nfrom rich.syntax import Syntax, SyntaxTheme\nfrom rich.table import Table\nfrom rich.text import Text, TextType\n\nfrom kimi_cli.utils.rich.syntax import KIMI_ANSI_THEME_NAME, resolve_code_theme\n\nLIST_INDENT_WIDTH = 2\n\n_FALLBACK_STYLES: Mapping[str, Style] = {\n    \"markdown.paragraph\": Style(),\n    \"markdown.h1\": Style(color=\"bright_white\", bold=True),\n    \"markdown.h1.underline\": Style(color=\"bright_white\", bold=True),\n    \"markdown.h2\": Style(color=\"white\", bold=True, underline=True),\n    \"markdown.h3\": Style(bold=True),\n    \"markdown.h4\": Style(bold=True),\n    \"markdown.h5\": Style(bold=True),\n    \"markdown.h6\": Style(dim=True, italic=True),\n    \"markdown.code\": Style(color=\"bright_cyan\", bold=True),\n    \"markdown.code_block\": Style(color=\"bright_cyan\"),\n    \"markdown.item\": Style(),\n    \"markdown.item.bullet\": Style(),\n    \"markdown.item.number\": Style(),\n    \"markdown.em\": Style(italic=True),\n    \"markdown.strong\": Style(bold=True),\n    \"markdown.s\": Style(strike=True),\n    \"markdown.link\": Style(color=\"bright_blue\", underline=True),\n    \"markdown.link_url\": Style(color=\"cyan\", underline=True),\n    \"markdown.block_quote\": Style(),\n    \"markdown.hr\": Style(color=\"grey58\"),\n}\n\n\ndef _strip_background(text: Text) -> Text:\n    \"\"\"Return a copy of ``text`` with all background colors removed.\"\"\"\n\n    clean = Text(\n        text.plain,\n        justify=text.justify,\n        overflow=text.overflow,\n        no_wrap=text.no_wrap,\n        end=text.end,\n        tab_size=text.tab_size,\n    )\n\n    if text.style:\n        base_style = text.style\n        if not isinstance(base_style, Style):\n            base_style = Style.parse(str(base_style))\n        base_style = base_style.copy()\n        if base_style._bgcolor is not None:\n            base_style._bgcolor = None\n        clean.stylize(base_style, 0, len(clean))\n\n    for span in text.spans:\n        style = span.style\n        if style is None:\n            continue\n        new_style = Style.parse(str(style)) if not isinstance(style, Style) else style.copy()\n        if new_style._bgcolor is not None:\n            new_style._bgcolor = None\n        clean.stylize(new_style, span.start, span.end)\n\n    return clean\n\n\nclass MarkdownElement:\n    new_line: ClassVar[bool] = True\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:\n        \"\"\"Factory to create markdown element,\n\n        Args:\n            markdown (Markdown): The parent Markdown object.\n            token (Token): A node from markdown-it.\n\n        Returns:\n            MarkdownElement: A new markdown element\n        \"\"\"\n        return cls()\n\n    def on_enter(self, context: MarkdownContext) -> None:\n        \"\"\"Called when the node is entered.\n\n        Args:\n            context (MarkdownContext): The markdown context.\n        \"\"\"\n\n    def on_text(self, context: MarkdownContext, text: TextType) -> None:\n        \"\"\"Called when text is parsed.\n\n        Args:\n            context (MarkdownContext): The markdown context.\n        \"\"\"\n\n    def on_leave(self, context: MarkdownContext) -> None:\n        \"\"\"Called when the parser leaves the element.\n\n        Args:\n            context (MarkdownContext): [description]\n        \"\"\"\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        \"\"\"Called when a child element is closed.\n\n        This method allows a parent element to take over rendering of its children.\n\n        Args:\n            context (MarkdownContext): The markdown context.\n            child (MarkdownElement): The child markdown element.\n\n        Returns:\n            bool: Return True to render the element, or False to not render the element.\n        \"\"\"\n        return True\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        return ()\n\n\nclass UnknownElement(MarkdownElement):\n    \"\"\"An unknown element.\n\n    Hopefully there will be no unknown elements, and we will have a MarkdownElement for\n    everything in the document.\n\n    \"\"\"\n\n\nclass TextElement(MarkdownElement):\n    \"\"\"Base class for elements that render text.\"\"\"\n\n    style_name = \"none\"\n\n    def on_enter(self, context: MarkdownContext) -> None:\n        self.style = context.enter_style(self.style_name)\n        self.text = Text(justify=\"left\")\n\n    def on_text(self, context: MarkdownContext, text: TextType) -> None:\n        self.text.append(text, context.current_style if isinstance(text, str) else None)\n\n    def on_leave(self, context: MarkdownContext) -> None:\n        context.leave_style()\n\n\nclass Paragraph(TextElement):\n    \"\"\"A Paragraph.\"\"\"\n\n    style_name = \"markdown.paragraph\"\n    justify: JustifyMethod\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> Paragraph:\n        return cls(justify=markdown.justify or \"left\")\n\n    def __init__(self, justify: JustifyMethod) -> None:\n        self.justify = justify\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        self.text.justify = self.justify\n        yield self.text\n\n\nclass Heading(TextElement):\n    \"\"\"A heading.\"\"\"\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> Heading:\n        return cls(token.tag)\n\n    def on_enter(self, context: MarkdownContext) -> None:\n        self.text = Text()\n        context.enter_style(self.style_name)\n\n    def __init__(self, tag: str) -> None:\n        self.tag = tag\n        self.style_name = f\"markdown.{tag}\"\n        super().__init__()\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        text = self.text\n        text.justify = \"left\"\n        width = max(1, text.cell_len)\n\n        if self.tag == \"h1\":\n            underline = Text(\"═\" * width)\n            underline.stylize(\"markdown.h1.underline\")\n            yield text\n            yield underline\n        else:\n            yield text\n\n\nclass CodeBlock(TextElement):\n    \"\"\"A code block with syntax highlighting.\"\"\"\n\n    style_name = \"markdown.code_block\"\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> CodeBlock:\n        node_info = token.info or \"\"\n        lexer_name = node_info.partition(\" \")[0]\n        return cls(lexer_name or \"text\", markdown.code_theme)\n\n    def __init__(self, lexer_name: str, theme: str | SyntaxTheme) -> None:\n        self.lexer_name = lexer_name\n        self.theme = theme\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        code = str(self.text).rstrip()\n        syntax = Syntax(\n            code,\n            self.lexer_name,\n            theme=self.theme,\n            word_wrap=True,\n            background_color=None,\n            padding=0,\n        )\n        highlighted = syntax.highlight(code)\n        highlighted.rstrip()\n        stripped = _strip_background(highlighted)\n        stripped.rstrip()\n        yield stripped\n\n\nclass BlockQuote(TextElement):\n    \"\"\"A block quote.\"\"\"\n\n    style_name = \"markdown.block_quote\"\n\n    def __init__(self) -> None:\n        self.elements: Renderables = Renderables()\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        self.elements.append(child)\n        return False\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        render_options = options.update(width=options.max_width - 4)\n        style = self.style.without_color\n        lines = console.render_lines(self.elements, render_options, style=style)\n        new_line = Segment(\"\\n\")\n        padding = Segment(\"▌ \", style)\n        for line in lines:\n            yield padding\n            yield from line\n            yield new_line\n\n\nclass HorizontalRule(MarkdownElement):\n    \"\"\"A horizontal rule to divide sections.\"\"\"\n\n    new_line = False\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        style = _FALLBACK_STYLES[\"markdown.hr\"].copy()\n        yield Rule(style=style)\n\n\nclass TableElement(MarkdownElement):\n    \"\"\"MarkdownElement corresponding to `table_open`.\"\"\"\n\n    def __init__(self) -> None:\n        self.header: TableHeaderElement | None = None\n        self.body: TableBodyElement | None = None\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        if isinstance(child, TableHeaderElement):\n            self.header = child\n        elif isinstance(child, TableBodyElement):\n            self.body = child\n        else:\n            raise RuntimeError(\"Couldn't process markdown table.\")\n        return False\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        table = Table(box=box.SIMPLE_HEAVY, show_edge=False)\n\n        if self.header is not None and self.header.row is not None:\n            for column in self.header.row.cells:\n                table.add_column(column.content)\n\n        if self.body is not None:\n            for row in self.body.rows:\n                row_content = [element.content for element in row.cells]\n                table.add_row(*row_content)\n\n        yield table\n\n\nclass TableHeaderElement(MarkdownElement):\n    \"\"\"MarkdownElement corresponding to `thead_open` and `thead_close`.\"\"\"\n\n    def __init__(self) -> None:\n        self.row: TableRowElement | None = None\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        assert isinstance(child, TableRowElement)\n        self.row = child\n        return False\n\n\nclass TableBodyElement(MarkdownElement):\n    \"\"\"MarkdownElement corresponding to `tbody_open` and `tbody_close`.\"\"\"\n\n    def __init__(self) -> None:\n        self.rows: list[TableRowElement] = []\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        assert isinstance(child, TableRowElement)\n        self.rows.append(child)\n        return False\n\n\nclass TableRowElement(MarkdownElement):\n    \"\"\"MarkdownElement corresponding to `tr_open` and `tr_close`.\"\"\"\n\n    def __init__(self) -> None:\n        self.cells: list[TableDataElement] = []\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        assert isinstance(child, TableDataElement)\n        self.cells.append(child)\n        return False\n\n\nclass TableDataElement(MarkdownElement):\n    \"\"\"MarkdownElement corresponding to `td_open` and `td_close`\n    and `th_open` and `th_close`.\"\"\"\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:\n        style = str(token.attrs.get(\"style\")) or \"\"\n\n        justify: JustifyMethod\n        if \"text-align:right\" in style:\n            justify = \"right\"\n        elif \"text-align:center\" in style:\n            justify = \"center\"\n        elif \"text-align:left\" in style:\n            justify = \"left\"\n        else:\n            justify = \"default\"\n\n        assert justify in get_args(JustifyMethod)\n        return cls(justify=justify)\n\n    def __init__(self, justify: JustifyMethod) -> None:\n        self.content: Text = Text(\"\", justify=justify)\n        self.justify = justify\n\n    def on_text(self, context: MarkdownContext, text: TextType) -> None:\n        text = Text(text) if isinstance(text, str) else text\n        text.stylize(context.current_style)\n        self.content.append_text(text)\n\n\nclass ListElement(MarkdownElement):\n    \"\"\"A list element.\"\"\"\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> ListElement:\n        return cls(token.type, int(token.attrs.get(\"start\", 1)))\n\n    def __init__(self, list_type: str, list_start: int | None) -> None:\n        self.items: list[ListItem] = []\n        self.list_type = list_type\n        self.list_start = list_start\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        assert isinstance(child, ListItem)\n        self.items.append(child)\n        return False\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        if self.list_type == \"bullet_list_open\":\n            for item in self.items:\n                yield from item.render_bullet(console, options)\n        else:\n            number = 1 if self.list_start is None else self.list_start\n            last_number = number + len(self.items)\n            for index, item in enumerate(self.items):\n                yield from item.render_number(console, options, number + index, last_number)\n\n\nclass ListItem(TextElement):\n    \"\"\"An item in a list.\"\"\"\n\n    style_name = \"markdown.item\"\n\n    @staticmethod\n    def _line_starts_with_list_marker(text: str) -> bool:\n        stripped = text.lstrip()\n        if not stripped:\n            return False\n        if stripped.startswith((\"• \", \"- \", \"* \")):\n            return True\n        index = 0\n        while index < len(stripped) and stripped[index].isdigit():\n            index += 1\n        if index == 0 or index >= len(stripped):\n            return False\n        marker = stripped[index]\n        has_space = index + 1 < len(stripped) and stripped[index + 1] == \" \"\n        return marker in {\".\", \")\"} and has_space\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:\n        # `list_item_open` levels grow by 2 for each nested list depth.\n        depth = max(0, (token.level - 1) // 2)\n        return cls(indent=depth)\n\n    def __init__(self, indent: int = 0) -> None:\n        self.indent = indent\n        self.elements: Renderables = Renderables()\n\n    def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:\n        self.elements.append(child)\n        return False\n\n    def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        lines = console.render_lines(self.elements, options, style=self.style)\n        indent_padding_len = LIST_INDENT_WIDTH * self.indent\n        indent_text = \" \" * indent_padding_len\n        bullet = Segment(\"• \")\n        new_line = Segment(\"\\n\")\n        bullet_width = len(bullet.text)\n        for first, line in loop_first(lines):\n            if first:\n                if indent_text:\n                    yield Segment(indent_text)\n                yield bullet\n            else:\n                plain = \"\".join(segment.text for segment in line)\n                if self._line_starts_with_list_marker(plain):\n                    prefix = \"\"\n                else:\n                    existing = len(plain) - len(plain.lstrip(\" \"))\n                    target = indent_padding_len + bullet_width\n                    missing = max(0, target - existing)\n                    prefix = \" \" * missing\n                if prefix:\n                    yield Segment(prefix)\n            yield from line\n            yield new_line\n\n    def render_number(\n        self, console: Console, options: ConsoleOptions, number: int, last_number: int\n    ) -> RenderResult:\n        lines = console.render_lines(self.elements, options, style=self.style)\n        new_line = Segment(\"\\n\")\n        indent_padding_len = LIST_INDENT_WIDTH * self.indent\n        indent_text = \" \" * indent_padding_len\n        numeral_text = f\"{number}. \"\n        numeral = Segment(numeral_text)\n        numeral_width = len(numeral_text)\n        for first, line in loop_first(lines):\n            if first:\n                if indent_text:\n                    yield Segment(indent_text)\n                yield numeral\n            else:\n                plain = \"\".join(segment.text for segment in line)\n                if self._line_starts_with_list_marker(plain):\n                    prefix = \"\"\n                else:\n                    existing = len(plain) - len(plain.lstrip(\" \"))\n                    target = indent_padding_len + numeral_width\n                    missing = max(0, target - existing)\n                    prefix = \" \" * missing\n                if prefix:\n                    yield Segment(prefix)\n            yield from line\n            yield new_line\n\n\nclass Link(TextElement):\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:\n        url = token.attrs.get(\"href\", \"#\")\n        return cls(token.content, str(url))\n\n    def __init__(self, text: str, href: str):\n        self.text = Text(text)\n        self.href = href\n\n\nclass ImageItem(TextElement):\n    \"\"\"Renders a placeholder for an image.\"\"\"\n\n    new_line = False\n\n    @classmethod\n    def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:\n        \"\"\"Factory to create markdown element,\n\n        Args:\n            markdown (Markdown): The parent Markdown object.\n            token (Any): A token from markdown-it.\n\n        Returns:\n            MarkdownElement: A new markdown element\n        \"\"\"\n        return cls(str(token.attrs.get(\"src\", \"\")), markdown.hyperlinks)\n\n    def __init__(self, destination: str, hyperlinks: bool) -> None:\n        self.destination = destination\n        self.hyperlinks = hyperlinks\n        self.link: str | None = None\n        super().__init__()\n\n    def on_enter(self, context: MarkdownContext) -> None:\n        self.link = context.current_style.link\n        self.text = Text(justify=\"left\")\n        super().on_enter(context)\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        link_style = Style(link=self.link or self.destination or None)\n        title = self.text or Text(self.destination.strip(\"/\").rsplit(\"/\", 1)[-1])\n        if self.hyperlinks:\n            title.stylize(link_style)\n        text = Text.assemble(\"🌆 \", title, \" \", end=\"\")\n        yield text\n\n\nclass MarkdownContext:\n    \"\"\"Manages the console render state.\"\"\"\n\n    def __init__(\n        self,\n        console: Console,\n        options: ConsoleOptions,\n        style: Style,\n        fallback_styles: Mapping[str, Style],\n        inline_code_lexer: str | None = None,\n        inline_code_theme: str | SyntaxTheme = KIMI_ANSI_THEME_NAME,\n    ) -> None:\n        self.console = console\n        self.options = options\n        self.style_stack: StyleStack = StyleStack(style)\n        self.stack: Stack[MarkdownElement] = Stack()\n        self._fallback_styles = fallback_styles\n\n        self._syntax: Syntax | None = None\n        if inline_code_lexer is not None:\n            self._syntax = Syntax(\"\", inline_code_lexer, theme=inline_code_theme)\n\n    @property\n    def current_style(self) -> Style:\n        \"\"\"Current style which is the product of all styles on the stack.\"\"\"\n        return self.style_stack.current\n\n    def on_text(self, text: str, node_type: str) -> None:\n        \"\"\"Called when the parser visits text.\"\"\"\n        if node_type in {\"fence\", \"code_inline\"} and self._syntax is not None:\n            highlighted = self._syntax.highlight(text)\n            highlighted.rstrip()\n            stripped = _strip_background(highlighted)\n            combined = Text.assemble(stripped, style=self.style_stack.current)\n            self.stack.top.on_text(self, combined)\n        else:\n            self.stack.top.on_text(self, text)\n\n    def enter_style(self, style_name: str | Style) -> Style:\n        \"\"\"Enter a style context.\"\"\"\n        if isinstance(style_name, Style):\n            style = style_name\n        else:\n            fallback = self._fallback_styles.get(style_name, Style())\n            style = self.console.get_style(style_name, default=fallback)\n            style = fallback + style\n        style = style.copy()\n        if isinstance(style_name, str) and style_name == \"markdown.block_quote\":\n            style = style.without_color\n        if (\n            isinstance(style_name, str)\n            and style_name in {\"markdown.code\", \"markdown.code_block\"}\n            and style._bgcolor is not None\n        ):\n            style._bgcolor = None\n        self.style_stack.push(style)\n        return self.current_style\n\n    def leave_style(self) -> Style:\n        \"\"\"Leave a style context.\"\"\"\n        style = self.style_stack.pop()\n        return style\n\n\nclass Markdown(JupyterMixin):\n    \"\"\"A Markdown renderable.\n\n    Args:\n        markup (str): A string containing markdown.\n        code_theme (str, optional): Pygments theme for code blocks. Defaults to \"kimi-ansi\".\n            See https://pygments.org/styles/ for code themes.\n        justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None.\n        style (Union[str, Style], optional): Optional style to apply to markdown.\n        hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``.\n        inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is\n            enabled. Defaults to None.\n        inline_code_theme: (Optional[str], optional): Pygments theme for inline code\n            highlighting, or None for no highlighting. Defaults to None.\n    \"\"\"\n\n    elements: ClassVar[dict[str, type[MarkdownElement]]] = {\n        \"paragraph_open\": Paragraph,\n        \"heading_open\": Heading,\n        \"fence\": CodeBlock,\n        \"code_block\": CodeBlock,\n        \"blockquote_open\": BlockQuote,\n        \"hr\": HorizontalRule,\n        \"bullet_list_open\": ListElement,\n        \"ordered_list_open\": ListElement,\n        \"list_item_open\": ListItem,\n        \"image\": ImageItem,\n        \"table_open\": TableElement,\n        \"tbody_open\": TableBodyElement,\n        \"thead_open\": TableHeaderElement,\n        \"tr_open\": TableRowElement,\n        \"td_open\": TableDataElement,\n        \"th_open\": TableDataElement,\n    }\n\n    inlines = {\"em\", \"strong\", \"code\", \"s\"}\n\n    def __init__(\n        self,\n        markup: str,\n        code_theme: str = KIMI_ANSI_THEME_NAME,\n        justify: JustifyMethod | None = None,\n        style: str | Style = \"none\",\n        hyperlinks: bool = True,\n        inline_code_lexer: str | None = None,\n        inline_code_theme: str | None = None,\n    ) -> None:\n        parser = MarkdownIt().enable(\"strikethrough\").enable(\"table\")\n        self.markup = markup\n        self.parsed = parser.parse(markup)\n        self.code_theme = resolve_code_theme(code_theme)\n        self.justify: JustifyMethod | None = justify\n        self.style = style\n        self.hyperlinks = hyperlinks\n        self.inline_code_lexer = inline_code_lexer\n        self.inline_code_theme = resolve_code_theme(inline_code_theme or code_theme)\n\n    def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]:\n        \"\"\"Flattens the token stream.\"\"\"\n        for token in tokens:\n            is_fence = token.type == \"fence\"\n            is_image = token.tag == \"img\"\n            if token.children and not (is_image or is_fence):\n                yield from self._flatten_tokens(token.children)\n            else:\n                yield token\n\n    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:\n        \"\"\"Render markdown to the console.\"\"\"\n        style = console.get_style(self.style, default=\"none\")\n        options = options.update(height=None)\n        context = MarkdownContext(\n            console,\n            options,\n            style,\n            _FALLBACK_STYLES,\n            inline_code_lexer=self.inline_code_lexer,\n            inline_code_theme=self.inline_code_theme,\n        )\n        tokens = self.parsed\n        inline_style_tags = self.inlines\n        new_line = False\n        _new_line_segment = Segment.line()\n        render_started = False\n\n        for token in self._flatten_tokens(tokens):\n            node_type = token.type\n            tag = token.tag\n\n            entering = token.nesting == 1\n            exiting = token.nesting == -1\n            self_closing = token.nesting == 0\n\n            if node_type in {\"text\", \"html_inline\", \"html_block\"}:\n                # Render HTML tokens as plain text so safeword markup stays visible.\n                if context.stack:\n                    context.on_text(token.content, node_type)\n                else:\n                    # Orphan text/html blocks can appear outside any element (e.g. <analysis>).\n                    paragraph = Paragraph(justify=self.justify or \"left\")\n                    paragraph.on_enter(context)\n                    paragraph.on_text(context, token.content)\n                    paragraph.on_leave(context)\n                    if new_line and render_started:\n                        yield _new_line_segment\n                    rendered = console.render(paragraph, context.options)\n                    for segment in rendered:\n                        render_started = True\n                        yield segment\n                    new_line = paragraph.new_line\n            elif node_type == \"hardbreak\":\n                context.on_text(\"\\n\", node_type)\n            elif node_type == \"softbreak\":\n                context.on_text(\" \", node_type)\n            elif node_type == \"link_open\":\n                href = str(token.attrs.get(\"href\", \"\"))\n                if self.hyperlinks:\n                    link_style = console.get_style(\"markdown.link_url\", default=\"none\")\n                    link_style += Style(link=href)\n                    context.enter_style(link_style)\n                else:\n                    context.stack.push(Link.create(self, token))\n            elif node_type == \"link_close\":\n                if self.hyperlinks:\n                    context.leave_style()\n                else:\n                    element = context.stack.pop()\n                    assert isinstance(element, Link)\n                    link_style = console.get_style(\"markdown.link\", default=\"none\")\n                    context.enter_style(link_style)\n                    context.on_text(element.text.plain, node_type)\n                    context.leave_style()\n                    context.on_text(\" (\", node_type)\n                    link_url_style = console.get_style(\"markdown.link_url\", default=\"none\")\n                    context.enter_style(link_url_style)\n                    context.on_text(element.href, node_type)\n                    context.leave_style()\n                    context.on_text(\")\", node_type)\n            elif tag in inline_style_tags and node_type != \"fence\" and node_type != \"code_block\":\n                if entering:\n                    # If it's an opening inline token e.g. strong, em, etc.\n                    # Then we move into a style context i.e. push to stack.\n                    context.enter_style(f\"markdown.{tag}\")\n                elif exiting:\n                    # If it's a closing inline style, then we pop the style\n                    # off of the stack, to move out of the context of it...\n                    context.leave_style()\n                else:\n                    # If it's a self-closing inline style e.g. `code_inline`\n                    context.enter_style(f\"markdown.{tag}\")\n                    if token.content:\n                        context.on_text(token.content, node_type)\n                    context.leave_style()\n            else:\n                # Map the markdown tag -> MarkdownElement renderable\n                element_class = self.elements.get(token.type) or UnknownElement\n                element = element_class.create(self, token)\n\n                if entering or self_closing:\n                    context.stack.push(element)\n                    element.on_enter(context)\n\n                if exiting:  # CLOSING tag\n                    element = context.stack.pop()\n\n                    should_render = not context.stack or (\n                        context.stack and context.stack.top.on_child_close(context, element)\n                    )\n\n                    if should_render:\n                        if new_line and render_started:\n                            yield _new_line_segment\n\n                        rendered = console.render(element, context.options)\n                        for segment in rendered:\n                            render_started = True\n                            yield segment\n                elif self_closing:  # SELF-CLOSING tags (e.g. text, code, image)\n                    context.stack.pop()\n                    text = token.content\n                    if text is not None:\n                        element.on_text(context, text)\n\n                    should_render = (\n                        not context.stack\n                        or context.stack\n                        and context.stack.top.on_child_close(context, element)\n                    )\n                    if should_render:\n                        if new_line and node_type != \"inline\" and render_started:\n                            yield _new_line_segment\n                        rendered = console.render(element, context.options)\n                        for segment in rendered:\n                            render_started = True\n                            yield segment\n\n                if exiting or self_closing:\n                    element.on_leave(context)\n                    new_line = element.new_line\n\n\nif __name__ == \"__main__\":\n    import argparse\n    import sys\n\n    parser = argparse.ArgumentParser(description=\"Render Markdown to the console with Rich\")\n    parser.add_argument(\n        \"path\",\n        metavar=\"PATH\",\n        help=\"path to markdown file, or - for stdin\",\n    )\n    parser.add_argument(\n        \"-c\",\n        \"--force-color\",\n        dest=\"force_color\",\n        action=\"store_true\",\n        default=None,\n        help=\"force color for non-terminals\",\n    )\n    parser.add_argument(\n        \"-t\",\n        \"--code-theme\",\n        dest=\"code_theme\",\n        default=KIMI_ANSI_THEME_NAME,\n        help='code theme (pygments name or \"kimi-ansi\")',\n    )\n    parser.add_argument(\n        \"-i\",\n        \"--inline-code-lexer\",\n        dest=\"inline_code_lexer\",\n        default=None,\n        help=\"inline_code_lexer\",\n    )\n    parser.add_argument(\n        \"-y\",\n        \"--hyperlinks\",\n        dest=\"hyperlinks\",\n        action=\"store_true\",\n        help=\"enable hyperlinks\",\n    )\n    parser.add_argument(\n        \"-w\",\n        \"--width\",\n        type=int,\n        dest=\"width\",\n        default=None,\n        help=\"width of output (default will auto-detect)\",\n    )\n    parser.add_argument(\n        \"-j\",\n        \"--justify\",\n        dest=\"justify\",\n        action=\"store_true\",\n        help=\"enable full text justify\",\n    )\n    parser.add_argument(\n        \"-p\",\n        \"--page\",\n        dest=\"page\",\n        action=\"store_true\",\n        help=\"use pager to scroll output\",\n    )\n    args = parser.parse_args()\n\n    from rich.console import Console\n\n    if args.path == \"-\":\n        markdown_body = sys.stdin.read()\n    else:\n        with open(args.path, encoding=\"utf-8\") as markdown_file:\n            markdown_body = markdown_file.read()\n\n    markdown = Markdown(\n        markdown_body,\n        justify=\"full\" if args.justify else \"left\",\n        code_theme=args.code_theme,\n        hyperlinks=args.hyperlinks,\n        inline_code_lexer=args.inline_code_lexer,\n    )\n    if args.page:\n        import io\n        import pydoc\n\n        fileio = io.StringIO()\n        console = Console(file=fileio, force_terminal=args.force_color, width=args.width)\n        console.print(markdown)\n        pydoc.pager(fileio.getvalue())\n\n    else:\n        console = Console(force_terminal=args.force_color, width=args.width, record=True)\n        console.print(markdown)\n"
  },
  {
    "path": "src/kimi_cli/utils/rich/markdown_sample.md",
    "content": "# Markdown Sample Document\n\nThis is a comprehensive sample document showcasing various Markdown elements.\n\n## Level 2 Heading\n\n### Level 3 Heading\n\nHere's some regular text with **bold text**, *italic text*, and `inline code`.\n\n## Lists\n\n### Unordered List\n\n- First item\n- Second item\n  - Nested item 1\n  - Nested item 2\n- Third item\n\n### Ordered List\n\n1. First step\n2. Second step\n   1. Sub-step A\n   2. Sub-step B\n3. Third step\n\n### Mixed List\n\n1. First item\n   - Sub-item with bullet\n   - Another sub-item\n2. Second item\n   1. Numbered sub-item\n   2. Another numbered sub-item\n\n## Links and References\n\nHere's a [link to GitHub](https://github.com) and another [relative link](../README.md).\n\n## Code Blocks\n\n```python\ndef hello_world():\n    \"\"\"A simple function to demonstrate code blocks.\"\"\"\n    print(\"Hello, World!\")\n    return 42\n\n# Call the function\nresult = hello_world()\n```\n\n```bash\n# Bash example\necho \"This is a bash script\"\nls -la /tmp\n```\n\n## Blockquotes\n\n> This is a blockquote.\n> It can span multiple lines.\n>\n> > And it can be nested too!\n\n## Tables\n\n| Column 1 | Column 2 | Column 3 |\n|----------|----------|----------|\n| Cell 1   | Cell 2   | Cell 3   |\n| Left     | Center   | Right    |\n| Foo      | Bar      | Baz      |\n\n## Horizontal Rules\n\n---\n\nHere's some text after a horizontal rule.\n\n---\n\n## Inline Formatting\n\nYou can combine **bold and *italic*** text, or use `code` within paragraphs.\n\n**Important**: Always test your `code` snippets before deployment.\n\n## Advanced Features\n\n### Task Lists\n\n- [x] Completed task\n- [ ] Pending task\n- [ ] Another pending task\n\n### Definition Lists\n\nTerm 1\n: Definition of term 1\n\nTerm 2\n: Definition of term 2\n: Another definition for term 2\n\n---\n\n*This document demonstrates comprehensive Markdown formatting capabilities.*\n"
  },
  {
    "path": "src/kimi_cli/utils/rich/markdown_sample_short.md",
    "content": "- First\n- Second\n"
  },
  {
    "path": "src/kimi_cli/utils/rich/syntax.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any\n\nfrom pygments.token import (\n    Comment,\n    Generic,\n    Keyword,\n    Name,\n    Number,\n    Operator,\n    Punctuation,\n    String,\n)\nfrom pygments.token import (\n    Literal as PygmentsLiteral,\n)\nfrom pygments.token import (\n    Text as PygmentsText,\n)\nfrom pygments.token import (\n    Token as PygmentsToken,\n)\nfrom rich.style import Style\nfrom rich.syntax import ANSISyntaxTheme, Syntax, SyntaxTheme\n\nKIMI_ANSI_THEME_NAME = \"kimi-ansi\"\nKIMI_ANSI_THEME = ANSISyntaxTheme(\n    {\n        PygmentsToken: Style(color=\"default\"),\n        PygmentsText: Style(color=\"default\"),\n        Comment: Style(color=\"bright_black\", italic=True),\n        Keyword: Style(color=\"bright_magenta\", bold=True),\n        Keyword.Constant: Style(color=\"bright_magenta\", bold=True),\n        Keyword.Declaration: Style(color=\"bright_magenta\", bold=True),\n        Keyword.Namespace: Style(color=\"bright_magenta\", bold=True),\n        Keyword.Pseudo: Style(color=\"bright_magenta\"),\n        Keyword.Reserved: Style(color=\"bright_magenta\", bold=True),\n        Keyword.Type: Style(color=\"bright_magenta\", bold=True),\n        Name: Style(color=\"default\"),\n        Name.Attribute: Style(color=\"cyan\"),\n        Name.Builtin: Style(color=\"bright_cyan\"),\n        Name.Builtin.Pseudo: Style(color=\"bright_magenta\"),\n        Name.Builtin.Type: Style(color=\"bright_cyan\", bold=True),\n        Name.Class: Style(color=\"bright_cyan\", bold=True),\n        Name.Constant: Style(color=\"bright_magenta\"),\n        Name.Decorator: Style(color=\"bright_magenta\"),\n        Name.Entity: Style(color=\"bright_cyan\"),\n        Name.Exception: Style(color=\"bright_magenta\", bold=True),\n        Name.Function: Style(color=\"bright_blue\"),\n        Name.Label: Style(color=\"bright_cyan\"),\n        Name.Namespace: Style(color=\"bright_cyan\"),\n        Name.Other: Style(color=\"bright_blue\"),\n        Name.Property: Style(color=\"bright_blue\"),\n        Name.Tag: Style(color=\"bright_blue\"),\n        Name.Variable: Style(color=\"bright_blue\"),\n        PygmentsLiteral: Style(color=\"bright_green\"),\n        PygmentsLiteral.Date: Style(color=\"green\"),\n        String: Style(color=\"yellow\"),\n        String.Doc: Style(color=\"yellow\", italic=True),\n        String.Interpol: Style(color=\"yellow\"),\n        String.Affix: Style(color=\"yellow\"),\n        Number: Style(color=\"bright_green\"),\n        Operator: Style(color=\"default\"),\n        Punctuation: Style(color=\"default\"),\n        Generic.Deleted: Style(color=\"red\"),\n        Generic.Emph: Style(italic=True),\n        Generic.Error: Style(color=\"bright_red\", bold=True),\n        Generic.Heading: Style(color=\"bright_cyan\", bold=True),\n        Generic.Inserted: Style(color=\"green\"),\n        Generic.Output: Style(color=\"bright_black\"),\n        Generic.Prompt: Style(color=\"bright_magenta\"),\n        Generic.Strong: Style(bold=True),\n        Generic.Subheading: Style(color=\"bright_cyan\"),\n        Generic.Traceback: Style(color=\"bright_red\", bold=True),\n    }\n)\n\n\ndef resolve_code_theme(theme: str | SyntaxTheme) -> str | SyntaxTheme:\n    if isinstance(theme, str) and theme.lower() == KIMI_ANSI_THEME_NAME:\n        return KIMI_ANSI_THEME\n    return theme\n\n\nclass KimiSyntax(Syntax):\n    def __init__(self, code: str, lexer: str, **kwargs: Any) -> None:\n        if \"theme\" not in kwargs or kwargs[\"theme\"] is None:\n            kwargs[\"theme\"] = KIMI_ANSI_THEME\n        super().__init__(code, lexer, **kwargs)\n\n\nif __name__ == \"__main__\":\n    from rich.console import Console\n    from rich.text import Text\n\n    console = Console()\n\n    examples = [\n        (\"diff\", \"diff\", \"@@ -1,2 +1,2 @@\\n-line one\\n+line uno\\n\"),\n        (\n            \"python\",\n            \"python\",\n            'def greet(name: str) -> str:\\n    return f\"Hi, {name}!\"\\n',\n        ),\n        (\"bash\", \"bash\", \"set -euo pipefail\\nprintf '%s\\\\n' \\\"hello\\\"\\n\"),\n    ]\n\n    for idx, (title, lexer, code) in enumerate(examples):\n        if idx:\n            console.print()\n        console.print(Text(f\"[{title}]\", style=\"bold\"))\n        console.print(KimiSyntax(code, lexer))\n"
  },
  {
    "path": "src/kimi_cli/utils/signals.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport signal\nfrom collections.abc import Callable\n\n\ndef install_sigint_handler(\n    loop: asyncio.AbstractEventLoop, handler: Callable[[], None]\n) -> Callable[[], None]:\n    \"\"\"\n    Install a SIGINT handler that works on Unix and Windows.\n\n    On Unix event loops, prefer `loop.add_signal_handler`.\n    On Windows (or other platforms) where it is not implemented, fall back to\n    `signal.signal`. The fallback cannot be removed from the loop, but we\n    restore the previous handler on uninstall.\n\n    Returns:\n        A function that removes the installed handler. It is guaranteed that\n        no exceptions are raised when calling the returned function.\n    \"\"\"\n\n    try:\n        loop.add_signal_handler(signal.SIGINT, handler)\n\n        def remove() -> None:\n            with contextlib.suppress(RuntimeError):\n                loop.remove_signal_handler(signal.SIGINT)\n\n        return remove\n    except RuntimeError:\n        # Windows ProactorEventLoop and some environments do not support\n        # add_signal_handler. Use synchronous signal handling as a fallback.\n        previous = signal.getsignal(signal.SIGINT)\n        signal.signal(signal.SIGINT, lambda signum, frame: handler())\n\n        def remove() -> None:\n            with contextlib.suppress(RuntimeError):\n                signal.signal(signal.SIGINT, previous)\n\n        return remove\n"
  },
  {
    "path": "src/kimi_cli/utils/slashcmd.py",
    "content": "import re\nfrom collections.abc import Awaitable, Callable, Sequence\nfrom dataclasses import dataclass\nfrom typing import overload\n\n\n@dataclass(frozen=True, slots=True, kw_only=True)\nclass SlashCommand[F: Callable[..., None | Awaitable[None]]]:\n    name: str\n    description: str\n    func: F\n    aliases: list[str]\n\n    def slash_name(self):\n        \"\"\"/name (aliases)\"\"\"\n        if self.aliases:\n            return f\"/{self.name} ({', '.join(self.aliases)})\"\n        return f\"/{self.name}\"\n\n\nclass SlashCommandRegistry[F: Callable[..., None | Awaitable[None]]]:\n    \"\"\"Registry for slash commands.\"\"\"\n\n    def __init__(self) -> None:\n        self._commands: dict[str, SlashCommand[F]] = {}\n        \"\"\"Primary name -> SlashCommand\"\"\"\n        self._command_aliases: dict[str, SlashCommand[F]] = {}\n        \"\"\"Primary name or alias -> SlashCommand\"\"\"\n\n    @overload\n    def command(self, func: F, /) -> F: ...\n\n    @overload\n    def command(\n        self,\n        *,\n        name: str | None = None,\n        aliases: Sequence[str] | None = None,\n    ) -> Callable[[F], F]: ...\n\n    def command(\n        self,\n        func: F | None = None,\n        *,\n        name: str | None = None,\n        aliases: Sequence[str] | None = None,\n    ) -> F | Callable[[F], F]:\n        \"\"\"\n        Decorator to register a slash command with optional custom name and aliases.\n\n        Usage examples:\n          @registry.command\n          def help(app: App, args: str): ...\n\n          @registry.command(name=\"run\")\n          def start(app: App, args: str): ...\n\n          @registry.command(aliases=[\"h\", \"?\", \"assist\"])\n          def help(app: App, args: str): ...\n        \"\"\"\n\n        def _register(f: F) -> F:\n            primary = name or f.__name__\n            alias_list = list(aliases) if aliases else []\n\n            # Create the primary command with aliases\n            cmd = SlashCommand[F](\n                name=primary,\n                description=(f.__doc__ or \"\").strip(),\n                func=f,\n                aliases=alias_list,\n            )\n\n            # Register primary command\n            self._commands[primary] = cmd\n            self._command_aliases[primary] = cmd\n\n            # Register aliases pointing to the same command\n            for alias in alias_list:\n                self._command_aliases[alias] = cmd\n\n            return f\n\n        if func is not None:\n            return _register(func)\n        return _register\n\n    def find_command(self, name: str) -> SlashCommand[F] | None:\n        return self._command_aliases.get(name)\n\n    def list_commands(self) -> list[SlashCommand[F]]:\n        \"\"\"Get all unique primary slash commands (without duplicating aliases).\"\"\"\n        return list(self._commands.values())\n\n\n@dataclass(frozen=True, slots=True, kw_only=True)\nclass SlashCommandCall:\n    name: str\n    args: str\n    raw_input: str\n\n\ndef parse_slash_command_call(user_input: str) -> SlashCommandCall | None:\n    \"\"\"\n    Parse a slash command call from user input.\n\n    Returns:\n        SlashCommandCall if a slash command is found, else None. The `args` field contains\n        the raw argument string after the command name.\n    \"\"\"\n    user_input = user_input.strip()\n    if not user_input or not user_input.startswith(\"/\"):\n        return None\n\n    name_match = re.match(r\"^\\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*)\", user_input)\n\n    if not name_match:\n        return None\n\n    command_name = name_match.group(1)\n    if len(user_input) > name_match.end() and not user_input[name_match.end()].isspace():\n        return None\n    raw_args = user_input[name_match.end() :].lstrip()\n    return SlashCommandCall(name=command_name, args=raw_args, raw_input=user_input)\n"
  },
  {
    "path": "src/kimi_cli/utils/string.py",
    "content": "from __future__ import annotations\n\nimport random\nimport re\nimport string\n\n_NEWLINE_RE = re.compile(r\"[\\r\\n]+\")\n\n\ndef shorten_middle(text: str, width: int, remove_newline: bool = True) -> str:\n    \"\"\"Shorten the text by inserting ellipsis in the middle.\"\"\"\n    if len(text) <= width:\n        return text\n    if remove_newline:\n        text = _NEWLINE_RE.sub(\" \", text)\n    return text[: width // 2] + \"...\" + text[-width // 2 :]\n\n\ndef random_string(length: int = 8) -> str:\n    \"\"\"Generate a random string of fixed length.\"\"\"\n    letters = string.ascii_lowercase\n    return \"\".join(random.choice(letters) for _ in range(length))\n"
  },
  {
    "path": "src/kimi_cli/utils/subprocess_env.py",
    "content": "\"\"\"Utilities for subprocess environment handling.\n\nThis module provides utilities to handle environment variables when spawning\nsubprocesses from a PyInstaller-frozen application. The main issue is that\nPyInstaller's bootloader modifies LD_LIBRARY_PATH to prioritize bundled libraries,\nwhich can cause conflicts when spawning external programs that expect system libraries.\n\nSee: https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport sys\n\n# Environment variables that PyInstaller may modify on Linux\n_PYINSTALLER_LD_VARS = [\n    \"LD_LIBRARY_PATH\",\n    \"LD_PRELOAD\",\n]\n\n\ndef get_clean_env(base_env: dict[str, str] | None = None) -> dict[str, str]:\n    \"\"\"\n    Get a clean environment suitable for spawning subprocesses.\n\n    In a PyInstaller-frozen application on Linux, this function restores\n    the original library path environment variables, preventing subprocesses\n    from loading incompatible bundled libraries.\n\n    Args:\n        base_env: Base environment to start from. If None, uses os.environ.\n\n    Returns:\n        A dictionary of environment variables safe for subprocess use.\n    \"\"\"\n    env = dict(base_env if base_env is not None else os.environ)\n\n    # Only process in PyInstaller frozen environment on Linux\n    if not getattr(sys, \"frozen\", False) or sys.platform != \"linux\":\n        return env\n\n    for var in _PYINSTALLER_LD_VARS:\n        orig_key = f\"{var}_ORIG\"\n        if orig_key in env:\n            # Restore the original value that was saved by PyInstaller bootloader\n            env[var] = env[orig_key]\n        elif var in env:\n            # Variable was not set before PyInstaller modified it, so remove it\n            del env[var]\n\n    return env\n"
  },
  {
    "path": "src/kimi_cli/utils/term.py",
    "content": "from __future__ import annotations\n\nimport contextlib\nimport os\nimport re\nimport sys\nimport time\n\n\ndef ensure_new_line() -> None:\n    \"\"\"Ensure the next prompt starts at column 0 regardless of prior command output.\"\"\"\n\n    if not sys.stdout.isatty() or not sys.stdin.isatty():\n        return\n\n    needs_break = True\n    if sys.platform == \"win32\":\n        column = _cursor_column_windows()\n        needs_break = column not in (None, 0)\n    else:\n        column = _cursor_column_unix()\n        needs_break = column not in (None, 1)\n\n    if needs_break:\n        _write_newline()\n\n\ndef ensure_tty_sane() -> None:\n    \"\"\"Restore basic tty settings so Ctrl-C works after raw-mode operations.\"\"\"\n    if sys.platform == \"win32\" or not sys.stdin.isatty():\n        return\n\n    try:\n        import termios\n    except Exception:\n        return\n\n    try:\n        fd = sys.stdin.fileno()\n        attrs = termios.tcgetattr(fd)\n    except Exception:\n        return\n\n    desired = termios.ISIG | termios.IEXTEN | termios.ICANON | termios.ECHO\n    if (attrs[3] & desired) == desired:\n        return\n\n    attrs[3] |= desired\n    with contextlib.suppress(OSError):\n        termios.tcsetattr(fd, termios.TCSADRAIN, attrs)\n\n\ndef _cursor_position_unix() -> tuple[int, int] | None:\n    \"\"\"Get cursor position (row, column) on Unix. Both are 1-indexed.\"\"\"\n    assert sys.platform != \"win32\"\n\n    import select\n    import termios\n    import tty\n\n    _CURSOR_QUERY = \"\\x1b[6n\"\n    _CURSOR_POSITION_RE = re.compile(r\"\\x1b\\[(\\d+);(\\d+)R\")\n\n    fd = sys.stdin.fileno()\n    oldterm = termios.tcgetattr(fd)\n\n    try:\n        tty.setcbreak(fd)\n        sys.stdout.write(_CURSOR_QUERY)\n        sys.stdout.flush()\n\n        response = \"\"\n        deadline = time.monotonic() + 0.2\n        while time.monotonic() < deadline:\n            timeout = max(0.01, deadline - time.monotonic())\n            ready, _, _ = select.select([sys.stdin], [], [], timeout)\n            if not ready:\n                continue\n            try:\n                chunk = os.read(fd, 32)\n            except OSError:\n                break\n            if not chunk:\n                break\n            response += chunk.decode(encoding=\"utf-8\", errors=\"ignore\")\n            match = _CURSOR_POSITION_RE.search(response)\n            if match:\n                return int(match.group(1)), int(match.group(2))\n    finally:\n        termios.tcsetattr(fd, termios.TCSADRAIN, oldterm)\n\n    return None\n\n\ndef _cursor_column_unix() -> int | None:\n    pos = _cursor_position_unix()\n    return pos[1] if pos else None\n\n\ndef _cursor_position_windows() -> tuple[int, int] | None:\n    \"\"\"Get cursor position (row, column) on Windows. Both are 1-indexed.\"\"\"\n    assert sys.platform == \"win32\"\n\n    import ctypes\n    from ctypes import wintypes\n\n    kernel32 = ctypes.windll.kernel32\n    _STD_OUTPUT_HANDLE = -11  # Windows API constant for standard output handle\n    handle = kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)\n    invalid_handle_value = ctypes.c_void_p(-1).value\n    if handle in (0, invalid_handle_value):\n        return None\n\n    class COORD(ctypes.Structure):\n        _fields_ = [(\"X\", wintypes.SHORT), (\"Y\", wintypes.SHORT)]\n\n    class SMALL_RECT(ctypes.Structure):\n        _fields_ = [\n            (\"Left\", wintypes.SHORT),\n            (\"Top\", wintypes.SHORT),\n            (\"Right\", wintypes.SHORT),\n            (\"Bottom\", wintypes.SHORT),\n        ]\n\n    class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):\n        _fields_ = [\n            (\"dwSize\", COORD),\n            (\"dwCursorPosition\", COORD),\n            (\"wAttributes\", wintypes.WORD),\n            (\"srWindow\", SMALL_RECT),\n            (\"dwMaximumWindowSize\", COORD),\n        ]\n\n    csbi = CONSOLE_SCREEN_BUFFER_INFO()\n    if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(csbi)):\n        return None\n\n    # Windows returns 0-indexed, convert to 1-indexed for consistency\n    return int(csbi.dwCursorPosition.Y) + 1, int(csbi.dwCursorPosition.X) + 1\n\n\ndef _cursor_column_windows() -> int | None:\n    pos = _cursor_position_windows()\n    return pos[1] if pos else None\n\n\ndef _write_newline() -> None:\n    sys.stdout.write(\"\\n\")\n    sys.stdout.flush()\n\n\ndef get_cursor_row() -> int | None:\n    \"\"\"Get the current cursor row (1-indexed).\"\"\"\n    if not sys.stdout.isatty() or not sys.stdin.isatty():\n        return None\n\n    if sys.platform == \"win32\":\n        pos = _cursor_position_windows()\n    else:\n        pos = _cursor_position_unix()\n\n    return pos[0] if pos else None\n\n\nif __name__ == \"__main__\":\n    print(\"test\", end=\"\", flush=True)\n    ensure_new_line()\n    print(\"next line\")\n"
  },
  {
    "path": "src/kimi_cli/utils/typing.py",
    "content": "from types import UnionType\nfrom typing import Any, TypeAliasType, Union, get_args, get_origin\n\n\ndef flatten_union(tp: Any) -> tuple[Any, ...]:\n    \"\"\"\n    If `tp` is a `UnionType`, return its flattened arguments as a tuple.\n    Otherwise, return a tuple with `tp` as the only element.\n    \"\"\"\n    if isinstance(tp, TypeAliasType):\n        tp = tp.__value__\n    origin = get_origin(tp)\n    if origin in (UnionType, Union):\n        args = get_args(tp)\n        flattened_args: list[Any] = []\n        for arg in args:\n            flattened_args.extend(flatten_union(arg))\n        return tuple(flattened_args)\n    else:\n        return (tp,)\n"
  },
  {
    "path": "src/kimi_cli/vis/__init__.py",
    "content": ""
  },
  {
    "path": "src/kimi_cli/vis/api/__init__.py",
    "content": "from kimi_cli.vis.api.sessions import router as sessions_router\nfrom kimi_cli.vis.api.statistics import router as statistics_router\nfrom kimi_cli.vis.api.system import router as system_router\n\n__all__ = [\"sessions_router\", \"statistics_router\", \"system_router\"]\n"
  },
  {
    "path": "src/kimi_cli/vis/api/sessions.py",
    "content": "\"\"\"Vis API for reading session tracing data.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport io\nimport json\nimport logging\nimport re\nimport shutil\nimport zipfile\nfrom pathlib import Path\nfrom typing import Any\nfrom uuid import uuid4\n\nimport aiofiles\nfrom fastapi import APIRouter, HTTPException, UploadFile\nfrom fastapi.responses import StreamingResponse\n\nfrom kimi_cli.metadata import load_metadata\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.wire.file import WireFileMetadata, parse_wire_file_line\n\nrouter = APIRouter(prefix=\"/api/vis\", tags=[\"vis\"])\nlogger = logging.getLogger(__name__)\n\n\ndef collect_events(\n    msg_type: str,\n    payload: dict[str, Any],\n    out: list[tuple[str, dict[str, Any]]],\n) -> None:\n    \"\"\"Recursively unwrap SubagentEvent and collect (type, payload) pairs.\"\"\"\n    if msg_type == \"SubagentEvent\":\n        inner: dict[str, Any] | None = payload.get(\"event\")\n        if isinstance(inner, dict):\n            inner_type: str = inner.get(\"type\", \"\")\n            inner_payload: dict[str, Any] = inner.get(\"payload\", {})\n            if inner_type:\n                collect_events(inner_type, inner_payload, out)\n    else:\n        out.append((msg_type, payload))\n\n\n_SESSION_ID_RE = re.compile(r\"^[a-zA-Z0-9_-]+$\")\n_IMPORTED_HASH = \"__imported__\"\n\n\ndef _get_imported_root() -> Path:\n    \"\"\"Return the root directory for imported sessions.\"\"\"\n    return get_share_dir() / \"imported_sessions\"\n\n\ndef _find_session_dir(work_dir_hash: str, session_id: str) -> Path | None:\n    \"\"\"Find session directory by work_dir_hash and session_id.\"\"\"\n    if not _SESSION_ID_RE.match(session_id):\n        return None\n    if work_dir_hash == _IMPORTED_HASH:\n        session_dir = _get_imported_root() / session_id\n        if session_dir.is_dir():\n            return session_dir\n        return None\n    if not _SESSION_ID_RE.match(work_dir_hash):\n        return None\n    sessions_root = get_share_dir() / \"sessions\"\n    session_dir = sessions_root / work_dir_hash / session_id\n    if session_dir.is_dir():\n        return session_dir\n    return None\n\n\ndef get_work_dir_for_hash(hash_dir_name: str) -> str | None:\n    \"\"\"Look up the work directory path from metadata for a given hash directory name.\"\"\"\n    try:\n        metadata = load_metadata()\n    except Exception:\n        return None\n    from hashlib import md5\n\n    from kaos.local import local_kaos\n\n    for wd in metadata.work_dirs:\n        path_md5 = md5(wd.path.encode(encoding=\"utf-8\")).hexdigest()\n        dir_basename = path_md5 if wd.kaos == local_kaos.name else f\"{wd.kaos}_{path_md5}\"\n        if dir_basename == hash_dir_name:\n            return wd.path\n    return None\n\n\ndef _scan_session_dir(\n    session_dir: Path,\n    work_dir_hash: str,\n    work_dir: str | None,\n    *,\n    imported: bool = False,\n) -> dict[str, Any] | None:\n    \"\"\"Extract session info from a session directory.\"\"\"\n    if not session_dir.is_dir():\n        return None\n\n    wire_path = session_dir / \"wire.jsonl\"\n    context_path = session_dir / \"context.jsonl\"\n    state_path = session_dir / \"state.json\"\n\n    # Get last updated time from most recent file\n    mtimes: list[float] = []\n    for p in [wire_path, context_path, state_path]:\n        if p.exists():\n            mtimes.append(p.stat().st_mtime)\n\n    # Extract title and count turns from wire.jsonl\n    title = \"\"\n    turn_count = 0\n    if wire_path.exists():\n        try:\n            with wire_path.open(encoding=\"utf-8\") as f:\n                for line in f:\n                    line = line.strip()\n                    if not line:\n                        continue\n                    try:\n                        parsed = parse_wire_file_line(line)\n                    except Exception:\n                        logger.debug(\"Skipped malformed line in %s\", wire_path)\n                        continue\n                    if isinstance(parsed, WireFileMetadata):\n                        continue\n                    if parsed.message.type == \"TurnBegin\":\n                        turn_count += 1\n                        if turn_count == 1:\n                            user_input = parsed.message.payload.get(\"user_input\", \"\")\n                            if isinstance(user_input, str):\n                                title = user_input[:100]\n                            elif isinstance(user_input, list) and user_input:\n                                first = user_input[0]\n                                if isinstance(first, dict):\n                                    title = str(first.get(\"text\", \"\"))[:100]\n        except Exception:\n            pass\n\n    # File sizes (cheap stat calls)\n    wire_size = wire_path.stat().st_size if wire_path.exists() else 0\n    context_size = context_path.stat().st_size if context_path.exists() else 0\n    state_size = state_path.stat().st_size if state_path.exists() else 0\n\n    # Read metadata.json if it exists\n    metadata_info: dict[str, Any] | None = None\n    metadata_path = session_dir / \"metadata.json\"\n    if metadata_path.exists():\n        with contextlib.suppress(Exception):\n            metadata_info = json.loads(metadata_path.read_text(encoding=\"utf-8\"))\n\n    return {\n        \"session_id\": session_dir.name,\n        \"session_dir\": str(session_dir),\n        \"work_dir\": work_dir,\n        \"work_dir_hash\": work_dir_hash,\n        \"title\": title,\n        \"last_updated\": max(mtimes) if mtimes else 0,\n        \"has_wire\": wire_path.exists(),\n        \"has_context\": context_path.exists(),\n        \"has_state\": state_path.exists(),\n        \"metadata\": metadata_info,\n        \"wire_size\": wire_size,\n        \"context_size\": context_size,\n        \"state_size\": state_size,\n        \"total_size\": wire_size + context_size + state_size,\n        \"turns\": turn_count,\n        \"imported\": imported,\n    }\n\n\n@router.get(\"/sessions\")\ndef list_sessions() -> list[dict[str, Any]]:\n    \"\"\"List all available sessions across all work directories.\"\"\"\n    results: list[dict[str, Any]] = []\n\n    # Scan normal sessions\n    sessions_root = get_share_dir() / \"sessions\"\n    if sessions_root.exists():\n        for work_dir_hash_dir in sessions_root.iterdir():\n            if not work_dir_hash_dir.is_dir():\n                continue\n            work_dir = get_work_dir_for_hash(work_dir_hash_dir.name)\n            for session_dir in work_dir_hash_dir.iterdir():\n                info = _scan_session_dir(session_dir, work_dir_hash_dir.name, work_dir)\n                if info:\n                    results.append(info)\n\n    # Scan imported sessions\n    imported_root = _get_imported_root()\n    if imported_root.exists():\n        for session_dir in imported_root.iterdir():\n            info = _scan_session_dir(\n                session_dir,\n                _IMPORTED_HASH,\n                None,\n                imported=True,\n            )\n            if info:\n                results.append(info)\n\n    results.sort(key=lambda s: s[\"last_updated\"], reverse=True)\n    return results\n\n\n@router.get(\"/sessions/{work_dir_hash}/{session_id}/wire\")\nasync def get_wire_events(work_dir_hash: str, session_id: str) -> dict[str, Any]:\n    \"\"\"Read and parse wire.jsonl for a session.\"\"\"\n    session_dir = _find_session_dir(work_dir_hash, session_id)\n    if session_dir is None:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n\n    wire_path = session_dir / \"wire.jsonl\"\n    if not wire_path.exists():\n        return {\"total\": 0, \"events\": []}\n\n    events: list[dict[str, Any]] = []\n    index = 0\n    async with aiofiles.open(wire_path, encoding=\"utf-8\") as f:\n        async for line in f:\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                parsed = parse_wire_file_line(line)\n            except Exception:\n                logger.debug(\"Skipped malformed line in %s\", wire_path)\n                continue\n            if isinstance(parsed, WireFileMetadata):\n                continue\n            events.append(\n                {\n                    \"index\": index,\n                    \"timestamp\": parsed.timestamp,\n                    \"type\": parsed.message.type,\n                    \"payload\": parsed.message.payload,\n                }\n            )\n            index += 1\n\n    return {\"total\": len(events), \"events\": events}\n\n\n@router.get(\"/sessions/{work_dir_hash}/{session_id}/context\")\nasync def get_context_messages(work_dir_hash: str, session_id: str) -> dict[str, Any]:\n    \"\"\"Read and parse context.jsonl for a session.\"\"\"\n    session_dir = _find_session_dir(work_dir_hash, session_id)\n    if session_dir is None:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n\n    context_path = session_dir / \"context.jsonl\"\n    if not context_path.exists():\n        return {\"total\": 0, \"messages\": []}\n\n    messages: list[dict[str, Any]] = []\n    index = 0\n    async with aiofiles.open(context_path, encoding=\"utf-8\") as f:\n        async for line in f:\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                msg = json.loads(line)\n            except json.JSONDecodeError:\n                logger.debug(\"Skipped malformed line in %s\", context_path)\n                continue\n            msg[\"index\"] = index\n            messages.append(msg)\n            index += 1\n\n    return {\"total\": len(messages), \"messages\": messages}\n\n\n@router.get(\"/sessions/{work_dir_hash}/{session_id}/state\")\nasync def get_session_state(work_dir_hash: str, session_id: str) -> dict[str, Any]:\n    \"\"\"Read state.json for a session.\"\"\"\n    session_dir = _find_session_dir(work_dir_hash, session_id)\n    if session_dir is None:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n\n    state_path = session_dir / \"state.json\"\n    if not state_path.exists():\n        return {}\n\n    async with aiofiles.open(state_path, encoding=\"utf-8\") as f:\n        content = await f.read()\n    try:\n        return json.loads(content)\n    except json.JSONDecodeError as err:\n        raise HTTPException(status_code=500, detail=\"Invalid state.json\") from err\n\n\n@router.get(\"/sessions/{work_dir_hash}/{session_id}/summary\")\nasync def get_session_summary(work_dir_hash: str, session_id: str) -> dict[str, Any]:\n    \"\"\"Compute summary statistics for a session by scanning wire.jsonl.\"\"\"\n    session_dir = _find_session_dir(work_dir_hash, session_id)\n    if session_dir is None:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n\n    wire_path = session_dir / \"wire.jsonl\"\n    context_path = session_dir / \"context.jsonl\"\n    state_path = session_dir / \"state.json\"\n\n    wire_size = wire_path.stat().st_size if wire_path.exists() else 0\n    context_size = context_path.stat().st_size if context_path.exists() else 0\n    state_size = state_path.stat().st_size if state_path.exists() else 0\n\n    zeros: dict[str, Any] = {\n        \"turns\": 0,\n        \"steps\": 0,\n        \"tool_calls\": 0,\n        \"errors\": 0,\n        \"compactions\": 0,\n        \"duration_sec\": 0,\n        \"input_tokens\": 0,\n        \"output_tokens\": 0,\n        \"wire_size\": wire_size,\n        \"context_size\": context_size,\n        \"state_size\": state_size,\n        \"total_size\": wire_size + context_size + state_size,\n    }\n\n    if not wire_path.exists():\n        return zeros\n\n    turns = steps = tool_calls = errors = compactions = 0\n    input_tokens = output_tokens = 0\n    first_ts = 0.0\n    last_ts = 0.0\n\n    async with aiofiles.open(wire_path, encoding=\"utf-8\") as f:\n        async for line in f:\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                parsed = parse_wire_file_line(line)\n            except Exception:\n                logger.debug(\"Skipped malformed line in %s\", wire_path)\n                continue\n            if isinstance(parsed, WireFileMetadata):\n                continue\n\n            ts = parsed.timestamp\n            msg_type = parsed.message.type\n            payload = parsed.message.payload\n\n            if first_ts == 0:\n                first_ts = ts\n            last_ts = ts\n\n            # Collect (type, payload) pairs, unwrapping SubagentEvent recursively\n            events_to_process: list[tuple[str, dict[str, Any]]] = []\n            collect_events(msg_type, payload, events_to_process)\n\n            for ev_type, ev_payload in events_to_process:\n                if ev_type == \"TurnBegin\":\n                    turns += 1\n                elif ev_type == \"StepBegin\":\n                    steps += 1\n                elif ev_type == \"ToolCall\":\n                    tool_calls += 1\n                elif ev_type == \"CompactionBegin\":\n                    compactions += 1\n                elif ev_type == \"StepInterrupted\":\n                    errors += 1\n                elif ev_type == \"ToolResult\":\n                    rv: dict[str, Any] | None = ev_payload.get(\"return_value\")\n                    if isinstance(rv, dict) and rv.get(\"is_error\"):\n                        errors += 1\n                elif ev_type == \"ApprovalResponse\":\n                    if ev_payload.get(\"response\") == \"reject\":\n                        errors += 1\n                elif ev_type == \"StatusUpdate\":\n                    tu: dict[str, Any] | None = ev_payload.get(\"token_usage\")\n                    if isinstance(tu, dict):\n                        input_tokens += (\n                            int(tu.get(\"input_other\", 0))\n                            + int(tu.get(\"input_cache_read\", 0))\n                            + int(tu.get(\"input_cache_creation\", 0))\n                        )\n                        output_tokens += int(tu.get(\"output\", 0))\n\n    return {\n        \"turns\": turns,\n        \"steps\": steps,\n        \"tool_calls\": tool_calls,\n        \"errors\": errors,\n        \"compactions\": compactions,\n        \"duration_sec\": last_ts - first_ts if last_ts > first_ts else 0,\n        \"input_tokens\": input_tokens,\n        \"output_tokens\": output_tokens,\n        \"wire_size\": wire_size,\n        \"context_size\": context_size,\n        \"state_size\": state_size,\n        \"total_size\": wire_size + context_size + state_size,\n    }\n\n\n@router.get(\"/sessions/{work_dir_hash}/{session_id}/download\")\ndef download_session(work_dir_hash: str, session_id: str) -> StreamingResponse:\n    \"\"\"Download all files in a session directory as a ZIP archive.\"\"\"\n    session_dir = _find_session_dir(work_dir_hash, session_id)\n    if session_dir is None:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n\n    buf = io.BytesIO()\n    with zipfile.ZipFile(buf, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        for file_path in sorted(session_dir.iterdir()):\n            if file_path.is_file():\n                zf.write(file_path, arcname=file_path.name)\n    buf.seek(0)\n\n    filename = f\"session-{session_id}.zip\"\n    return StreamingResponse(\n        buf,\n        media_type=\"application/zip\",\n        headers={\"Content-Disposition\": f'attachment; filename=\"{filename}\"'},\n    )\n\n\n@router.post(\"/sessions/import\")\nasync def import_session(file: UploadFile) -> dict[str, Any]:\n    \"\"\"Import a session from an uploaded ZIP archive.\"\"\"\n    if not file.filename or not file.filename.endswith(\".zip\"):\n        raise HTTPException(status_code=400, detail=\"Only .zip files are accepted\")\n\n    content = await file.read()\n    if not content:\n        raise HTTPException(status_code=400, detail=\"Empty file\")\n\n    # Reject uploads larger than 200 MB\n    _MAX_UPLOAD_BYTES = 200 * 1024 * 1024\n    if len(content) > _MAX_UPLOAD_BYTES:\n        raise HTTPException(status_code=413, detail=\"File too large (max 200 MB)\")\n\n    # Validate ZIP\n    buf = io.BytesIO(content)\n    try:\n        zf = zipfile.ZipFile(buf, \"r\")\n    except zipfile.BadZipFile as err:\n        raise HTTPException(status_code=400, detail=\"Invalid ZIP file\") from err\n\n    with zf:\n        names = zf.namelist()\n        # Must contain wire.jsonl or context.jsonl at root or under exactly one directory\n        _VALID_FILES = (\"wire.jsonl\", \"context.jsonl\")\n        has_valid = any(\n            n in _VALID_FILES or (n.count(\"/\") == 1 and n.endswith(_VALID_FILES)) for n in names\n        )\n        if not has_valid:\n            raise HTTPException(\n                status_code=400,\n                detail=\"ZIP must contain wire.jsonl or context.jsonl at the top level \"\n                \"(or inside a single directory)\",\n            )\n\n        session_id = uuid4().hex[:16]\n        imported_root = _get_imported_root()\n        session_dir = imported_root / session_id\n        session_dir.mkdir(parents=True, exist_ok=True)\n\n        # Zip Slip protection: reject entries with path traversal or absolute paths\n        for info in zf.infolist():\n            if info.filename.startswith(\"/\") or \"..\" in info.filename.split(\"/\"):\n                shutil.rmtree(session_dir, ignore_errors=True)\n                raise HTTPException(\n                    status_code=400,\n                    detail=\"ZIP contains unsafe path entries\",\n                )\n\n        # Extract - handle both flat ZIPs and ZIPs with a single top-level directory\n        zf.extractall(session_dir)\n\n        # If all files are under a single subdirectory, flatten them\n        entries = list(session_dir.iterdir())\n        if len(entries) == 1 and entries[0].is_dir():\n            nested_dir = entries[0]\n            for item in nested_dir.iterdir():\n                shutil.move(str(item), str(session_dir / item.name))\n            nested_dir.rmdir()\n\n    return {\n        \"session_id\": session_id,\n        \"work_dir_hash\": _IMPORTED_HASH,\n    }\n\n\n@router.delete(\"/sessions/{work_dir_hash}/{session_id}\")\ndef delete_session(work_dir_hash: str, session_id: str) -> dict[str, str]:\n    \"\"\"Delete an imported session.\"\"\"\n    if work_dir_hash != _IMPORTED_HASH:\n        raise HTTPException(status_code=403, detail=\"Only imported sessions can be deleted\")\n\n    if not _SESSION_ID_RE.match(session_id):\n        raise HTTPException(status_code=400, detail=\"Invalid session ID\")\n\n    session_dir = _get_imported_root() / session_id\n    if not session_dir.is_dir():\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n\n    shutil.rmtree(session_dir)\n    return {\"status\": \"deleted\"}\n"
  },
  {
    "path": "src/kimi_cli/vis/api/statistics.py",
    "content": "\"\"\"Vis API for aggregate statistics across all sessions.\"\"\"\n\nfrom __future__ import annotations\n\nimport time\nfrom collections import defaultdict\nfrom datetime import UTC, datetime, timedelta\nfrom typing import Any\n\nfrom fastapi import APIRouter\n\nfrom kimi_cli.share import get_share_dir\nfrom kimi_cli.vis.api.sessions import collect_events, get_work_dir_for_hash\nfrom kimi_cli.wire.file import WireFileMetadata, parse_wire_file_line\n\nrouter = APIRouter(prefix=\"/api/vis\", tags=[\"vis\"])\n\n\n# Simple in-memory cache: (result, timestamp)\n_cache: dict[str, tuple[dict[str, Any], float]] = {}\n_CACHE_TTL = 60  # seconds\n\n\n@router.get(\"/statistics\")\ndef get_statistics() -> dict[str, Any]:\n    \"\"\"Aggregate statistics across all sessions.\"\"\"\n    now = time.time()\n    cached = _cache.get(\"statistics\")\n    if cached and (now - cached[1]) < _CACHE_TTL:\n        return cached[0]\n\n    sessions_root = get_share_dir() / \"sessions\"\n    if not sessions_root.exists():\n        empty: dict[str, Any] = {\n            \"total_sessions\": 0,\n            \"total_turns\": 0,\n            \"total_tokens\": {\"input\": 0, \"output\": 0},\n            \"total_duration_sec\": 0,\n            \"tool_usage\": [],\n            \"daily_usage\": [],\n            \"per_project\": [],\n        }\n        _cache[\"statistics\"] = (empty, now)\n        return empty\n\n    total_sessions = 0\n    total_turns = 0\n    total_input_tokens = 0\n    total_output_tokens = 0\n    total_duration_sec = 0.0\n\n    # tool_name -> { count, error_count }\n    tool_stats: dict[str, dict[str, int]] = defaultdict(lambda: {\"count\": 0, \"error_count\": 0})\n\n    # date_str -> { sessions, turns }\n    daily_stats: dict[str, dict[str, int]] = defaultdict(lambda: {\"sessions\": 0, \"turns\": 0})\n\n    # work_dir -> { sessions, turns }\n    project_stats: dict[str, dict[str, int]] = defaultdict(lambda: {\"sessions\": 0, \"turns\": 0})\n\n    for work_dir_hash_dir in sessions_root.iterdir():\n        if not work_dir_hash_dir.is_dir():\n            continue\n        work_dir = get_work_dir_for_hash(work_dir_hash_dir.name) or work_dir_hash_dir.name\n\n        for session_dir in work_dir_hash_dir.iterdir():\n            if not session_dir.is_dir():\n                continue\n\n            wire_path = session_dir / \"wire.jsonl\"\n            if not wire_path.exists():\n                continue\n\n            total_sessions += 1\n            session_turns = 0\n            session_input_tokens = 0\n            session_output_tokens = 0\n            first_ts = 0.0\n            last_ts = 0.0\n            session_date: str | None = None\n\n            # Track pending tool calls for error attribution\n            pending_tools: dict[str, str] = {}  # tool_call_id -> tool_name\n\n            try:\n                with wire_path.open(encoding=\"utf-8\") as f:\n                    for line in f:\n                        line = line.strip()\n                        if not line:\n                            continue\n                        try:\n                            parsed = parse_wire_file_line(line)\n                        except Exception:\n                            continue\n                        if isinstance(parsed, WireFileMetadata):\n                            continue\n\n                        ts = parsed.timestamp\n                        msg_type = parsed.message.type\n                        payload = parsed.message.payload\n\n                        if first_ts == 0:\n                            first_ts = ts\n                            # Determine date from first timestamp\n                            try:\n                                dt = datetime.fromtimestamp(ts, tz=UTC)\n                                session_date = dt.strftime(\"%Y-%m-%d\")\n                            except Exception:\n                                pass\n                        last_ts = ts\n\n                        # Collect (type, payload) pairs, unwrapping SubagentEvent recursively\n                        events_to_process: list[tuple[str, dict[str, Any]]] = []\n                        collect_events(msg_type, payload, events_to_process)\n\n                        for ev_type, ev_payload in events_to_process:\n                            if ev_type == \"TurnBegin\":\n                                session_turns += 1\n                            elif ev_type == \"ToolCall\":\n                                fn: dict[str, Any] | None = ev_payload.get(\"function\")\n                                tool_id: str = ev_payload.get(\"id\", \"\")\n                                if isinstance(fn, dict):\n                                    name: str = fn.get(\"name\", \"unknown\")\n                                    tool_stats[name][\"count\"] += 1\n                                    if tool_id:\n                                        pending_tools[tool_id] = name\n                            elif ev_type == \"ToolResult\":\n                                tool_call_id: str = ev_payload.get(\"tool_call_id\", \"\")\n                                rv: dict[str, Any] | None = ev_payload.get(\"return_value\")\n                                if isinstance(rv, dict) and rv.get(\"is_error\"):\n                                    tool_name = pending_tools.get(tool_call_id)\n                                    if tool_name:\n                                        tool_stats[tool_name][\"error_count\"] += 1\n                                pending_tools.pop(tool_call_id, None)\n                            elif ev_type == \"StatusUpdate\":\n                                tu: dict[str, Any] | None = ev_payload.get(\"token_usage\")\n                                if isinstance(tu, dict):\n                                    session_input_tokens += (\n                                        int(tu.get(\"input_other\", 0))\n                                        + int(tu.get(\"input_cache_read\", 0))\n                                        + int(tu.get(\"input_cache_creation\", 0))\n                                    )\n                                    session_output_tokens += int(tu.get(\"output\", 0))\n            except Exception:\n                continue\n\n            total_turns += session_turns\n            total_input_tokens += session_input_tokens\n            total_output_tokens += session_output_tokens\n\n            duration = last_ts - first_ts if last_ts > first_ts else 0\n            total_duration_sec += duration\n\n            # Aggregate daily\n            if session_date:\n                daily_stats[session_date][\"sessions\"] += 1\n                daily_stats[session_date][\"turns\"] += session_turns\n\n            # Aggregate per project\n            project_stats[work_dir][\"sessions\"] += 1\n            project_stats[work_dir][\"turns\"] += session_turns\n\n    # Build tool_usage: top 20 by count\n    tool_usage = sorted(\n        [\n            {\"name\": name, \"count\": stats[\"count\"], \"error_count\": stats[\"error_count\"]}\n            for name, stats in tool_stats.items()\n        ],\n        key=lambda x: x[\"count\"],\n        reverse=True,\n    )[:20]\n\n    # Build daily_usage: last 30 days\n    today = datetime.now(tz=UTC)\n    daily_usage: list[dict[str, Any]] = []\n    for i in range(29, -1, -1):\n        d = today - timedelta(days=i)\n        date_str = d.strftime(\"%Y-%m-%d\")\n        entry = daily_stats.get(date_str, {\"sessions\": 0, \"turns\": 0})\n        daily_usage.append(\n            {\n                \"date\": date_str,\n                \"sessions\": entry[\"sessions\"],\n                \"turns\": entry[\"turns\"],\n            }\n        )\n\n    # Build per_project: top 10 by turns\n    per_project = sorted(\n        [\n            {\"work_dir\": wd, \"sessions\": stats[\"sessions\"], \"turns\": stats[\"turns\"]}\n            for wd, stats in project_stats.items()\n        ],\n        key=lambda x: x[\"turns\"],\n        reverse=True,\n    )[:10]\n\n    result: dict[str, Any] = {\n        \"total_sessions\": total_sessions,\n        \"total_turns\": total_turns,\n        \"total_tokens\": {\"input\": total_input_tokens, \"output\": total_output_tokens},\n        \"total_duration_sec\": total_duration_sec,\n        \"tool_usage\": tool_usage,\n        \"daily_usage\": daily_usage,\n        \"per_project\": per_project,\n    }\n\n    _cache[\"statistics\"] = (result, now)\n    return result\n"
  },
  {
    "path": "src/kimi_cli/vis/api/system.py",
    "content": "\"\"\"Vis API for server capabilities and metadata.\"\"\"\n\nfrom __future__ import annotations\n\nimport sys\nfrom typing import Any\n\nfrom fastapi import APIRouter\n\nrouter = APIRouter(prefix=\"/api/vis\", tags=[\"vis\"])\n\n\n@router.get(\"/capabilities\")\ndef get_capabilities() -> dict[str, Any]:\n    \"\"\"Return server capabilities that affect frontend feature visibility.\"\"\"\n    return {\"open_in_supported\": sys.platform in {\"darwin\", \"win32\"}}\n"
  },
  {
    "path": "src/kimi_cli/vis/app.py",
    "content": "\"\"\"Kimi Agent Tracing Visualizer application.\"\"\"\n\nfrom __future__ import annotations\n\nimport socket\nimport webbrowser\nfrom pathlib import Path\nfrom typing import Any, cast\n\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.staticfiles import StaticFiles\n\nfrom kimi_cli.vis.api import sessions_router, statistics_router, system_router\nfrom kimi_cli.web.api.open_in import router as open_in_router\n\nSTATIC_DIR = Path(__file__).parent / \"static\"\nGZIP_MINIMUM_SIZE = 1024\nGZIP_COMPRESSION_LEVEL = 6\nDEFAULT_PORT = 5495\nMAX_PORT_ATTEMPTS = 10\n\n\ndef create_app() -> FastAPI:\n    \"\"\"Create the FastAPI application for the tracing visualizer.\"\"\"\n    application = FastAPI(\n        title=\"Kimi Agent Tracing Visualizer\",\n        docs_url=None,\n        separate_input_output_schemas=False,\n    )\n\n    application.add_middleware(\n        cast(Any, GZipMiddleware),\n        minimum_size=GZIP_MINIMUM_SIZE,\n        compresslevel=GZIP_COMPRESSION_LEVEL,\n    )\n\n    application.add_middleware(\n        cast(Any, CORSMiddleware),\n        allow_origins=[\"*\"],  # Local-only tool; port is dynamic so wildcard is acceptable\n        allow_credentials=True,\n        allow_methods=[\"*\"],\n        allow_headers=[\"*\"],\n    )\n\n    application.include_router(sessions_router)\n    application.include_router(statistics_router)\n    application.include_router(system_router)\n    application.include_router(open_in_router)\n\n    @application.get(\"/healthz\")\n    async def health_probe() -> dict[str, Any]:  # pyright: ignore[reportUnusedFunction]\n        return {\"status\": \"ok\"}\n\n    if STATIC_DIR.exists():\n        application.mount(\"/\", StaticFiles(directory=STATIC_DIR, html=True), name=\"static\")\n\n    return application\n\n\ndef find_available_port(host: str, start_port: int, max_attempts: int = MAX_PORT_ATTEMPTS) -> int:\n    \"\"\"Find an available port starting from start_port.\"\"\"\n    for offset in range(max_attempts):\n        port = start_port + offset\n        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n            try:\n                s.bind((host, port))\n                return port\n            except OSError:\n                continue\n    raise RuntimeError(\n        f\"Cannot find available port in range {start_port}-{start_port + max_attempts - 1}\"\n    )\n\n\ndef _print_banner(lines: list[str]) -> None:\n    \"\"\"Print a boxed banner, reusing the same tag conventions as kimi web.\"\"\"\n    import textwrap\n\n    processed: list[str] = []\n    for line in lines:\n        if line == \"<hr>\":\n            processed.append(line)\n        elif not line:\n            processed.append(\"\")\n        elif line.startswith(\"<center>\") or line.startswith(\"<nowrap>\"):\n            processed.append(line)\n        else:\n            processed.extend(textwrap.wrap(line, width=78))\n\n    def strip_tags(s: str) -> str:\n        return s.removeprefix(\"<center>\").removeprefix(\"<nowrap>\")\n\n    content_lines = [strip_tags(line) for line in processed if line != \"<hr>\"]\n    width = max(60, *(len(line) for line in content_lines))\n    top = \"+\" + \"=\" * (width + 2) + \"+\"\n\n    print(top)\n    for line in processed:\n        if line == \"<hr>\":\n            print(\"|\" + \"-\" * (width + 2) + \"|\")\n        elif line.startswith(\"<center>\"):\n            content = line.removeprefix(\"<center>\")\n            print(f\"| {content.center(width)} |\")\n        elif line.startswith(\"<nowrap>\"):\n            content = line.removeprefix(\"<nowrap>\")\n            print(f\"| {content.ljust(width)} |\")\n        else:\n            print(f\"| {line.ljust(width)} |\")\n    print(top)\n\n\ndef run_vis_server(\n    host: str = \"127.0.0.1\",\n    port: int = DEFAULT_PORT,\n    reload: bool = False,\n    open_browser: bool = True,\n) -> None:\n    \"\"\"Run the visualizer web server.\"\"\"\n    import threading\n\n    import uvicorn\n\n    actual_port = find_available_port(host, port)\n    if actual_port != port:\n        print(f\"\\nPort {port} is in use, using port {actual_port} instead\")\n\n    url = f\"http://{host}:{actual_port}\"\n\n    banner_lines = [\n        \"<center>██╗  ██╗██╗███╗   ███╗██╗    ██╗   ██╗██╗███████╗\",\n        \"<center>██║ ██╔╝██║████╗ ████║██║    ██║   ██║██║██╔════╝\",\n        \"<center>█████╔╝ ██║██╔████╔██║██║    ██║   ██║██║███████╗\",\n        \"<center>██╔═██╗ ██║██║╚██╔╝██║██║    ╚██╗ ██╔╝██║╚════██║\",\n        \"<center>██║  ██╗██║██║ ╚═╝ ██║██║     ╚████╔╝ ██║███████║\",\n        \"<center>╚═╝  ╚═╝╚═╝╚═╝     ╚═╝╚═╝      ╚═══╝  ╚═╝╚══════╝\",\n        \"\",\n        \"<center>AGENT TRACING VISUALIZER (Technical Preview)\",\n        \"\",\n        \"<hr>\",\n        \"\",\n        f\"<nowrap>  ➜  Local    {url}\",\n        \"\",\n        \"<hr>\",\n        \"\",\n        \"<nowrap>  This feature is in Technical Preview and may be unstable.\",\n        \"<nowrap>  Please report issues to the kimi-cli team.\",\n        \"\",\n    ]\n\n    _print_banner(banner_lines)\n\n    if open_browser:\n\n        def open_browser_after_delay() -> None:\n            import time\n\n            time.sleep(1.5)\n            webbrowser.open(url)\n\n        thread = threading.Thread(target=open_browser_after_delay, daemon=True)\n        thread.start()\n\n    uvicorn.run(\n        \"kimi_cli.vis.app:create_app\",\n        factory=True,\n        host=host,\n        port=actual_port,\n        reload=reload,\n        log_level=\"info\",\n        timeout_graceful_shutdown=3,\n    )\n\n\n__all__ = [\"create_app\", \"find_available_port\", \"run_vis_server\"]\n"
  },
  {
    "path": "src/kimi_cli/web/__init__.py",
    "content": "\"\"\"Kimi Code CLI Web Interface.\"\"\"\n\nfrom kimi_cli.web.app import create_app, run_web_server\n\n__all__ = [\"create_app\", \"run_web_server\"]\n"
  },
  {
    "path": "src/kimi_cli/web/api/__init__.py",
    "content": "\"\"\"API routes.\"\"\"\n\nfrom kimi_cli.web.api import config, open_in, sessions\n\nconfig_router = config.router\nsessions_router = sessions.router\nwork_dirs_router = sessions.work_dirs_router\nopen_in_router = open_in.router\n\n__all__ = [\n    \"config_router\",\n    \"open_in_router\",\n    \"sessions_router\",\n    \"work_dirs_router\",\n]\n"
  },
  {
    "path": "src/kimi_cli/web/api/config.py",
    "content": "\"\"\"Config API routes.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Depends, HTTPException, Request, status\nfrom pydantic import BaseModel, Field\n\nfrom kimi_cli import logger\nfrom kimi_cli.config import LLMModel, get_config_file, load_config, save_config\nfrom kimi_cli.llm import ProviderType, derive_model_capabilities\nfrom kimi_cli.web.runner.process import KimiCLIRunner\n\nrouter = APIRouter(prefix=\"/api/config\", tags=[\"config\"])\n\n\nclass ConfigModel(LLMModel):\n    \"\"\"Model configuration for frontend.\"\"\"\n\n    name: str = Field(description=\"Model key in kimi-cli config (Config.models)\")\n    provider_type: ProviderType = Field(description=\"Provider type (LLMProvider.type)\")\n\n\nclass GlobalConfig(BaseModel):\n    \"\"\"Global configuration snapshot for frontend.\"\"\"\n\n    default_model: str = Field(description=\"Current default model key\")\n    default_thinking: bool = Field(description=\"Current default thinking mode\")\n    models: list[ConfigModel] = Field(description=\"All configured models\")\n\n\nclass UpdateGlobalConfigRequest(BaseModel):\n    \"\"\"Request to update global config.\"\"\"\n\n    default_model: str | None = Field(default=None, description=\"New default model key\")\n    default_thinking: bool | None = Field(default=None, description=\"New default thinking mode\")\n    restart_running_sessions: bool | None = Field(\n        default=None, description=\"Whether to restart running sessions\"\n    )\n    force_restart_busy_sessions: bool | None = Field(\n        default=None, description=\"Whether to force restart busy sessions\"\n    )\n\n\nclass UpdateGlobalConfigResponse(BaseModel):\n    \"\"\"Response after updating global config.\"\"\"\n\n    config: GlobalConfig = Field(description=\"Updated config snapshot\")\n    restarted_session_ids: list[str] | None = Field(\n        default=None, description=\"IDs of restarted sessions\"\n    )\n    skipped_busy_session_ids: list[str] | None = Field(\n        default=None, description=\"IDs of busy sessions that were skipped\"\n    )\n\n\nclass ConfigToml(BaseModel):\n    \"\"\"Raw config.toml content.\"\"\"\n\n    content: str = Field(description=\"Raw TOML content\")\n    path: str = Field(description=\"Path to config file\")\n\n\nclass UpdateConfigTomlRequest(BaseModel):\n    \"\"\"Request to update config.toml.\"\"\"\n\n    content: str = Field(description=\"New TOML content\")\n\n\nclass UpdateConfigTomlResponse(BaseModel):\n    \"\"\"Response after updating config.toml.\"\"\"\n\n    success: bool = Field(description=\"Whether the update was successful\")\n    error: str | None = Field(default=None, description=\"Error message if failed\")\n\n\ndef _build_global_config() -> GlobalConfig:\n    \"\"\"Build GlobalConfig from kimi-cli config.\"\"\"\n    config = load_config()\n\n    models: list[ConfigModel] = []\n    for model_name, model in config.models.items():\n        provider = config.providers.get(model.provider)\n        if provider is None:\n            continue\n\n        # Derive capabilities\n        derived_caps = derive_model_capabilities(model)\n        capabilities = derived_caps or None\n\n        models.append(\n            ConfigModel(\n                name=model_name,\n                model=model.model,\n                provider=model.provider,\n                provider_type=provider.type,\n                max_context_size=model.max_context_size,\n                capabilities=capabilities,\n            )\n        )\n\n    return GlobalConfig(\n        default_model=config.default_model,\n        default_thinking=config.default_thinking,\n        models=models,\n    )\n\n\ndef _get_runner(req: Request) -> KimiCLIRunner:\n    \"\"\"Get KimiCLIRunner from FastAPI app state.\"\"\"\n    return req.app.state.runner\n\n\ndef _ensure_sensitive_apis_allowed(request: Request) -> None:\n    \"\"\"Block sensitive config writes when restricted.\"\"\"\n    if getattr(request.app.state, \"restrict_sensitive_apis\", False):\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"Sensitive config APIs are disabled in this mode.\",\n        )\n\n\n@router.get(\"/\", summary=\"Get global (kimi-cli) config snapshot\")\nasync def get_global_config() -> GlobalConfig:\n    \"\"\"Get global (kimi-cli) config snapshot.\"\"\"\n    return _build_global_config()\n\n\n@router.patch(\"/\", summary=\"Update global (kimi-cli) default model/thinking\")\nasync def update_global_config(\n    request: UpdateGlobalConfigRequest,\n    http_request: Request,\n    runner: KimiCLIRunner = Depends(_get_runner),\n) -> UpdateGlobalConfigResponse:\n    \"\"\"Update global (kimi-cli) default model/thinking.\"\"\"\n    _ensure_sensitive_apis_allowed(http_request)\n    config = load_config()\n\n    # Validate and update default_model\n    if request.default_model is not None:\n        if request.default_model not in config.models:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=f\"Model '{request.default_model}' not found in config\",\n            )\n        config.default_model = request.default_model\n\n    # Update default_thinking\n    if request.default_thinking is not None:\n        config.default_thinking = request.default_thinking\n\n    # Save config\n    save_config(config)\n\n    # Restart running workers to apply config changes\n    restarted: list[str] = []\n    skipped_busy: list[str] = []\n\n    restart_running = request.restart_running_sessions\n    if restart_running is None:\n        restart_running = True  # Default to restarting sessions\n\n    if restart_running:\n        summary = await runner.restart_running_workers(\n            reason=\"config_update\",\n            force=request.force_restart_busy_sessions or False,\n        )\n        restarted = [str(sid) for sid in summary.restarted_session_ids]\n        skipped_busy = [str(sid) for sid in summary.skipped_busy_session_ids]\n\n    return UpdateGlobalConfigResponse(\n        config=_build_global_config(),\n        restarted_session_ids=restarted if restarted else None,\n        skipped_busy_session_ids=skipped_busy if skipped_busy else None,\n    )\n\n\n@router.get(\"/toml\", summary=\"Get kimi-cli config.toml\")\nasync def get_config_toml(http_request: Request) -> ConfigToml:\n    \"\"\"Get kimi-cli config.toml.\"\"\"\n    _ensure_sensitive_apis_allowed(http_request)\n    config_file = get_config_file()\n    if not config_file.exists():\n        return ConfigToml(content=\"\", path=str(config_file))\n    return ConfigToml(content=config_file.read_text(encoding=\"utf-8\"), path=str(config_file))\n\n\n@router.put(\"/toml\", summary=\"Update kimi-cli config.toml\")\nasync def update_config_toml(\n    request: UpdateConfigTomlRequest,\n    http_request: Request,\n) -> UpdateConfigTomlResponse:\n    \"\"\"Update kimi-cli config.toml.\"\"\"\n    from kimi_cli.config import load_config_from_string\n\n    _ensure_sensitive_apis_allowed(http_request)\n    try:\n        # Validate the config first\n        load_config_from_string(request.content)\n\n        # Write to file\n        config_file = get_config_file()\n        config_file.parent.mkdir(parents=True, exist_ok=True)\n        config_file.write_text(request.content, encoding=\"utf-8\")\n\n        return UpdateConfigTomlResponse(success=True)\n    except Exception as e:\n        logger.warning(f\"Failed to update config.toml: {e}\")\n        return UpdateConfigTomlResponse(success=False, error=str(e))\n"
  },
  {
    "path": "src/kimi_cli/web/api/open_in.py",
    "content": "\"\"\"Open local apps for a path on the host machine.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport subprocess\nimport sys\nfrom pathlib import Path\nfrom typing import Literal\n\nfrom fastapi import APIRouter, HTTPException, status\nfrom pydantic import BaseModel\n\nfrom kimi_cli import logger\n\nrouter = APIRouter(prefix=\"/api/open-in\", tags=[\"open-in\"])\n\n\nclass OpenInRequest(BaseModel):\n    \"\"\"Open path in a local app.\"\"\"\n\n    app: Literal[\"finder\", \"cursor\", \"vscode\", \"iterm\", \"terminal\", \"antigravity\"]\n    path: str\n\n\nclass OpenInResponse(BaseModel):\n    \"\"\"Open path response.\"\"\"\n\n    ok: bool\n    detail: str | None = None\n\n\ndef _resolve_path(path: str) -> Path:\n    \"\"\"Resolve and validate a path (file or directory).\"\"\"\n    resolved = Path(path).expanduser()\n    try:\n        resolved = resolved.resolve()\n    except FileNotFoundError:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=f\"Path does not exist: {path}\",\n        ) from None\n\n    if not resolved.exists():\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=f\"Path does not exist: {path}\",\n        )\n    return resolved\n\n\ndef _run_command(args: list[str]) -> None:\n    subprocess.run(\n        args,\n        check=True,\n        capture_output=True,\n        text=True,\n    )\n\n\ndef _spawn_process(args: list[str]) -> None:\n    subprocess.Popen(args, close_fds=True)\n\n\ndef _open_app(app_name: str, path: Path, fallback: str | None = None) -> None:\n    try:\n        _run_command([\"open\", \"-a\", app_name, str(path)])\n        return\n    except subprocess.CalledProcessError as exc:\n        if fallback is None:\n            raise\n        logger.warning(\"Open with {} failed: {}\", app_name, exc)\n    _run_command([\"open\", \"-a\", fallback, str(path)])\n\n\ndef _open_terminal(path: Path) -> None:\n    script = f'tell application \"Terminal\" to do script \"cd \" & quoted form of \"{path}\"'\n    _run_command([\"osascript\", \"-e\", script])\n\n\ndef _open_iterm(path: Path) -> None:\n    script = \"\\n\".join(\n        [\n            'tell application \"iTerm\"',\n            \"  create window with default profile\",\n            \"  tell current session of current window\",\n            f'    write text \"cd \" & quoted form of \"{path}\"',\n            \"  end tell\",\n            \"end tell\",\n        ]\n    )\n    try:\n        _run_command([\"osascript\", \"-e\", script])\n    except subprocess.CalledProcessError:\n        script = script.replace('\"iTerm\"', '\"iTerm2\"')\n        _run_command([\"osascript\", \"-e\", script])\n\n\ndef _open_windows_app(command: str, path: Path) -> None:\n    _run_command([\"cmd\", \"/c\", \"start\", \"\", command, str(path)])\n\n\ndef _open_windows_explorer(path: Path, *, is_file: bool) -> None:\n    if is_file:\n        _spawn_process([\"explorer\", f\"/select,{path}\"])\n    else:\n        _spawn_process([\"explorer\", str(path)])\n\n\ndef _open_windows_terminal(path: Path) -> None:\n    try:\n        _run_command([\"cmd\", \"/c\", \"start\", \"\", \"wt.exe\", \"-d\", str(path)])\n    except subprocess.CalledProcessError as exc:\n        logger.warning(\"Open with Windows Terminal failed: {}\", exc)\n        _run_command([\"cmd\", \"/c\", \"start\", \"\", \"cmd.exe\", \"/K\", f'cd /d \"{path}\"'])\n\n\ndef _open_in_macos(app: OpenInRequest, path: Path, *, is_file: bool) -> None:\n    match app.app:\n        case \"finder\":\n            if is_file:\n                # Reveal file in Finder\n                _run_command([\"open\", \"-R\", str(path)])\n            else:\n                _run_command([\"open\", str(path)])\n        case \"cursor\":\n            _open_app(\"Cursor\", path)\n        case \"vscode\":\n            _open_app(\"Visual Studio Code\", path, fallback=\"Code\")\n        case \"antigravity\":\n            _open_app(\"Antigravity\", path)\n        case \"iterm\":\n            # Terminal apps need directory\n            directory = path.parent if is_file else path\n            _open_iterm(directory)\n        case \"terminal\":\n            directory = path.parent if is_file else path\n            _open_terminal(directory)\n        case _:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=f\"Unsupported app: {app.app}\",\n            )\n\n\ndef _open_in_windows(app: OpenInRequest, path: Path, *, is_file: bool) -> None:\n    match app.app:\n        case \"finder\":\n            _open_windows_explorer(path, is_file=is_file)\n        case \"cursor\":\n            _open_windows_app(\"cursor\", path)\n        case \"vscode\":\n            _open_windows_app(\"code\", path)\n        case \"terminal\":\n            directory = path.parent if is_file else path\n            _open_windows_terminal(directory)\n        case \"iterm\" | \"antigravity\":\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=f\"{app.app} is not supported on Windows.\",\n            )\n        case _:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=f\"Unsupported app: {app.app}\",\n            )\n\n\ndef _open_in_sync(request: OpenInRequest, path: Path, *, is_file: bool) -> None:\n    if sys.platform == \"darwin\":\n        _open_in_macos(request, path, is_file=is_file)\n    else:\n        _open_in_windows(request, path, is_file=is_file)\n\n\n@router.post(\"\", summary=\"Open a path in a local application\")\nasync def open_in(request: OpenInRequest) -> OpenInResponse:\n    if sys.platform not in {\"darwin\", \"win32\"}:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Open-in is only supported on macOS and Windows.\",\n        )\n\n    path = _resolve_path(request.path)\n    is_file = path.is_file()\n\n    try:\n        await asyncio.to_thread(_open_in_sync, request, path, is_file=is_file)\n    except subprocess.CalledProcessError as exc:\n        logger.warning(\"Open-in failed ({}): {}\", request.app, exc)\n        detail = exc.stderr.strip() if exc.stderr else \"Failed to open application.\"\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=detail,\n        ) from exc\n\n    return OpenInResponse(ok=True)\n"
  },
  {
    "path": "src/kimi_cli/web/api/sessions.py",
    "content": "\"\"\"Sessions API routes.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport mimetypes\nimport os\nimport re\nimport shutil\nimport time\nfrom datetime import UTC, datetime\nfrom pathlib import Path\nfrom typing import Any, cast\nfrom urllib.parse import quote\nfrom uuid import UUID, uuid4\n\nfrom fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status\nfrom fastapi.responses import FileResponse, Response\nfrom kaos.path import KaosPath\nfrom pydantic import BaseModel, Field\nfrom starlette.websockets import WebSocket, WebSocketDisconnect\n\nfrom kimi_cli import logger\nfrom kimi_cli.metadata import load_metadata, save_metadata\nfrom kimi_cli.session import Session as KimiCLISession\nfrom kimi_cli.utils.subprocess_env import get_clean_env\nfrom kimi_cli.web.auth import is_origin_allowed, is_private_ip, verify_token\nfrom kimi_cli.web.models import (\n    GenerateTitleRequest,\n    GenerateTitleResponse,\n    GitDiffStats,\n    GitFileDiff,\n    Session,\n    SessionStatus,\n    UpdateSessionRequest,\n)\nfrom kimi_cli.web.runner.messages import new_session_status_message, send_history_complete\nfrom kimi_cli.web.runner.process import KimiCLIRunner\nfrom kimi_cli.web.store.sessions import (\n    JointSession,\n    SessionMetadata,\n    invalidate_sessions_cache,\n    load_session_by_id,\n    load_session_metadata,\n    load_sessions_page,\n    run_auto_archive,\n    save_session_metadata,\n)\nfrom kimi_cli.wire.jsonrpc import (\n    ErrorCodes,\n    JSONRPCErrorObject,\n    JSONRPCErrorResponse,\n    JSONRPCInMessageAdapter,\n    JSONRPCPromptMessage,\n)\nfrom kimi_cli.wire.serde import deserialize_wire_message\nfrom kimi_cli.wire.types import is_request\n\nrouter = APIRouter(prefix=\"/api/sessions\", tags=[\"sessions\"])\nwork_dirs_router = APIRouter(prefix=\"/api/work-dirs\", tags=[\"work-dirs\"])\n\n# Constants\nMAX_UPLOAD_SIZE = 100 * 1024 * 1024  # 100MB\nDEFAULT_MAX_PUBLIC_PATH_DEPTH = 6\nSENSITIVE_PATH_PARTS = {\n    \"id_rsa\",\n    \"id_ed25519\",\n    \"known_hosts\",\n    \"credentials\",\n    \".aws\",\n    \".ssh\",\n    \".gnupg\",\n    \".kube\",\n    \".npmrc\",\n    \".pypirc\",\n    \".netrc\",\n}\nSENSITIVE_PATH_EXTENSIONS = {\n    \".pem\",\n    \".key\",\n    \".p12\",\n    \".pfx\",\n    \".kdbx\",\n    \".der\",\n}\n# Home directory patterns to detect if resolved path escapes to sensitive locations\nSENSITIVE_HOME_PATHS = {\n    \".ssh\",\n    \".gnupg\",\n    \".aws\",\n    \".kube\",\n}\nCHECKPOINT_USER_PATTERN = re.compile(r\"^<system>CHECKPOINT \\d+</system>$\")\n\n\ndef sanitize_filename(filename: str) -> str:\n    \"\"\"Remove potentially dangerous characters from filename.\"\"\"\n    # Keep only alphanumeric, dots, underscores, hyphens, and spaces\n    safe = \"\".join(c for c in filename if c.isalnum() or c in \"._- \")\n    return safe.strip() or \"unnamed\"\n\n\ndef get_runner(req: Request) -> KimiCLIRunner:\n    \"\"\"Get the KimiCLIRunner from the FastAPI app state.\"\"\"\n    return req.app.state.runner\n\n\ndef get_runner_ws(ws: WebSocket) -> KimiCLIRunner:\n    \"\"\"Get the KimiCLIRunner from the FastAPI app state (for WebSocket routes).\"\"\"\n    return ws.app.state.runner\n\n\ndef get_editable_session(\n    session_id: UUID,\n    runner: KimiCLIRunner,\n) -> JointSession:\n    \"\"\"Get a session and verify it's not busy.\"\"\"\n    session = load_session_by_id(session_id)\n    if session is None:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Session not found\",\n        )\n    # Check if session is busy\n    session_process = runner.get_session(session_id)\n    if session_process and session_process.is_busy:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Session is busy. Please wait for it to complete before modifying.\",\n        )\n    return session\n\n\ndef _relative_parts(path: Path) -> list[str]:\n    return [part for part in path.parts if part not in {\"\", \".\"}]\n\n\ndef _is_sensitive_relative_path(rel_path: Path) -> bool:\n    parts = _relative_parts(rel_path)\n    for part in parts:\n        if part.startswith(\".\"):\n            return True\n        if part.lower() in SENSITIVE_PATH_PARTS:\n            return True\n    return rel_path.suffix.lower() in SENSITIVE_PATH_EXTENSIONS\n\n\ndef _contains_symlink(path: Path, base: Path) -> bool:\n    \"\"\"Check if any component of the path (relative to base) is a symlink.\"\"\"\n    try:\n        current = base\n        rel_parts = path.relative_to(base).parts\n        for part in rel_parts:\n            current = current / part\n            if current.is_symlink():\n                return True\n    except (ValueError, OSError):\n        return True\n    return False\n\n\ndef _is_path_in_sensitive_location(path: Path) -> bool:\n    \"\"\"Check if resolved path points to a sensitive location (e.g., ~/.ssh, ~/.aws).\"\"\"\n    try:\n        home = Path.home()\n        if path.is_relative_to(home):\n            rel_to_home = path.relative_to(home)\n            first_part = rel_to_home.parts[0] if rel_to_home.parts else \"\"\n            if first_part in SENSITIVE_HOME_PATHS:\n                return True\n    except (ValueError, RuntimeError):\n        pass\n    return False\n\n\ndef _ensure_public_file_access_allowed(\n    rel_path: Path,\n    restrict_sensitive_apis: bool,\n    max_path_depth: int = DEFAULT_MAX_PUBLIC_PATH_DEPTH,\n) -> None:\n    if not restrict_sensitive_apis:\n        return\n    rel_parts = _relative_parts(rel_path)\n    if len(rel_parts) > max_path_depth:\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=f\"Path too deep for public access \"\n            f\"(max depth: {max_path_depth}, current: {len(rel_parts)}).\",\n        )\n    if _is_sensitive_relative_path(rel_path):\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"Access to sensitive files is disabled.\",\n        )\n\n\ndef _read_wire_lines(wire_file: Path) -> list[str]:\n    \"\"\"Read and parse wire.jsonl into JSONRPC event strings (runs in thread).\"\"\"\n    result: list[str] = []\n    with open(wire_file, encoding=\"utf-8\") as f:\n        for line in f:\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                record = json.loads(line)\n                if not isinstance(record, dict):\n                    continue\n                record = cast(dict[str, Any], record)\n                record_type = record.get(\"type\")\n                if isinstance(record_type, str) and record_type == \"metadata\":\n                    continue\n                message_raw = record.get(\"message\")\n                if not isinstance(message_raw, dict):\n                    continue\n                message_raw = cast(dict[str, Any], message_raw)\n                message = deserialize_wire_message(message_raw)\n                _is_req = is_request(message)\n                event_msg: dict[str, Any] = {\n                    \"jsonrpc\": \"2.0\",\n                    \"method\": \"request\" if _is_req else \"event\",\n                    \"params\": message_raw,\n                }\n                if _is_req:\n                    # JSON-RPC requests require a top-level ``id`` so the\n                    # client can correlate its response.  Use the request's\n                    # own ``id`` field (e.g. ApprovalRequest.id,\n                    # QuestionRequest.id).  Note: ``message_raw`` wraps data\n                    # as ``{\"type\": ..., \"payload\": {...}}`` so the id lives\n                    # on the deserialized object, not at the raw dict top level.\n                    event_msg[\"id\"] = message.id\n                result.append(json.dumps(event_msg, ensure_ascii=False))\n            except (json.JSONDecodeError, KeyError, ValueError, TypeError):\n                continue\n    return result\n\n\nasync def replay_history(ws: WebSocket, session_dir: Path) -> None:\n    \"\"\"Replay historical wire messages from wire.jsonl to a WebSocket.\"\"\"\n    wire_file = session_dir / \"wire.jsonl\"\n    if not await asyncio.to_thread(wire_file.exists):\n        return\n\n    try:\n        lines = await asyncio.to_thread(_read_wire_lines, wire_file)\n        for event_text in lines:\n            await ws.send_text(event_text)\n    except Exception:\n        pass\n\n\n@router.get(\"/\", summary=\"List all sessions\")\nasync def list_sessions(\n    runner: KimiCLIRunner = Depends(get_runner),\n    limit: int = 100,\n    offset: int = 0,\n    q: str | None = None,\n    archived: bool | None = None,\n) -> list[Session]:\n    \"\"\"List sessions with optional pagination and search.\n\n    Args:\n        limit: Maximum number of sessions to return (default 100, max 500).\n        offset: Number of sessions to skip (default 0).\n        q: Optional search query to filter by title or work_dir.\n        archived: Filter by archived status.\n            - None (default): Only return non-archived sessions.\n            - True: Only return archived sessions.\n    \"\"\"\n    if limit <= 0:\n        limit = 100\n    if limit > 500:\n        limit = 500\n    if offset < 0:\n        offset = 0\n\n    # Run auto-archive in background (throttled internally, runs at most once per 5 minutes)\n    await asyncio.to_thread(run_auto_archive)\n\n    sessions = load_sessions_page(limit=limit, offset=offset, query=q, archived=archived)\n    for session in sessions:\n        session_process = runner.get_session(session.session_id)\n        session.is_running = session_process is not None and session_process.is_running\n        session.status = session_process.status if session_process else None\n    return cast(list[Session], sessions)\n\n\n@router.get(\"/{session_id}\", summary=\"Get session\")\nasync def get_session(\n    session_id: UUID,\n    runner: KimiCLIRunner = Depends(get_runner),\n) -> Session | None:\n    \"\"\"Get a session by ID.\"\"\"\n    session = load_session_by_id(session_id)\n    if session is not None:\n        session_process = runner.get_session(session_id)\n        session.is_running = session_process is not None and session_process.is_running\n        session.status = session_process.status if session_process else None\n    return session\n\n\n@router.post(\"/\", summary=\"Create a new session\")\nasync def create_session(request: CreateSessionRequest | None = None) -> Session:\n    \"\"\"Create a new session.\"\"\"\n    # Use provided work_dir or default to user's home directory\n    if request and request.work_dir:\n        work_dir_path = Path(request.work_dir).expanduser().resolve()\n        # Validate the directory exists\n        if not work_dir_path.exists():\n            if request.create_dir:\n                # Auto-create the directory\n                try:\n                    work_dir_path.mkdir(parents=True, exist_ok=True)\n                except PermissionError as e:\n                    raise HTTPException(\n                        status_code=status.HTTP_403_FORBIDDEN,\n                        detail=f\"Permission denied: cannot create directory {request.work_dir}\",\n                    ) from e\n                except OSError as e:\n                    raise HTTPException(\n                        status_code=status.HTTP_400_BAD_REQUEST,\n                        detail=f\"Failed to create directory: {e}\",\n                    ) from e\n            else:\n                # Return 404 to indicate directory does not exist\n                raise HTTPException(\n                    status_code=status.HTTP_404_NOT_FOUND,\n                    detail=f\"Directory does not exist: {request.work_dir}\",\n                )\n        if not work_dir_path.is_dir():\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=f\"Path is not a directory: {request.work_dir}\",\n            )\n        work_dir = KaosPath.unsafe_from_local_path(work_dir_path)\n    else:\n        work_dir = KaosPath.unsafe_from_local_path(Path.home())\n    kimi_cli_session = await KimiCLISession.create(work_dir=work_dir)\n    context_file = kimi_cli_session.dir / \"context.jsonl\"\n    invalidate_sessions_cache()\n    invalidate_work_dirs_cache()\n    return Session(\n        session_id=UUID(kimi_cli_session.id),\n        title=kimi_cli_session.title,\n        last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),\n        is_running=False,\n        status=SessionStatus(\n            session_id=UUID(kimi_cli_session.id),\n            state=\"stopped\",\n            seq=0,\n            worker_id=None,\n            reason=None,\n            detail=None,\n            updated_at=datetime.now(UTC),\n        ),\n        work_dir=str(work_dir),\n        session_dir=str(kimi_cli_session.dir),\n    )\n\n\nclass CreateSessionRequest(BaseModel):\n    \"\"\"Create session request.\"\"\"\n\n    work_dir: str | None = None\n    create_dir: bool = False  # Whether to auto-create directory if it doesn't exist\n\n\nclass ForkSessionRequest(BaseModel):\n    \"\"\"Fork session request.\"\"\"\n\n    turn_index: int = Field(..., ge=0)  # 0-based, fork includes this turn and all previous turns\n\n\nclass UploadSessionFileResponse(BaseModel):\n    \"\"\"Upload file response.\"\"\"\n\n    path: str\n    filename: str\n    size: int\n\n\n@router.post(\"/{session_id}/files\", summary=\"Upload file to session\")\nasync def upload_session_file(\n    session_id: UUID,\n    file: UploadFile,\n    runner: KimiCLIRunner = Depends(get_runner),\n) -> UploadSessionFileResponse:\n    \"\"\"Upload a file to a session.\"\"\"\n    session = get_editable_session(session_id, runner)\n    session_dir = session.kimi_cli_session.dir\n    upload_dir = session_dir / \"uploads\"\n    upload_dir.mkdir(parents=True, exist_ok=True)\n\n    # Read and validate file size\n    content = await file.read()\n    if len(content) > MAX_UPLOAD_SIZE:\n        raise HTTPException(\n            status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,\n            detail=f\"File too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)\",\n        )\n\n    # Generate safe filename\n    file_name = str(uuid4())\n    if file.filename:\n        safe_name = sanitize_filename(file.filename)\n        name, ext = os.path.splitext(safe_name)\n        file_name = f\"{name}_{file_name[:6]}{ext}\"\n\n    upload_path = upload_dir / file_name\n    upload_path.write_bytes(content)\n\n    return UploadSessionFileResponse(\n        path=str(upload_path),\n        filename=file_name,\n        size=len(content),\n    )\n\n\n@router.get(\n    \"/{session_id}/uploads/{path:path}\",\n    summary=\"Get uploaded file from session uploads\",\n)\nasync def get_session_upload_file(\n    session_id: UUID,\n    path: str,\n) -> Response:\n    \"\"\"Get a file from a session's uploads directory.\"\"\"\n    session = load_session_by_id(session_id)\n    if session is None:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Session not found\",\n        )\n\n    uploads_dir = (session.kimi_cli_session.dir / \"uploads\").resolve()\n    if not uploads_dir.exists():\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Uploads directory not found\",\n        )\n\n    file_path = (uploads_dir / path).resolve()\n    if not file_path.is_relative_to(uploads_dir):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Invalid path: path traversal not allowed\",\n        )\n\n    if not file_path.exists() or not file_path.is_file():\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"File not found\",\n        )\n\n    media_type, _ = mimetypes.guess_type(file_path.name)\n    encoded_filename = quote(file_path.name, safe=\"\")\n    return FileResponse(\n        file_path,\n        media_type=media_type or \"application/octet-stream\",\n        headers={\n            \"Content-Disposition\": f\"inline; filename*=UTF-8''{encoded_filename}\",\n        },\n    )\n\n\n@router.get(\n    \"/{session_id}/files/{path:path}\",\n    summary=\"Get file or list directory from session work_dir\",\n)\nasync def get_session_file(\n    session_id: UUID,\n    path: str,\n    request: Request,\n) -> Response:\n    \"\"\"Get a file or list directory from session work directory.\"\"\"\n    session = load_session_by_id(session_id)\n    if session is None:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Session not found\",\n        )\n\n    # Security check: prevent path traversal attacks using resolve()\n    work_dir = Path(str(session.kimi_cli_session.work_dir)).resolve()\n    requested_path = work_dir / path\n    file_path = requested_path.resolve()\n\n    # Check path traversal\n    if not file_path.is_relative_to(work_dir):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Invalid path: path traversal not allowed\",\n        )\n\n    rel_path = file_path.relative_to(work_dir)\n    restrict_sensitive_apis = getattr(request.app.state, \"restrict_sensitive_apis\", False)\n    max_path_depth = (\n        getattr(request.app.state, \"max_public_path_depth\", None) or DEFAULT_MAX_PUBLIC_PATH_DEPTH\n    )\n\n    # Additional security checks when restricting sensitive APIs\n    if restrict_sensitive_apis:\n        # Check for symlinks in the path\n        if _contains_symlink(requested_path, work_dir):\n            raise HTTPException(\n                status_code=status.HTTP_403_FORBIDDEN,\n                detail=\"Symbolic links are not allowed in public mode.\",\n            )\n\n        # Check if resolved path points to sensitive location\n        if _is_path_in_sensitive_location(file_path):\n            raise HTTPException(\n                status_code=status.HTTP_403_FORBIDDEN,\n                detail=\"Access to sensitive system directories is not allowed.\",\n            )\n\n    _ensure_public_file_access_allowed(rel_path, restrict_sensitive_apis, max_path_depth)\n\n    if not file_path.exists():\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"File not found\",\n        )\n\n    if file_path.is_dir():\n        result: list[dict[str, str | int]] = []\n        for subpath in file_path.iterdir():\n            if restrict_sensitive_apis:\n                rel_subpath = rel_path / subpath.name\n                if _is_sensitive_relative_path(rel_subpath):\n                    continue\n            if subpath.is_dir():\n                result.append({\"name\": subpath.name, \"type\": \"directory\"})\n            else:\n                result.append(\n                    {\n                        \"name\": subpath.name,\n                        \"type\": \"file\",\n                        \"size\": subpath.stat().st_size,\n                    }\n                )\n        result.sort(key=lambda x: (cast(str, x[\"type\"]), cast(str, x[\"name\"])))\n        return Response(content=json.dumps(result), media_type=\"application/json\")\n\n    content = file_path.read_bytes()\n    media_type, _ = mimetypes.guess_type(file_path.name)\n    encoded_filename = quote(file_path.name, safe=\"\")\n    return Response(\n        content=content,\n        media_type=media_type or \"application/octet-stream\",\n        headers={\"Content-Disposition\": f\"attachment; filename*=UTF-8''{encoded_filename}\"},\n    )\n\n\ndef _update_last_session_id(session: JointSession) -> None:\n    \"\"\"Update last_session_id for the session's work directory.\"\"\"\n    kimi_session = session.kimi_cli_session\n    work_dir = kimi_session.work_dir\n\n    metadata = load_metadata()\n    work_dir_meta = metadata.get_work_dir_meta(work_dir)\n\n    if work_dir_meta is None:\n        work_dir_meta = metadata.new_work_dir_meta(work_dir)\n\n    work_dir_meta.last_session_id = kimi_session.id\n    save_metadata(metadata)\n\n\n@router.delete(\"/{session_id}\", summary=\"Delete a session\")\nasync def delete_session(session_id: UUID, runner: KimiCLIRunner = Depends(get_runner)) -> None:\n    \"\"\"Delete a session.\"\"\"\n    session = get_editable_session(session_id, runner)\n    session_process = runner.get_session(session_id)\n    if session_process is not None:\n        await session_process.stop()\n    wd_meta = session.kimi_cli_session.work_dir_meta\n    if wd_meta.last_session_id == str(session_id):\n        metadata = load_metadata()\n        for wd in metadata.work_dirs:\n            if wd.path == wd_meta.path:\n                wd.last_session_id = None\n                break\n        save_metadata(metadata)\n    session_dir = session.kimi_cli_session.dir\n    if session_dir.exists():\n        shutil.rmtree(session_dir)\n    invalidate_sessions_cache()\n\n\n@router.patch(\"/{session_id}\", summary=\"Update session\")\nasync def update_session(\n    session_id: UUID,\n    request: UpdateSessionRequest,\n    runner: KimiCLIRunner = Depends(get_runner),\n) -> Session:\n    \"\"\"Update a session (e.g., rename title or archive/unarchive).\"\"\"\n    session = get_editable_session(session_id, runner)\n    session_dir = session.kimi_cli_session.dir\n\n    # Load existing metadata\n    metadata = load_session_metadata(session_dir, str(session_id))\n\n    # Update title if provided\n    if request.title is not None:\n        metadata = metadata.model_copy(update={\"title\": request.title})\n\n    # Update archived status if provided\n    if request.archived is not None:\n        updates: dict[str, bool | float | None] = {\"archived\": request.archived}\n        if request.archived:\n            # User manually archived: set archived_at, reset auto_archive_exempt\n            updates[\"archived_at\"] = time.time()\n            updates[\"auto_archive_exempt\"] = False\n        else:\n            # User manually unarchived: clear archived_at, set auto_archive_exempt\n            # This prevents the session from being auto-archived again\n            updates[\"archived_at\"] = None\n            updates[\"auto_archive_exempt\"] = True\n        metadata = metadata.model_copy(update=updates)\n\n    # Save metadata\n    save_session_metadata(session_dir, metadata)\n\n    # Invalidate cache to force reload\n    invalidate_sessions_cache()\n\n    # Return updated session\n    updated_session = load_session_by_id(session_id)\n    if updated_session is None:\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Failed to reload session after update\",\n        )\n    return updated_session\n\n\ndef extract_first_turn_from_wire(session_dir: Path) -> tuple[str, str] | None:\n    \"\"\"Extract the first turn's user message and assistant response from wire.jsonl.\n\n    Returns:\n        tuple[str, str] | None: (user_message, assistant_response) or None if not found\n    \"\"\"\n    wire_file = session_dir / \"wire.jsonl\"\n    if not wire_file.exists():\n        return None\n\n    user_message: str | None = None\n    assistant_response_parts: list[str] = []\n    in_first_turn = False\n\n    try:\n        with open(wire_file, encoding=\"utf-8\") as f:\n            for line in f:\n                line = line.strip()\n                if not line:\n                    continue\n                try:\n                    record = json.loads(line)\n                    message = record.get(\"message\", {})\n                    msg_type = message.get(\"type\")\n\n                    if msg_type == \"TurnBegin\":\n                        if in_first_turn:\n                            # Second turn started, stop\n                            break\n                        in_first_turn = True\n                        user_input = message.get(\"payload\", {}).get(\"user_input\")\n                        if user_input:\n                            from kosong.message import Message\n\n                            msg = Message(role=\"user\", content=user_input)\n                            user_message = msg.extract_text(\" \")\n\n                    elif msg_type == \"ContentPart\" and in_first_turn:\n                        payload = message.get(\"payload\", {})\n                        if payload.get(\"type\") == \"text\" and payload.get(\"text\"):\n                            assistant_response_parts.append(payload[\"text\"])\n\n                    elif msg_type == \"TurnEnd\" and in_first_turn:\n                        break\n\n                except json.JSONDecodeError:\n                    continue\n    except OSError:\n        return None\n\n    if user_message and assistant_response_parts:\n        return (user_message, \"\".join(assistant_response_parts))\n    return None\n\n\ndef truncate_wire_at_turn(wire_path: Path, turn_index: int) -> list[str]:\n    \"\"\"Read wire.jsonl and return all lines up to and including the given turn.\n\n    Args:\n        wire_path: Path to the wire.jsonl file\n        turn_index: 0-based turn index. Returns turns 0..turn_index inclusive.\n\n    Returns:\n        List of raw JSON lines (including the metadata header)\n\n    Raises:\n        ValueError: If turn_index is out of range\n    \"\"\"\n    if not wire_path.exists():\n        raise ValueError(\"wire.jsonl not found\")\n\n    lines: list[str] = []\n    current_turn = -1  # Will become 0 on first TurnBegin\n\n    with open(wire_path, encoding=\"utf-8\") as f:\n        for line in f:\n            stripped = line.strip()\n            if not stripped:\n                continue\n\n            try:\n                record: dict[str, Any] = json.loads(stripped)\n            except json.JSONDecodeError:\n                continue\n\n            # Always keep metadata header\n            if record.get(\"type\") == \"metadata\":\n                lines.append(stripped)\n                continue\n\n            message: dict[str, Any] = record.get(\"message\", {})\n            msg_type: str | None = message.get(\"type\")\n\n            if msg_type == \"TurnBegin\":\n                current_turn += 1\n                if current_turn > turn_index:\n                    break\n\n            if current_turn <= turn_index:\n                lines.append(stripped)\n\n            # Stop after the TurnEnd of the target turn\n            if msg_type == \"TurnEnd\" and current_turn == turn_index:\n                break\n\n    if current_turn < turn_index:\n        raise ValueError(f\"turn_index {turn_index} out of range (max turn: {current_turn})\")\n\n    return lines\n\n\ndef _is_checkpoint_user_message(record: dict[str, Any]) -> bool:\n    \"\"\"Whether a context line is the synthetic user checkpoint marker.\"\"\"\n    if record.get(\"role\") != \"user\":\n        return False\n\n    content = record.get(\"content\")\n    if isinstance(content, str):\n        return CHECKPOINT_USER_PATTERN.fullmatch(content.strip()) is not None\n\n    parts = cast(list[Any], content) if isinstance(content, list) else []\n    if len(parts) == 1 and isinstance(parts[0], dict):\n        first_part = cast(dict[str, Any], parts[0])\n        text = first_part.get(\"text\")\n        if isinstance(text, str):\n            return CHECKPOINT_USER_PATTERN.fullmatch(text.strip()) is not None\n\n    return False\n\n\ndef truncate_context_at_turn(context_path: Path, turn_index: int) -> list[str]:\n    \"\"\"Read context.jsonl and return all lines up to and including the given turn.\n\n    Turn detection is based on real user messages, excluding synthetic checkpoint\n    user entries like ``<system>CHECKPOINT N</system>``.\n\n    Unlike wire truncation, this is best-effort: if context has fewer user turns\n    than ``turn_index`` (e.g. slash-command turns that did not mutate context),\n    return all available context lines instead of failing.\n    \"\"\"\n    if not context_path.exists():\n        return []\n\n    lines: list[str] = []\n    current_turn = -1  # Will become 0 on first real user message\n\n    with open(context_path, encoding=\"utf-8\") as f:\n        for line in f:\n            stripped = line.strip()\n            if not stripped:\n                continue\n\n            try:\n                record: dict[str, Any] = json.loads(stripped)\n            except json.JSONDecodeError:\n                continue\n\n            if record.get(\"role\") == \"user\" and not _is_checkpoint_user_message(record):\n                current_turn += 1\n                if current_turn > turn_index:\n                    break\n\n            if current_turn <= turn_index:\n                lines.append(stripped)\n\n    return lines\n\n\n@router.post(\"/{session_id}/fork\", summary=\"Fork a session at a specific turn\")\nasync def fork_session(\n    session_id: UUID,\n    request: ForkSessionRequest,\n    runner: KimiCLIRunner = Depends(get_runner),\n) -> Session:\n    \"\"\"Fork a session, creating a new session with history up to the specified turn.\n\n    The new session shares the same work_dir as the original session.\n    \"\"\"\n    source_session = get_editable_session(session_id, runner)\n    source_dir = source_session.kimi_cli_session.dir\n    wire_path = source_dir / \"wire.jsonl\"\n    context_path = source_dir / \"context.jsonl\"\n\n    try:\n        truncated_wire_lines = truncate_wire_at_turn(wire_path, request.turn_index)\n        truncated_context_lines = truncate_context_at_turn(context_path, request.turn_index)\n    except ValueError as e:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=str(e),\n        ) from e\n\n    # Create new session with the same work_dir.\n    # Only write the essential files explicitly — do NOT copytree the whole\n    # source directory, which would bring in rotated context backups\n    # (context_N.jsonl) and subagent contexts (context_sub_N.jsonl).\n    work_dir = source_session.kimi_cli_session.work_dir\n    new_session = await KimiCLISession.create(work_dir=work_dir)\n    new_session_dir = new_session.dir\n\n    # Copy only the video files that are actually referenced in the truncated\n    # wire history.  Videos are referenced by path (<video path=\"...\">) and\n    # served via the uploads endpoint, so the physical file must exist.\n    # Images and text docs are already embedded (base64 / inline) in\n    # context.jsonl and don't need the physical file.\n    source_uploads = source_dir / \"uploads\"\n    if source_uploads.is_dir():\n        # Collect video filenames referenced in the truncated wire.\n        # In the raw JSON lines the pattern looks like: uploads/filename.mp4\n        referenced_videos: set[str] = set()\n        for line in truncated_wire_lines:\n            for match in re.finditer(r\"uploads/([^\\\"\\\\<>\\s]+)\", line):\n                fname = match.group(1)\n                mime, _ = mimetypes.guess_type(fname)\n                if mime and mime.startswith(\"video/\"):\n                    referenced_videos.add(fname)\n\n        # Copy only those referenced video files that exist on disk.\n        files_to_copy = [\n            source_uploads / name for name in referenced_videos if (source_uploads / name).is_file()\n        ]\n        if files_to_copy:\n            new_uploads = new_session_dir / \"uploads\"\n            new_uploads.mkdir(parents=True, exist_ok=True)\n            copied_names: list[str] = []\n            for vf in files_to_copy:\n                shutil.copy2(vf, new_uploads / vf.name)\n                copied_names.append(vf.name)\n            # Write a .sent marker so _encode_uploaded_files() won't re-send\n            # these inherited videos.  The marker is kept across process\n            # restarts (not deleted after reading).\n            (new_uploads / \".sent\").write_text(json.dumps(copied_names), encoding=\"utf-8\")\n\n    # Write truncated wire.jsonl\n    new_wire_path = new_session_dir / \"wire.jsonl\"\n    with open(new_wire_path, \"w\", encoding=\"utf-8\") as f:\n        for line in truncated_wire_lines:\n            f.write(line + \"\\n\")\n\n    # Write truncated context.jsonl (overwrites the empty file from create())\n    new_context_path = new_session_dir / \"context.jsonl\"\n    with open(new_context_path, \"w\", encoding=\"utf-8\") as f:\n        for line in truncated_context_lines:\n            f.write(line + \"\\n\")\n\n    # Build fresh metadata — not inherited from source — so future\n    # SessionMetadata fields get their defaults instead of stale values.\n    source_metadata = load_session_metadata(source_dir, str(session_id))\n    source_title = (\n        source_metadata.title if source_metadata.title != \"Untitled\" else source_session.title\n    )\n    new_metadata = SessionMetadata(\n        session_id=new_session.id,\n        title=f\"Fork: {source_title}\",\n        wire_mtime=new_wire_path.stat().st_mtime,\n    )\n    save_session_metadata(new_session_dir, new_metadata)\n\n    invalidate_sessions_cache()\n    invalidate_work_dirs_cache()\n\n    context_file = new_session_dir / \"context.jsonl\"\n    return Session(\n        session_id=UUID(new_session.id),\n        title=new_metadata.title,\n        last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),\n        is_running=False,\n        status=SessionStatus(\n            session_id=UUID(new_session.id),\n            state=\"stopped\",\n            seq=0,\n            worker_id=None,\n            reason=None,\n            detail=None,\n            updated_at=datetime.now(UTC),\n        ),\n        work_dir=str(work_dir),\n        session_dir=str(new_session_dir),\n    )\n\n\n@router.post(\"/{session_id}/generate-title\", summary=\"Generate session title using AI\")\nasync def generate_session_title(\n    session_id: UUID,\n    request: GenerateTitleRequest | None = None,\n    runner: KimiCLIRunner = Depends(get_runner),\n) -> GenerateTitleResponse:\n    \"\"\"Generate a concise session title using AI based on the first conversation turn.\n\n    If request body is empty or parameters are missing, the backend will\n    automatically read the first turn from wire.jsonl.\n    \"\"\"\n    session = get_editable_session(session_id, runner)\n    session_dir = session.kimi_cli_session.dir\n\n    # Load existing metadata\n    metadata = load_session_metadata(session_dir, str(session_id))\n\n    # Check if title was already generated (avoid duplicate calls)\n    if metadata.title_generated:\n        return GenerateTitleResponse(title=metadata.title)\n\n    # Get message content: prefer request parameters, otherwise read from wire.jsonl\n    user_message = request.user_message if request else None\n    assistant_response = request.assistant_response if request else None\n\n    if not user_message or not assistant_response:\n        first_turn = extract_first_turn_from_wire(session_dir)\n        if first_turn:\n            user_message, assistant_response = first_turn\n\n    # If still no user message, return default title\n    if not user_message:\n        return GenerateTitleResponse(title=\"Untitled\")\n\n    # Fallback title from user message (used if AI generation fails)\n    from textwrap import shorten\n\n    user_text = user_message.strip()\n    user_text = \" \".join(user_text.split())\n    fallback_title = shorten(user_text, width=50, placeholder=\"...\") or \"Untitled\"\n\n    # If AI generation failed too many times, use fallback and mark as generated\n    if metadata.title_generate_attempts >= 3:\n        metadata = metadata.model_copy(\n            update={\n                \"title\": fallback_title,\n                \"title_generated\": True,\n            }\n        )\n        save_session_metadata(session_dir, metadata)\n        invalidate_sessions_cache()\n        return GenerateTitleResponse(title=fallback_title)\n\n    # Try to generate title using AI\n    title = fallback_title\n    ai_generated = False\n    try:\n        from kosong import generate\n        from kosong.message import Message\n\n        from kimi_cli.config import load_config\n        from kimi_cli.llm import create_llm\n\n        config = load_config()\n        model_name = config.default_model\n\n        if model_name and model_name in config.models:\n            model_config = config.models[model_name]\n            provider_config = config.providers.get(model_config.provider)\n\n            if provider_config:\n                llm = create_llm(provider_config, model_config)\n\n                if llm:\n                    system_prompt = (\n                        \"Generate a concise session title (max 50 characters) \"\n                        \"based on the conversation. \"\n                        \"Only respond with the title text, nothing else. \"\n                        \"No quotes, no explanation.\"\n                    )\n\n                    prompt = f\"\"\"User: {user_message[:300]}\nAssistant: {(assistant_response or \"\")[:300]}\n\nTitle:\"\"\"\n\n                    result = await generate(\n                        chat_provider=llm.chat_provider,\n                        system_prompt=system_prompt,\n                        tools=[],\n                        history=[Message(role=\"user\", content=prompt)],\n                    )\n\n                    generated_title = result.message.extract_text().strip()\n                    # Remove quotes if present\n                    generated_title = generated_title.strip(\"\\\"'\")\n\n                    if generated_title and len(generated_title) <= 50:\n                        title = generated_title\n                        ai_generated = True\n                    elif generated_title:\n                        title = shorten(generated_title, width=50, placeholder=\"...\")\n                        ai_generated = True\n\n    except Exception as e:\n        logger.warning(f\"Failed to generate title using AI: {e}\")\n        # Keep fallback_title, ai_generated stays False\n\n    # Save the title to metadata\n    if ai_generated:\n        # AI succeeded: set title_generated = True\n        metadata = metadata.model_copy(\n            update={\n                \"title\": title,\n                \"title_generated\": True,\n            }\n        )\n    else:\n        # AI failed: increment attempts counter\n        metadata = metadata.model_copy(\n            update={\n                \"title\": title,\n                \"title_generate_attempts\": metadata.title_generate_attempts + 1,\n            }\n        )\n    save_session_metadata(session_dir, metadata)\n\n    # Invalidate cache\n    invalidate_sessions_cache()\n\n    return GenerateTitleResponse(title=title)\n\n\n@router.websocket(\"/{session_id}/stream\")\nasync def session_stream(\n    session_id: UUID,\n    websocket: WebSocket,\n    runner: KimiCLIRunner = Depends(get_runner_ws),\n) -> None:\n    \"\"\"WebSocket stream for a session.\n\n    Flow:\n    1. Accept the WebSocket connection\n    2. If history exists, attach WebSocket in replay mode\n    3. Replay history messages from wire.jsonl\n    4. Start worker if needed\n    5. Flush buffered live messages and send status snapshot\n    6. Forward incoming messages to the subprocess\n    7. Clean up on disconnect\n    \"\"\"\n    expected_token = getattr(websocket.app.state, \"session_token\", None)\n    enforce_origin = getattr(websocket.app.state, \"enforce_origin\", False)\n    allowed_origins = getattr(websocket.app.state, \"allowed_origins\", [])\n    lan_only = getattr(websocket.app.state, \"lan_only\", False)\n\n    # LAN-only check\n    if lan_only:\n        client_ip = websocket.client.host if websocket.client else None\n        if client_ip and not is_private_ip(client_ip):\n            await websocket.close(code=4403, reason=\"Access denied: LAN only\")\n            return\n\n    if enforce_origin:\n        origin = websocket.headers.get(\"origin\")\n        if origin and not is_origin_allowed(origin, allowed_origins):\n            await websocket.close(code=4403, reason=\"Origin not allowed\")\n            return\n\n    if expected_token:\n        token = websocket.query_params.get(\"token\")\n        if not verify_token(token, expected_token):\n            await websocket.close(code=4401, reason=\"Auth required\")\n            return\n\n    await websocket.accept()\n\n    # Check if session exists\n    session = await asyncio.to_thread(load_session_by_id, session_id)\n    if session is None:\n        await websocket.close(code=4004, reason=\"Session not found\")\n        return\n\n    # Check if session has history\n    session_dir = session.kimi_cli_session.dir\n    wire_file = session_dir / \"wire.jsonl\"\n    has_history = await asyncio.to_thread(wire_file.exists)\n\n    session_process = await runner.get_or_create_session(session_id)\n    attached = False\n    try:\n        if has_history:\n            # Attach WebSocket in replay mode before history replay\n            await session_process.add_websocket_and_begin_replay(websocket)\n            attached = True\n\n            # Replay history\n            try:\n                await replay_history(websocket, session_dir)\n            except Exception as e:\n                logger.warning(f\"Failed to replay history: {e}\")\n\n        # Check if WebSocket is still connected before continuing\n        if not await send_history_complete(websocket):\n            logger.debug(\"WebSocket disconnected during history replay\")\n            return\n\n        # Start session environment – if anything fails here, send an error\n        # status so the client doesn't hang on \"Connecting to environment...\".\n        try:\n            # Ensure work_dir exists\n            work_dir = Path(str(session.kimi_cli_session.work_dir))\n            await asyncio.to_thread(lambda: work_dir.mkdir(parents=True, exist_ok=True))\n\n            if not attached:\n                # No history: attach and start worker\n                session_process = await runner.get_or_create_session(session_id)\n                await session_process.add_websocket_and_begin_replay(websocket)\n                attached = True\n\n            assert session_process is not None\n            # End replay and start worker\n            await session_process.end_replay(websocket)\n            await session_process.start()\n            await session_process.send_status_snapshot(websocket)\n        except Exception as e:\n            logger.warning(f\"Failed to start session environment: {e}\")\n            try:\n                error_status = SessionStatus(\n                    session_id=session_id,\n                    state=\"error\",\n                    seq=0,\n                    worker_id=None,\n                    reason=\"initialization_failed\",\n                    detail=str(e),\n                    updated_at=datetime.now(UTC),\n                )\n                await websocket.send_text(\n                    new_session_status_message(error_status).model_dump_json()\n                )\n            except Exception:\n                pass\n            return\n\n        # Track whether we've updated last_session_id for this connection.\n        # We defer the update until the first prompt message is actually forwarded,\n        # so that merely opening/viewing a session does not change last_session_id.\n        last_session_id_updated = False\n\n        # Forward incoming messages to the subprocess\n        while True:\n            try:\n                message = await websocket.receive_text()\n                # Reject new prompts when session is busy\n                if session_process.is_busy:\n                    try:\n                        in_message = JSONRPCInMessageAdapter.validate_json(message)\n                    except ValueError:\n                        in_message = None\n                    if isinstance(in_message, JSONRPCPromptMessage):\n                        await websocket.send_text(\n                            JSONRPCErrorResponse(\n                                id=in_message.id,\n                                error=JSONRPCErrorObject(\n                                    code=ErrorCodes.INVALID_STATE,\n                                    message=(\n                                        \"Session is busy; wait for completion before sending \"\n                                        \"a new prompt.\"\n                                    ),\n                                ),\n                            ).model_dump_json()\n                        )\n                        continue\n\n                # Update last_session_id on first successful prompt\n                if not last_session_id_updated:\n                    try:\n                        in_message = JSONRPCInMessageAdapter.validate_json(message)\n                    except ValueError:\n                        in_message = None\n                    if isinstance(in_message, JSONRPCPromptMessage):\n                        await asyncio.to_thread(_update_last_session_id, session)\n                        last_session_id_updated = True\n\n                logger.debug(f\"sending message to session {session_id}\")\n                await session_process.send_message(message)\n            except WebSocketDisconnect:\n                logger.debug(\"WebSocket disconnected\")\n                break\n            except Exception as e:\n                logger.warning(f\"WebSocket error: {e.__class__.__name__} {e}\")\n                break\n    finally:\n        if attached and session_process:\n            await session_process.remove_websocket(websocket)\n\n\n# Work dirs cache\n_work_dirs_cache: list[str] | None = None\n_work_dirs_cache_time: float = 0.0\n_WORK_DIRS_CACHE_TTL = 30.0  # seconds\n\n\ndef invalidate_work_dirs_cache() -> None:\n    \"\"\"Clear the work dirs cache.\"\"\"\n    global _work_dirs_cache, _work_dirs_cache_time\n    _work_dirs_cache = None\n    _work_dirs_cache_time = 0.0\n\n\ndef _get_work_dirs_sync() -> list[str]:\n    \"\"\"Synchronous helper for get_work_dirs (runs in thread pool).\"\"\"\n    import time\n\n    global _work_dirs_cache, _work_dirs_cache_time\n\n    # Check cache\n    now = time.time()\n    if _work_dirs_cache is not None and (now - _work_dirs_cache_time) < _WORK_DIRS_CACHE_TTL:\n        return _work_dirs_cache\n\n    # Build fresh list\n    metadata = load_metadata()\n    work_dirs: list[str] = []\n    for wd in metadata.work_dirs:\n        # Filter out temporary directories\n        if \"/tmp\" in wd.path or \"/var/folders\" in wd.path or \"/.cache/\" in wd.path:\n            continue\n        # Verify directory exists\n        if Path(wd.path).exists():\n            work_dirs.append(wd.path)\n\n    # Update cache\n    result = work_dirs[:20]\n    _work_dirs_cache = result\n    _work_dirs_cache_time = now\n    return result\n\n\n@work_dirs_router.get(\"/\", summary=\"List available work directories\")\nasync def get_work_dirs() -> list[str]:\n    \"\"\"Get a list of available work directories from metadata.\"\"\"\n    return await asyncio.to_thread(_get_work_dirs_sync)\n\n\n@work_dirs_router.get(\"/startup\", summary=\"Get the startup directory\")\nasync def get_startup_dir(request: Request) -> str:\n    \"\"\"Get the directory where kimi web was started.\"\"\"\n    return request.app.state.startup_dir\n\n\n@router.get(\"/{session_id}/git-diff\", summary=\"Get git diff stats\")\nasync def get_session_git_diff(session_id: UUID) -> GitDiffStats:\n    \"\"\"get git diff stats for the session's work directory\"\"\"\n    session = load_session_by_id(session_id)\n    if session is None:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n\n    work_dir = Path(str(session.kimi_cli_session.work_dir))\n\n    # Check if it is a git repository\n    if not (work_dir / \".git\").exists():\n        return GitDiffStats(is_git_repo=False)\n\n    try:\n        files: list[GitFileDiff] = []\n        total_add, total_del = 0, 0\n\n        # Check if HEAD exists (repo has at least one commit)\n        check_proc = await asyncio.create_subprocess_exec(\n            \"git\",\n            \"rev-parse\",\n            \"--verify\",\n            \"HEAD\",\n            cwd=str(work_dir),\n            stdout=asyncio.subprocess.DEVNULL,\n            stderr=asyncio.subprocess.DEVNULL,\n            env=get_clean_env(),\n        )\n        await check_proc.wait()\n        has_head = check_proc.returncode == 0\n\n        if has_head:\n            # Execute git diff --numstat HEAD (including staged and unstaged)\n            proc = await asyncio.create_subprocess_exec(\n                \"git\",\n                \"diff\",\n                \"--numstat\",\n                \"HEAD\",\n                cwd=str(work_dir),\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n                env=get_clean_env(),\n            )\n            stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)\n\n            # Parse output\n            for line in stdout.decode().strip().split(\"\\n\"):\n                if not line:\n                    continue\n                parts = line.split(\"\\t\")\n                if len(parts) >= 3:\n                    add = int(parts[0]) if parts[0] != \"-\" else 0\n                    dele = int(parts[1]) if parts[1] != \"-\" else 0\n                    total_add += add\n                    total_del += dele\n                    # Determine file status\n                    file_status: str = \"modified\"\n                    if dele == 0 and add > 0:\n                        file_status = \"added\"\n                    elif add == 0 and dele > 0:\n                        file_status = \"deleted\"\n                    files.append(\n                        GitFileDiff(\n                            path=parts[2],\n                            additions=add,\n                            deletions=dele,\n                            status=file_status,  # type: ignore[arg-type]\n                        )\n                    )\n\n        # Also get untracked files (new files not yet added to git)\n        untracked_proc = await asyncio.create_subprocess_exec(\n            \"git\",\n            \"ls-files\",\n            \"--others\",\n            \"--exclude-standard\",\n            cwd=str(work_dir),\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.DEVNULL,\n            env=get_clean_env(),\n        )\n        untracked_stdout, _ = await asyncio.wait_for(untracked_proc.communicate(), timeout=5.0)\n\n        # Add untracked files to the result\n        for line in untracked_stdout.decode().strip().split(\"\\n\"):\n            if line:\n                files.append(\n                    GitFileDiff(\n                        path=line,\n                        additions=0,  # Cannot count lines for untracked files\n                        deletions=0,\n                        status=\"added\",\n                    )\n                )\n\n        if not has_head:\n            return GitDiffStats(\n                is_git_repo=True,\n                has_changes=len(files) > 0,\n                total_additions=0,\n                total_deletions=0,\n                files=files,\n            )\n\n        return GitDiffStats(\n            is_git_repo=True,\n            has_changes=len(files) > 0,\n            total_additions=total_add,\n            total_deletions=total_del,\n            files=files,\n        )\n    except TimeoutError:\n        return GitDiffStats(is_git_repo=True, error=\"Git command timed out\")\n    except Exception as e:\n        return GitDiffStats(is_git_repo=True, error=str(e))\n"
  },
  {
    "path": "src/kimi_cli/web/app.py",
    "content": "\"\"\"Kimi Code CLI Web UI application.\"\"\"\n\nimport importlib\nimport os\nimport secrets\nimport socket\nimport sys\nimport webbrowser\nfrom collections.abc import Callable\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\nfrom typing import Any, cast\nfrom urllib.parse import quote\n\nimport scalar_fastapi\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.staticfiles import StaticFiles\nfrom starlette.responses import HTMLResponse\n\nfrom kimi_cli import logger\nfrom kimi_cli.web.api import (\n    config_router,\n    open_in_router,\n    sessions_router,\n    work_dirs_router,\n)\nfrom kimi_cli.web.auth import (\n    DEFAULT_ALLOWED_ORIGIN_REGEX,\n    AuthMiddleware,\n    is_private_ip,\n    normalize_allowed_origins,\n)\nfrom kimi_cli.web.runner.process import KimiCLIRunner\n\n# Configure logging based on LOG_LEVEL environment variable\n_log_level = os.environ.get(\"LOG_LEVEL\", \"WARNING\").upper()\nlogger.remove()\nlogger.enable(\"kimi_cli\")\nlogger.add(sys.stderr, level=_log_level)\n\n# scalar-fastapi does not ship typing stubs.\nget_scalar_api_reference = cast(  # pyright: ignore[reportUnknownMemberType]\n    Callable[..., HTMLResponse],\n    scalar_fastapi.get_scalar_api_reference,  # pyright: ignore[reportUnknownMemberType]\n)\n\n# Constants\nSTATIC_DIR = Path(__file__).parent / \"static\"\nGZIP_MINIMUM_SIZE = 1024\nGZIP_COMPRESSION_LEVEL = 6\nDEFAULT_PORT = 5494\nMAX_PORT_ATTEMPTS = 10\nENV_SESSION_TOKEN = \"KIMI_WEB_SESSION_TOKEN\"\nENV_ALLOWED_ORIGINS = \"KIMI_WEB_ALLOWED_ORIGINS\"\nENV_ENFORCE_ORIGIN = \"KIMI_WEB_ENFORCE_ORIGIN\"\nENV_RESTRICT_SENSITIVE_APIS = \"KIMI_WEB_RESTRICT_SENSITIVE_APIS\"\nENV_MAX_PUBLIC_PATH_DEPTH = \"KIMI_WEB_MAX_PUBLIC_PATH_DEPTH\"\n\n\ndef _is_local_host(host: str) -> bool:\n    return host in {\"127.0.0.1\", \"localhost\", \"::1\"}\n\n\ndef _get_address_family(host: str) -> socket.AddressFamily:\n    \"\"\"Determine the socket address family for a given host.\n\n    Returns AF_INET6 for IPv6 addresses, AF_INET for IPv4 and hostnames.\n    \"\"\"\n    # Check for IPv6 address patterns\n    if \":\" in host:\n        return socket.AF_INET6\n    return socket.AF_INET\n\n\ndef _get_private_addresses(addresses: list[str]) -> list[str]:\n    \"\"\"Filter addresses to only include private IPs.\"\"\"\n    return [ip for ip in addresses if is_private_ip(ip)]\n\n\ndef _load_env_flag(key: str) -> bool:\n    return os.environ.get(key, \"\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n\ndef _get_network_addresses() -> list[str]:\n    \"\"\"Get all non-loopback IPv4 addresses for this machine.\n\n    Uses multiple methods to ensure we get all addresses across platforms.\n    \"\"\"\n    addresses: list[str] = []\n\n    # Method 1: Try using socket.getaddrinfo with the hostname\n    try:\n        hostname = socket.gethostname()\n        addr_infos = socket.getaddrinfo(hostname, None, socket.AF_INET)\n        for info in addr_infos:\n            ip = info[4][0]\n            if isinstance(ip, str) and not ip.startswith(\"127.\") and ip not in addresses:\n                addresses.append(ip)\n    except OSError:\n        pass\n\n    # Method 2: Try connecting to external address to get local interface\n    try:\n        # This doesn't actually send any data, just determines routing\n        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n        s.connect((\"8.8.8.8\", 80))\n        ip = s.getsockname()[0]\n        s.close()\n        if ip and not ip.startswith(\"127.\") and ip not in addresses:\n            addresses.append(ip)\n    except OSError:\n        pass\n\n    # Method 3: Try netifaces if available (most comprehensive)\n    try:\n        netifaces = importlib.import_module(\"netifaces\")\n\n        for interface in netifaces.interfaces():\n            addrs = netifaces.ifaddresses(interface)\n            if netifaces.AF_INET in addrs:\n                for addr_info in addrs[netifaces.AF_INET]:\n                    addr = addr_info.get(\"addr\")\n                    if addr and not addr.startswith(\"127.\") and addr not in addresses:\n                        addresses.append(addr)\n    except ImportError:\n        pass\n    except Exception:\n        pass\n\n    return addresses\n\n\nENV_LAN_ONLY = \"KIMI_WEB_LAN_ONLY\"\n\n\ndef create_app(\n    session_token: str | None = None,\n    allowed_origins: list[str] | None = None,\n    enforce_origin: bool | None = None,\n    restrict_sensitive_apis: bool | None = None,\n    max_public_path_depth: int | None = None,\n    lan_only: bool | None = None,\n) -> FastAPI:\n    \"\"\"Create the FastAPI application for Kimi CLI web UI.\"\"\"\n\n    env_token = os.environ.get(ENV_SESSION_TOKEN) or None\n    env_origins = normalize_allowed_origins(os.environ.get(ENV_ALLOWED_ORIGINS))\n    env_enforce_origin = _load_env_flag(ENV_ENFORCE_ORIGIN)\n    env_restrict_sensitive = _load_env_flag(ENV_RESTRICT_SENSITIVE_APIS)\n    env_max_depth_str = os.environ.get(ENV_MAX_PUBLIC_PATH_DEPTH)\n    env_max_depth = (\n        int(env_max_depth_str) if env_max_depth_str and env_max_depth_str.isdigit() else None\n    )\n    env_lan_only = _load_env_flag(ENV_LAN_ONLY)\n\n    session_token = session_token if session_token is not None else env_token\n    allowed_origins = allowed_origins if allowed_origins is not None else env_origins\n    enforce_origin = enforce_origin if enforce_origin is not None else env_enforce_origin\n    restrict_sensitive_apis = (\n        restrict_sensitive_apis if restrict_sensitive_apis is not None else env_restrict_sensitive\n    )\n    max_public_path_depth = (\n        max_public_path_depth if max_public_path_depth is not None else env_max_depth\n    )\n    lan_only = lan_only if lan_only is not None else env_lan_only\n\n    @asynccontextmanager\n    async def lifespan(app: FastAPI):\n        app.state.startup_dir = os.getcwd()\n        app.state.session_token = session_token\n        app.state.allowed_origins = allowed_origins\n        app.state.enforce_origin = enforce_origin\n        app.state.restrict_sensitive_apis = restrict_sensitive_apis\n        app.state.max_public_path_depth = max_public_path_depth\n        app.state.lan_only = lan_only\n\n        # Start KimiCLI runner\n        runner = KimiCLIRunner()\n        app.state.runner = runner\n        runner.start()\n\n        try:\n            yield\n        finally:\n            await runner.stop()\n\n    application = FastAPI(\n        title=\"Kimi Code CLI Web Interface\",\n        docs_url=None,\n        lifespan=lifespan,\n        separate_input_output_schemas=False,\n    )\n\n    application.add_middleware(\n        cast(Any, GZipMiddleware),\n        minimum_size=GZIP_MINIMUM_SIZE,\n        compresslevel=GZIP_COMPRESSION_LEVEL,\n    )\n\n    application.add_middleware(\n        cast(Any, AuthMiddleware),\n        session_token=session_token,\n        allowed_origins=allowed_origins,\n        enforce_origin=enforce_origin,\n        lan_only=lan_only,\n    )\n\n    cors_kwargs: dict[str, Any] = {\n        \"allow_credentials\": True,\n        \"allow_methods\": [\"*\"],\n        \"allow_headers\": [\"*\"],\n    }\n    if allowed_origins:\n        cors_kwargs[\"allow_origins\"] = allowed_origins\n    else:\n        cors_kwargs[\"allow_origin_regex\"] = DEFAULT_ALLOWED_ORIGIN_REGEX.pattern\n\n    # CORS middleware for local development\n    application.add_middleware(cast(Any, CORSMiddleware), **cors_kwargs)\n\n    application.include_router(config_router)\n    application.include_router(sessions_router)\n    application.include_router(work_dirs_router)\n    if not restrict_sensitive_apis:\n        application.include_router(open_in_router)\n\n    @application.get(\"/scalar\", include_in_schema=False)\n    @application.get(\"/docs\", include_in_schema=False)\n    async def scalar_html() -> HTMLResponse:  # pyright: ignore[reportUnusedFunction]\n        return get_scalar_api_reference(\n            openapi_url=application.openapi_url or \"\",\n            title=application.title,\n        )\n\n    @application.get(\"/healthz\")\n    async def health_probe() -> dict[str, Any]:  # pyright: ignore[reportUnusedFunction]\n        \"\"\"Health check endpoint.\"\"\"\n        return {\"status\": \"ok\"}\n\n    # Mount static files as fallback (must be last)\n    if STATIC_DIR.exists():\n        application.mount(\"/\", StaticFiles(directory=STATIC_DIR, html=True), name=\"static\")\n\n    return application\n\n\ndef find_available_port(host: str, start_port: int, max_attempts: int = MAX_PORT_ATTEMPTS) -> int:\n    \"\"\"Find an available port starting from start_port.\n\n    Args:\n        host: Host address to bind to\n        start_port: Starting port number (1-65535)\n        max_attempts: Maximum number of ports to try (must be positive)\n\n    Returns:\n        An available port number\n\n    Raises:\n        ValueError: If parameters are invalid\n        RuntimeError: If no available port is found within the range\n    \"\"\"\n    if max_attempts <= 0:\n        raise ValueError(\"max_attempts must be positive\")\n    if start_port < 1 or start_port > 65535:\n        raise ValueError(\"start_port must be between 1 and 65535\")\n\n    family = _get_address_family(host)\n    for offset in range(max_attempts):\n        port = start_port + offset\n        with socket.socket(family, socket.SOCK_STREAM) as s:\n            # Set SO_REUSEADDR to allow reusing ports in TIME_WAIT state.\n            # This matches uvicorn's behavior and prevents false positives\n            # when checking port availability after a recent shutdown.\n            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n            try:\n                s.bind((host, port))\n                return port\n            except OSError:\n                continue\n    raise RuntimeError(\n        f\"Cannot find available port in range {start_port}-{start_port + max_attempts - 1}\"\n    )\n\n\ndef run_web_server(\n    host: str = \"127.0.0.1\",\n    port: int = DEFAULT_PORT,\n    reload: bool = False,\n    open_browser: bool = True,\n    auth_token: str | None = None,\n    allowed_origins: str | None = None,\n    dangerously_omit_auth: bool = False,\n    restrict_sensitive_apis: bool | None = None,\n    lan_only: bool = True,\n) -> None:\n    \"\"\"Run the web server.\"\"\"\n    import sys\n    import textwrap\n    import threading\n\n    import uvicorn\n\n    def print_banner(lines: list[str]) -> None:\n        # Process lines, respecting special tags\n        processed: list[str] = []\n        for line in lines:\n            if line == \"<hr>\":\n                processed.append(line)\n            elif not line:\n                processed.append(\"\")\n            elif line.startswith(\"<center>\") or line.startswith(\"<nowrap>\"):\n                # Don't wrap these lines\n                processed.append(line)\n            else:\n                processed.extend(textwrap.wrap(line, width=78))\n\n        # Calculate width based on content (strip tags for measurement)\n        def strip_tags(s: str) -> str:\n            return s.removeprefix(\"<center>\").removeprefix(\"<nowrap>\")\n\n        content_lines = [strip_tags(line) for line in processed if line != \"<hr>\"]\n        width = max(60, *(len(line) for line in content_lines))\n        top = \"+\" + \"=\" * (width + 2) + \"+\"\n\n        print(top)\n        for line in processed:\n            if line == \"<hr>\":\n                print(\"|\" + \"-\" * (width + 2) + \"|\")\n            elif line.startswith(\"<center>\"):\n                content = line.removeprefix(\"<center>\")\n                print(f\"| {content.center(width)} |\")\n            elif line.startswith(\"<nowrap>\"):\n                content = line.removeprefix(\"<nowrap>\")\n                print(f\"| {content.ljust(width)} |\")\n            else:\n                print(f\"| {line.ljust(width)} |\")\n        print(top)\n\n    public_mode = not _is_local_host(host)\n    parsed_allowed_origins = normalize_allowed_origins(allowed_origins)\n    auto_populate_origins = public_mode and not parsed_allowed_origins\n\n    if restrict_sensitive_apis is None:\n        # Only restrict sensitive APIs in public mode (non-LAN-only)\n        restrict_sensitive_apis = public_mode and not lan_only\n\n    if public_mode and dangerously_omit_auth:\n        warning_lines = [\n            \"SECURITY WARNING\",\n            \"\",\n            \"Authentication is DISABLED while running on a public host.\",\n            \"Anyone on the network can access your sessions and files.\",\n            \"\",\n            \"Type 'I UNDERSTAND THE RISKS' to continue:\",\n        ]\n        print_banner(warning_lines)\n        if not sys.stdin.isatty():\n            raise RuntimeError(\"Refusing to start without auth in non-interactive mode.\")\n        response = input(\"> \").strip()\n        if response != \"I UNDERSTAND THE RISKS\":\n            raise RuntimeError(\"Aborted by user.\")\n\n    if dangerously_omit_auth:\n        session_token = None\n    elif auth_token:\n        session_token = auth_token\n    elif public_mode:\n        session_token = secrets.token_urlsafe(32)\n    else:\n        session_token = None\n\n    if session_token:\n        os.environ[ENV_SESSION_TOKEN] = session_token\n    else:\n        os.environ.pop(ENV_SESSION_TOKEN, None)\n\n    # Find available port first (needed for auto-populating origins)\n    actual_port = find_available_port(host, port)\n    if actual_port != port:\n        print(f\"Port {port} is in use, using port {actual_port} instead\")\n\n    # Auto-populate allowed origins with detected network addresses + port\n    if auto_populate_origins:\n        auto_origins = [\n            f\"http://localhost:{actual_port}\",\n            f\"http://127.0.0.1:{actual_port}\",\n        ]\n        if host == \"0.0.0.0\":\n            # Binding to all interfaces: add all network addresses\n            network_addrs = _get_network_addresses()\n            for addr in network_addrs:\n                auto_origins.append(f\"http://{addr}:{actual_port}\")\n        else:\n            # Explicit host specified: only add that host\n            auto_origins.append(f\"http://{host}:{actual_port}\")\n        parsed_allowed_origins = auto_origins\n\n    if parsed_allowed_origins:\n        os.environ[ENV_ALLOWED_ORIGINS] = \",\".join(parsed_allowed_origins)\n    else:\n        os.environ.pop(ENV_ALLOWED_ORIGINS, None)\n\n    os.environ[ENV_ENFORCE_ORIGIN] = \"1\" if (public_mode and not lan_only) else \"0\"\n    os.environ[ENV_RESTRICT_SENSITIVE_APIS] = \"1\" if restrict_sensitive_apis else \"0\"\n    os.environ[ENV_LAN_ONLY] = \"1\" if lan_only else \"0\"\n\n    # Determine display URLs\n    display_hosts: list[tuple[str, str]] = []\n    if host == \"0.0.0.0\":\n        # Show localhost as \"Local\" and network interfaces\n        display_hosts.append((\"Local\", \"localhost\"))\n        network_addrs = _get_network_addresses()\n\n        # In lan_only mode, only show private IPs\n        if lan_only:\n            network_addrs = _get_private_addresses(network_addrs)\n\n        for addr in network_addrs:\n            display_hosts.append((\"Network\", addr))\n    else:\n        # Show the specified host\n        label = \"Local\" if _is_local_host(host) else \"Network\"\n        display_hosts.append((label, host))\n\n    # Build URLs with token if needed\n    def make_url(host_addr: str) -> tuple[str, str]:\n        \"\"\"Returns (url, browser_url) tuple.\"\"\"\n        url = f\"http://{host_addr}:{actual_port}\"\n        browser_url = f\"{url}/?token={quote(session_token)}\" if session_token else url\n        return url, browser_url\n\n    # For browser opening, prefer localhost, then first network address\n    browser_host = \"localhost\" if host == \"0.0.0.0\" else host\n    _, browser_url = make_url(browser_host)\n\n    if open_browser:\n\n        def open_browser_after_delay():\n            import time\n\n            time.sleep(1.5)\n            webbrowser.open(browser_url)\n\n        # Start browser opener in a daemon thread\n        thread = threading.Thread(target=open_browser_after_delay, daemon=True)\n        thread.start()\n\n    banner_lines = [\n        \"<center>██╗  ██╗██╗███╗   ███╗██╗     ██████╗ ██████╗ ██████╗ ███████╗\",\n        \"<center>██║ ██╔╝██║████╗ ████║██║    ██╔════╝██╔═══██╗██╔══██╗██╔════╝\",\n        \"<center>█████╔╝ ██║██╔████╔██║██║    ██║     ██║   ██║██║  ██║█████╗  \",\n        \"<center>██╔═██╗ ██║██║╚██╔╝██║██║    ██║     ██║   ██║██║  ██║██╔══╝  \",\n        \"<center>██║  ██╗██║██║ ╚═╝ ██║██║    ╚██████╗╚██████╔╝██████╔╝███████╗\",\n        \"<center>╚═╝  ╚═╝╚═╝╚═╝     ╚═╝╚═╝     ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝\",\n        \"\",\n        \"<center>WEB UI (Technical Preview)\",\n        \"\",\n        \"<hr>\",\n        \"\",\n    ]\n\n    # Add URLs for each host (nowrap to keep URLs on single line for easy copying)\n    for label, host_addr in display_hosts:\n        url, url_with_token = make_url(host_addr)\n        if session_token:\n            banner_lines.append(f\"<nowrap>  ➜  {label:8} {url_with_token}\")\n        else:\n            banner_lines.append(f\"<nowrap>  ➜  {label:8} {url}\")\n\n    # Auth token or warnings\n    if session_token:\n        banner_lines.extend(\n            [\n                \"\",\n                f\"<nowrap>  Token:   {session_token}\",\n            ]\n        )\n    elif public_mode:\n        banner_lines.extend(\n            [\n                \"\",\n                \"<nowrap>  ⚠ AUTH DISABLED - Anyone on the network can access\",\n            ]\n        )\n\n    if restrict_sensitive_apis:\n        banner_lines.append(\"<nowrap>  ⚠ Sensitive APIs are restricted\")\n\n    # Show network access mode and tips\n    banner_lines.append(\"\")\n    banner_lines.append(\"<hr>\")\n    banner_lines.append(\"\")\n\n    if not public_mode:\n        # Local-only mode (127.0.0.1)\n        banner_lines.extend(\n            [\n                \"<nowrap>  Tips:\",\n                \"<nowrap>    • Use -n / --network to share on LAN\",\n                \"<nowrap>    • Use --network --public for public access\",\n            ]\n        )\n    elif lan_only:\n        # LAN mode (0.0.0.0 with lan_only)\n        banner_lines.extend(\n            [\n                \"<nowrap>  Mode: LAN only (private IPs)\",\n                \"\",\n                \"<nowrap>  Tips:\",\n                \"<nowrap>    • Use --public to allow public access\",\n                \"<nowrap>    • ⚠ Public mode allows access from any IP\",\n            ]\n        )\n    else:\n        # Public mode (0.0.0.0 without lan_only)\n        banner_lines.extend(\n            [\n                \"<nowrap>  ⚠ Mode: PUBLIC (all networks)\",\n                \"<nowrap>    Anyone with the URL can access this instance\",\n                \"\",\n                \"<nowrap>  Security tips:\",\n                \"<nowrap>    • Keep your auth token secure\",\n                \"<nowrap>    • Consider using firewall or VPN\",\n            ]\n        )\n\n    banner_lines.append(\"\")\n\n    print_banner(banner_lines)\n    # print(f\"API docs available at {url}/docs\")\n\n    uvicorn.run(\n        \"kimi_cli.web.app:create_app\",\n        factory=True,\n        host=host,\n        port=actual_port,\n        reload=reload,\n        log_level=\"info\",\n        timeout_graceful_shutdown=3,\n    )\n\n\n__all__ = [\"create_app\", \"find_available_port\", \"run_web_server\"]\n"
  },
  {
    "path": "src/kimi_cli/web/auth.py",
    "content": "\"\"\"Auth helpers and middleware for Kimi CLI web.\"\"\"\n\nfrom __future__ import annotations\n\nimport hmac\nimport ipaddress\nimport re\nfrom collections.abc import Iterable\n\nfrom fastapi import Request\nfrom starlette.middleware.base import BaseHTTPMiddleware\nfrom starlette.responses import JSONResponse\nfrom starlette.types import ASGIApp\n\nDEFAULT_ALLOWED_ORIGIN_REGEX = re.compile(r\"^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$\")\n\n\ndef timing_safe_compare(a: str, b: str) -> bool:\n    \"\"\"Timing-safe string comparison.\"\"\"\n    return hmac.compare_digest(a.encode(), b.encode())\n\n\ndef parse_bearer_token(value: str | None) -> str | None:\n    \"\"\"Extract bearer token from Authorization header.\"\"\"\n    if not value:\n        return None\n    scheme, _, token = value.partition(\" \")\n    if scheme.lower() != \"bearer\":\n        return None\n    token = token.strip()\n    return token or None\n\n\ndef normalize_allowed_origins(value: str | None) -> list[str]:\n    \"\"\"Parse comma-separated origins into a normalized list.\"\"\"\n    if not value:\n        return []\n    origins: list[str] = []\n    for raw in value.split(\",\"):\n        origin = raw.strip().rstrip(\"/\")\n        if origin:\n            origins.append(origin)\n    return origins\n\n\ndef is_origin_allowed(origin: str, allowed_origins: Iterable[str] | None) -> bool:\n    \"\"\"Check if an origin is allowed.\n\n    Args:\n        origin: The origin to check\n        allowed_origins: List of allowed origins.\n                        - None: use default localhost regex\n                        - Empty list: reject all origins\n                        - Non-empty list: check against the list (supports \"*\" wildcard)\n    \"\"\"\n    origin = origin.rstrip(\"/\")\n\n    # None means use default behavior (localhost only)\n    if allowed_origins is None:\n        return bool(DEFAULT_ALLOWED_ORIGIN_REGEX.match(origin))\n\n    allowed = list(allowed_origins)\n\n    # Empty list explicitly means reject all\n    if not allowed:\n        return False\n\n    # Check for wildcard or exact match\n    if \"*\" in allowed:\n        return True\n    return origin in allowed\n\n\ndef extract_token_from_request(request: Request) -> str | None:\n    \"\"\"Get auth token from Authorization header or query (GET-only).\"\"\"\n    token = parse_bearer_token(request.headers.get(\"authorization\"))\n    if token:\n        return token\n    if request.method.upper() == \"GET\":\n        query_token = request.query_params.get(\"token\")\n        if query_token:\n            return query_token\n    return None\n\n\ndef verify_token(provided: str | None, expected: str) -> bool:\n    \"\"\"Verify token using timing-safe comparison.\"\"\"\n    if not provided:\n        return False\n    return timing_safe_compare(provided, expected)\n\n\ndef is_private_ip(ip: str) -> bool:\n    \"\"\"Check if an IP address is in a private range (RFC 1918 + localhost).\n\n    Supports both IPv4 and IPv6 addresses.\n    \"\"\"\n    if not ip:\n        return False\n    try:\n        addr = ipaddress.ip_address(ip)\n        # is_private covers RFC 1918 (10.x, 172.16-31.x, 192.168.x)\n        # is_loopback covers 127.x.x.x and ::1\n        # is_link_local covers 169.254.x.x and fe80::/10\n        return addr.is_private or addr.is_loopback or addr.is_link_local\n    except ValueError:\n        return False\n\n\ndef get_client_ip(request: Request, trust_proxy: bool = False) -> str | None:\n    \"\"\"Extract client IP from request.\n\n    Args:\n        request: The incoming request\n        trust_proxy: If True, trust X-Forwarded-For header (only enable behind trusted proxy)\n    \"\"\"\n    if trust_proxy:\n        forwarded = request.headers.get(\"x-forwarded-for\")\n        if forwarded:\n            return forwarded.split(\",\")[0].strip()\n    if request.client:\n        return request.client.host\n    return None\n\n\nclass AuthMiddleware(BaseHTTPMiddleware):\n    \"\"\"Bearer token auth, origin checks, and LAN-only mode for API routes.\"\"\"\n\n    def __init__(\n        self,\n        app: ASGIApp,\n        session_token: str | None,\n        allowed_origins: Iterable[str] | None,\n        enforce_origin: bool,\n        lan_only: bool = False,\n    ) -> None:\n        super().__init__(app)\n        self._session_token = session_token\n        self._allowed_origins = list(allowed_origins) if allowed_origins is not None else None\n        self._enforce_origin = enforce_origin\n        self._lan_only = lan_only\n\n    async def dispatch(self, request: Request, call_next):  # type: ignore[override]\n        path = request.url.path\n\n        # LAN-only check applies to all requests (including static files)\n        if self._lan_only:\n            client_ip = get_client_ip(request)\n            if client_ip and not is_private_ip(client_ip):\n                return JSONResponse(\n                    status_code=403,\n                    content={\"detail\": \"Access denied: only local network access is allowed\"},\n                )\n\n        if request.method.upper() == \"OPTIONS\":\n            return await call_next(request)\n        if path in {\"/healthz\", \"/docs\", \"/scalar\"}:\n            return await call_next(request)\n        if not path.startswith(\"/api/\"):\n            return await call_next(request)\n\n        if self._enforce_origin:\n            origin = request.headers.get(\"origin\")\n            if origin and not is_origin_allowed(origin, self._allowed_origins):\n                return JSONResponse(\n                    status_code=403,\n                    content={\"detail\": \"Origin not allowed\"},\n                )\n\n        if self._session_token:\n            provided = extract_token_from_request(request)\n            if not verify_token(provided, self._session_token):\n                return JSONResponse(\n                    status_code=401,\n                    content={\"detail\": \"Unauthorized\"},\n                )\n\n        return await call_next(request)\n\n\n__all__ = [\n    \"AuthMiddleware\",\n    \"DEFAULT_ALLOWED_ORIGIN_REGEX\",\n    \"extract_token_from_request\",\n    \"get_client_ip\",\n    \"is_origin_allowed\",\n    \"is_private_ip\",\n    \"normalize_allowed_origins\",\n    \"timing_safe_compare\",\n    \"verify_token\",\n]\n"
  },
  {
    "path": "src/kimi_cli/web/models.py",
    "content": "\"\"\"Kimi Code CLI Web UI data models.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Literal\nfrom uuid import UUID\n\nfrom pydantic import BaseModel, Field\n\nSessionState = Literal[\"stopped\", \"idle\", \"busy\", \"restarting\", \"error\"]\n\n\nclass SessionStatus(BaseModel):\n    \"\"\"Runtime status of a web session.\"\"\"\n\n    session_id: UUID = Field(..., description=\"Session unique ID\")\n    state: SessionState = Field(..., description=\"Current session state\")\n    seq: int = Field(..., description=\"Monotonic sequence number\")\n    worker_id: str | None = Field(default=None, description=\"Worker instance ID\")\n    reason: str | None = Field(default=None, description=\"Reason for the state transition\")\n    detail: str | None = Field(default=None, description=\"Additional detail for debugging\")\n    updated_at: datetime = Field(..., description=\"Timestamp for this state\")\n\n\nclass SessionNoticePayload(BaseModel):\n    \"\"\"Payload for session notice events.\"\"\"\n\n    text: str = Field(..., description=\"Display text for the notice\")\n    kind: Literal[\"restart\"] = Field(default=\"restart\", description=\"Notice type\")\n    reason: str | None = Field(default=None, description=\"Reason for the notice\")\n    restart_ms: int | None = Field(default=None, description=\"Restart duration in ms\")\n\n\nclass SessionNoticeEvent(BaseModel):\n    \"\"\"Session notice event sent to frontend.\"\"\"\n\n    type: Literal[\"SessionNotice\"] = Field(default=\"SessionNotice\", description=\"Event type\")\n    payload: SessionNoticePayload\n\n\nclass GitFileDiff(BaseModel):\n    \"\"\"Single file git diff statistics\"\"\"\n\n    path: str = Field(..., description=\"File path\")\n    additions: int = Field(..., description=\"Number of added lines\")\n    deletions: int = Field(..., description=\"Number of deleted lines\")\n    status: Literal[\"added\", \"modified\", \"deleted\", \"renamed\"] = Field(\n        ..., description=\"File change status\"\n    )\n\n\nclass GitDiffStats(BaseModel):\n    \"\"\"Git diff statistics for a work directory.\"\"\"\n\n    is_git_repo: bool = Field(..., description=\"Whether the directory is a git repo\")\n    has_changes: bool = Field(default=False, description=\"Whether there are uncommitted changes\")\n    total_additions: int = Field(default=0, description=\"Total added lines\")\n    total_deletions: int = Field(default=0, description=\"Total deleted lines\")\n    files: list[GitFileDiff] = Field(default=[], description=\"Per-file diff stats\")\n    error: str | None = Field(default=None, description=\"Error message if any\")\n\n\nclass Session(BaseModel):\n    \"\"\"Web UI session metadata.\"\"\"\n\n    session_id: UUID = Field(..., description=\"Session unique ID\")\n    title: str = Field(..., description=\"Session title derived from kimi-cli history\")\n    last_updated: datetime = Field(..., description=\"Last updated timestamp\")\n    is_running: bool = Field(default=False, description=\"Whether the session is running\")\n    status: SessionStatus | None = Field(default=None, description=\"Session runtime status\")\n    work_dir: str | None = Field(default=None, description=\"Working directory for the session\")\n    session_dir: str | None = Field(default=None, description=\"Session directory path\")\n    archived: bool = Field(default=False, description=\"Whether the session is archived\")\n\n\nclass UpdateSessionRequest(BaseModel):\n    \"\"\"Update session request.\"\"\"\n\n    title: str | None = Field(default=None, min_length=1, max_length=200)\n    archived: bool | None = Field(default=None, description=\"Archive or unarchive the session\")\n\n\nclass GenerateTitleRequest(BaseModel):\n    \"\"\"Generate title request.\n\n    Parameters are optional - if not provided, the backend will read\n    from wire.jsonl automatically.\n    \"\"\"\n\n    user_message: str | None = None\n    assistant_response: str | None = None\n\n\nclass GenerateTitleResponse(BaseModel):\n    \"\"\"Generate title response.\"\"\"\n\n    title: str\n"
  },
  {
    "path": "src/kimi_cli/web/runner/__init__.py",
    "content": "\"\"\"Kimi CLI session runner.\"\"\"\n\nfrom kimi_cli.web.runner.process import KimiCLIRunner\n\n__all__ = [\"KimiCLIRunner\"]\n"
  },
  {
    "path": "src/kimi_cli/web/runner/messages.py",
    "content": "\"\"\"JSON-RPC message helpers for Kimi CLI web interface.\"\"\"\n\nfrom typing import Literal\nfrom uuid import uuid4\n\nfrom fastapi import WebSocket\nfrom pydantic import BaseModel, ConfigDict\nfrom starlette.websockets import WebSocketState\n\nfrom kimi_cli.web.models import SessionStatus\n\n\nclass _MessageBase(BaseModel):\n    \"\"\"Base model for JSON-RPC messages.\"\"\"\n\n    jsonrpc: Literal[\"2.0\"] = \"2.0\"\n    model_config = ConfigDict(extra=\"forbid\")\n\n\nclass JSONRPCSessionStatusMessage(_MessageBase):\n    \"\"\"Session status update message.\"\"\"\n\n    method: Literal[\"session_status\"] = \"session_status\"\n    params: SessionStatus\n\n\nclass JSONRPCHistoryCompleteMessage(_MessageBase):\n    \"\"\"Sent after history replay, before environment is ready.\"\"\"\n\n    method: Literal[\"history_complete\"] = \"history_complete\"\n    id: str\n\n\ndef new_session_status_message(status: SessionStatus) -> JSONRPCSessionStatusMessage:\n    \"\"\"Create a new session status message.\"\"\"\n    return JSONRPCSessionStatusMessage(params=status)\n\n\ndef new_history_complete_message() -> JSONRPCHistoryCompleteMessage:\n    \"\"\"Create a new history complete message.\"\"\"\n    return JSONRPCHistoryCompleteMessage(id=str(uuid4()))\n\n\nasync def send_history_complete(ws: WebSocket) -> bool:\n    \"\"\"Send history complete message to a WebSocket.\n\n    Returns:\n        True if message was sent successfully, False if the send fails or the WebSocket is not\n        connected.\n    \"\"\"\n    if ws.client_state != WebSocketState.CONNECTED:\n        return False\n    try:\n        await ws.send_text(new_history_complete_message().model_dump_json())\n        return True\n    except Exception:\n        return False\n"
  },
  {
    "path": "src/kimi_cli/web/runner/process.py",
    "content": "\"\"\"Session process management for Kimi CLI web interface.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport base64\nimport contextlib\nimport io\nimport json\nimport mimetypes\nimport sys\nimport time\nfrom collections.abc import AsyncGenerator\nfrom dataclasses import dataclass\nfrom datetime import UTC, datetime\nfrom pathlib import Path\nfrom uuid import UUID, uuid4\n\nfrom kosong.message import ContentPart, ImageURLPart, TextPart\nfrom PIL import Image\nfrom PIL.Image import Image as PILImage\nfrom pydantic import TypeAdapter\nfrom starlette.websockets import WebSocket, WebSocketState\n\nfrom kimi_cli import logger\nfrom kimi_cli.config import load_config\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.utils.subprocess_env import get_clean_env\nfrom kimi_cli.web.models import (\n    SessionNoticeEvent,\n    SessionNoticePayload,\n    SessionState,\n    SessionStatus,\n)\nfrom kimi_cli.web.runner.messages import new_session_status_message\nfrom kimi_cli.web.store.sessions import load_session_by_id\nfrom kimi_cli.wire.jsonrpc import (\n    JSONRPCCancelMessage,\n    JSONRPCErrorObject,\n    JSONRPCErrorResponse,\n    JSONRPCEventMessage,\n    JSONRPCInMessage,\n    JSONRPCInMessageAdapter,\n    JSONRPCOutMessage,\n    JSONRPCPromptMessage,\n    JSONRPCRequestMessage,\n    JSONRPCSuccessResponse,\n)\nfrom kimi_cli.wire.serde import deserialize_wire_message\n\nJSONRPCOutMessageAdapter = TypeAdapter[JSONRPCOutMessage](JSONRPCOutMessage)\n\n\nclass SessionProcess:\n    \"\"\"Manages a single session's KimiCLI subprocess.\n\n    Handles:\n    - Starting/stopping the subprocess\n    - Reading from stdout (wire messages from KimiCLI)\n    - Writing to stdin (user input to KimiCLI)\n    - Broadcasting messages to connected WebSockets\n\n    Concurrency model:\n    - `SessionProcess` is the long-lived container for a `session_id`.\n      It may outlive worker restarts.\n    - Liveness vs busy are separate:\n      - `is_alive` / `is_running`: worker subprocess exists and has not exited.\n      - `is_busy`: there is at least one in-flight prompt id.\n    - WebSocket fanout supports \"join while running\":\n      - New clients replay `wire.jsonl` history first.\n      - Live messages during replay are buffered per-WS and flushed afterwards.\n\n    Locks:\n    - `_lock` guards worker lifecycle and busy state.\n    - `_ws_lock` guards WebSocket state.\n    \"\"\"\n\n    def __init__(self, session_id: UUID) -> None:\n        \"\"\"Initialize a session process.\"\"\"\n        self.session_id = session_id\n        self._in_flight_prompt_ids: set[str] = set()\n        self._status_seq = 0\n        self._worker_id: str | None = None\n        self._status = SessionStatus(\n            session_id=self.session_id,\n            state=\"stopped\",\n            seq=self._status_seq,\n            worker_id=self._worker_id,\n            reason=None,\n            detail=None,\n            updated_at=datetime.now(UTC),\n        )\n        self._process: asyncio.subprocess.Process | None = None\n        self._websockets: set[WebSocket] = set()\n        self._websocket_count = 0\n        self._replay_buffers: dict[WebSocket, list[str]] = {}\n        self._read_task: asyncio.Task[None] | None = None\n        self._expecting_exit = False\n        self._lock = asyncio.Lock()\n        self._ws_lock = asyncio.Lock()\n        self._sent_files: set[str] = set()\n\n    @property\n    def is_alive(self) -> bool:\n        \"\"\"Whether the worker subprocess exists and has not exited.\"\"\"\n        process = self._process\n        return process is not None and process.returncode is None\n\n    @property\n    def is_running(self) -> bool:\n        \"\"\"Backward-compatible name: indicates worker liveness.\"\"\"\n        return self.is_alive\n\n    @property\n    def is_busy(self) -> bool:\n        \"\"\"Whether the session is currently processing a prompt.\"\"\"\n        return len(self._in_flight_prompt_ids) > 0\n\n    @property\n    def status(self) -> SessionStatus:\n        \"\"\"Current runtime status snapshot.\"\"\"\n        return self._status\n\n    @property\n    def websocket_count(self) -> int:\n        \"\"\"Get the number of connected WebSockets.\"\"\"\n        return self._websocket_count\n\n    async def send_status_snapshot(self, ws: WebSocket) -> None:\n        \"\"\"Send the current status snapshot to a specific WebSocket.\"\"\"\n        await ws.send_text(new_session_status_message(self._status).model_dump_json())\n\n    def _build_status(\n        self,\n        state: SessionState,\n        reason: str | None,\n        detail: str | None,\n    ) -> SessionStatus | None:\n        \"\"\"Build a new status object if different from current.\"\"\"\n        current = self._status\n        if (\n            current.state == state\n            and current.reason == reason\n            and current.detail == detail\n            and current.worker_id == self._worker_id\n        ):\n            return None\n        self._status_seq += 1\n        status = SessionStatus(\n            session_id=self.session_id,\n            state=state,\n            seq=self._status_seq,\n            worker_id=self._worker_id,\n            reason=reason,\n            detail=detail,\n            updated_at=datetime.now(UTC),\n        )\n        self._status = status\n        return status\n\n    async def _emit_status(\n        self,\n        state: SessionState,\n        *,\n        reason: str | None = None,\n        detail: str | None = None,\n    ) -> None:\n        \"\"\"Emit a status update if different from current.\"\"\"\n        status = self._build_status(state, reason, detail)\n        if status is None:\n            return\n        await self._broadcast(new_session_status_message(status).model_dump_json())\n\n    async def start(\n        self,\n        *,\n        reason: str | None = None,\n        detail: str | None = None,\n        restart_started_at: float | None = None,\n    ) -> None:\n        \"\"\"Start the KimiCLI subprocess.\"\"\"\n        async with self._lock:\n            if self.is_alive:\n                if self._read_task is None or self._read_task.done():\n                    self._read_task = asyncio.create_task(self._read_loop())\n                return\n\n            self._in_flight_prompt_ids.clear()\n            self._expecting_exit = False\n            self._worker_id = str(uuid4())\n\n            # 16MB buffer for large messages (e.g., base64-encoded images)\n            STREAM_LIMIT = 16 * 1024 * 1024\n\n            if getattr(sys, \"frozen\", False):\n                worker_cmd = [sys.executable, \"__web-worker\", str(self.session_id)]\n            else:\n                worker_cmd = [\n                    sys.executable,\n                    \"-m\",\n                    \"kimi_cli.web.runner.worker\",\n                    str(self.session_id),\n                ]\n\n            self._process = await asyncio.create_subprocess_exec(\n                *worker_cmd,\n                stdin=asyncio.subprocess.PIPE,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n                limit=STREAM_LIMIT,\n                env=get_clean_env(),\n            )\n\n            self._read_task = asyncio.create_task(self._read_loop())\n            if restart_started_at is not None:\n                elapsed_ms = int((time.perf_counter() - restart_started_at) * 1000)\n                detail = f\"restart_ms={elapsed_ms}\"\n                await self._emit_status(\"idle\", reason=reason or \"start\", detail=detail)\n                await self._emit_restart_notice(reason=reason, restart_ms=elapsed_ms)\n            else:\n                await self._emit_status(\"idle\", reason=reason or \"start\", detail=None)\n\n    async def stop(self) -> None:\n        \"\"\"Stop the session: terminate worker and close all WebSockets.\"\"\"\n        await self.stop_worker(reason=\"stop\")\n        await self._close_all_websockets()\n\n    async def stop_worker(\n        self,\n        *,\n        reason: str | None = None,\n        emit_status: bool = True,\n    ) -> None:\n        \"\"\"Stop only the worker subprocess, keeping WebSockets connected.\"\"\"\n        async with self._lock:\n            self._expecting_exit = True\n            if self._process is not None:\n                if self._process.returncode is None:\n                    self._process.terminate()\n                try:\n                    await asyncio.wait_for(self._process.wait(), timeout=10.0)\n                except TimeoutError:\n                    self._process.kill()\n                    await self._process.wait()\n                self._process = None\n\n            if self._read_task is not None:\n                self._read_task.cancel()\n                with contextlib.suppress(asyncio.CancelledError):\n                    await self._read_task\n                self._read_task = None\n\n            self._in_flight_prompt_ids.clear()\n            self._worker_id = None\n            self._expecting_exit = False\n            if emit_status:\n                await self._emit_status(\"stopped\", reason=reason or \"stop\")\n\n    async def restart_worker(self, *, reason: str | None = None) -> None:\n        \"\"\"Restart the worker subprocess without disconnecting WebSockets.\"\"\"\n        started_at = time.perf_counter()\n        await self._emit_status(\"restarting\", reason=reason or \"restart\")\n        await self.stop_worker(reason=\"restart\", emit_status=False)\n        await self.start(reason=reason or \"restart\", restart_started_at=started_at)\n\n    async def _emit_restart_notice(self, *, reason: str | None, restart_ms: int) -> None:\n        \"\"\"Emit a restart notice to all WebSockets.\"\"\"\n        label = \"Session restarted\"\n        if reason == \"config_update\":\n            label = \"Session restarted due to config update\"\n        payload = SessionNoticePayload(\n            text=f\"{label} · {restart_ms}ms\",\n            kind=\"restart\",\n            reason=reason,\n            restart_ms=restart_ms,\n        )\n        event = SessionNoticeEvent(payload=payload)\n        await self._broadcast(\n            json.dumps(\n                {\n                    \"jsonrpc\": \"2.0\",\n                    \"method\": \"event\",\n                    \"params\": event.model_dump(mode=\"json\"),\n                },\n                ensure_ascii=False,\n            )\n        )\n\n    async def _read_loop(self) -> None:\n        \"\"\"Read messages from subprocess stdout and broadcast to WebSockets.\"\"\"\n        assert self._process is not None\n        assert self._process.stdout is not None\n        assert self._process.stderr is not None\n\n        try:\n            while True:\n                line = await self._process.stdout.readline()\n                if not line:\n                    if self._process.stdout.at_eof():\n                        if self._expecting_exit:\n                            break\n                        stderr = await self._process.stderr.read()\n                        if not stderr:\n                            stderr = b\"No stderr\"\n                        await self._broadcast(\n                            JSONRPCErrorResponse(\n                                id=str(uuid4()),\n                                error=JSONRPCErrorObject(\n                                    code=self._process.returncode or -1,\n                                    message=stderr.decode(\"utf-8\"),\n                                ),\n                            ).model_dump_json()\n                        )\n                        logger.warning(\n                            f\"Process exited with {self._process.returncode}: \"\n                            f\"{stderr.decode('utf-8')}\"\n                        )\n                        self._in_flight_prompt_ids.clear()\n                        await self._emit_status(\n                            \"error\",\n                            reason=\"process_exit\",\n                            detail=stderr.decode(\"utf-8\"),\n                        )\n                        break\n                    else:\n                        continue\n\n                await self._broadcast(line.decode(\"utf-8\").rstrip(\"\\n\"))\n\n                # Handle out message\n                try:\n                    msg = json.loads(line)\n                    match msg.get(\"method\"):\n                        case \"event\":\n                            msg[\"params\"] = deserialize_wire_message(msg[\"params\"])\n                            await self._handle_out_message(JSONRPCEventMessage.model_validate(msg))\n                        case \"request\":\n                            msg[\"params\"] = deserialize_wire_message(msg[\"params\"])\n                            await self._handle_out_message(\n                                JSONRPCRequestMessage.model_validate(msg)\n                            )\n                        case _:\n                            if msg.get(\"error\"):\n                                await self._handle_out_message(\n                                    JSONRPCErrorResponse.model_validate(msg)\n                                )\n                            else:\n                                await self._handle_out_message(\n                                    JSONRPCSuccessResponse.model_validate(msg)\n                                )\n                except json.JSONDecodeError:\n                    logger.error(f\"Invalid JSONRPC out message: {line}\")\n\n        except asyncio.CancelledError:\n            raise\n        except Exception as e:\n            logger.warning(f\"Unexpected error in read loop: {e.__class__.__name__} {e}\")\n\n    async def _handle_out_message(self, message: JSONRPCOutMessage) -> None:\n        \"\"\"Handle outbound message from worker.\"\"\"\n        match message:\n            case JSONRPCSuccessResponse():\n                was_busy = self.is_busy\n                if message.id in self._in_flight_prompt_ids:\n                    self._in_flight_prompt_ids.remove(message.id)\n                if was_busy and not self.is_busy:\n                    await self._emit_status(\"idle\", reason=\"prompt_complete\")\n            case JSONRPCErrorResponse():\n                was_busy = self.is_busy\n                if message.id in self._in_flight_prompt_ids:\n                    self._in_flight_prompt_ids.remove(message.id)\n                if was_busy and not self.is_busy:\n                    await self._emit_status(\"idle\", reason=\"prompt_error\")\n            case _:\n                return\n\n    async def _encode_uploaded_files(self) -> AsyncGenerator[ContentPart]:\n        \"\"\"Encode uploaded files for sending to the model.\"\"\"\n        session = load_session_by_id(self.session_id)\n        assert session is not None\n\n        uploads_dir = session.kimi_cli_session.dir / \"uploads\"\n        if not uploads_dir.exists():\n            return\n\n        # Load .sent marker left by fork to avoid re-sending inherited files.\n        # The marker is kept (not deleted) so it survives process restarts.\n        sent_marker = uploads_dir / \".sent\"\n        if sent_marker.exists():\n            try:\n                already_sent = json.loads(sent_marker.read_text(encoding=\"utf-8\"))\n                self._sent_files.update(already_sent)\n            except Exception:\n                pass\n\n        all_files = sorted(\n            (f for f in uploads_dir.iterdir() if f.name != \".sent\"),\n            key=lambda x: x.name,\n        )\n        files = [f for f in all_files if f.name not in self._sent_files]\n\n        if not files:\n            return\n\n        # Build file list with paths and mime types\n        file_infos: list[tuple[Path, str]] = []\n        for file in files:\n            mime_type, _ = mimetypes.guess_type(file.name)\n            file_infos.append((file, mime_type or \"application/octet-stream\"))\n\n        # Output file list summary\n        file_list_lines = [\"<uploaded_files>\"]\n        for idx, (file, _) in enumerate(file_infos, start=1):\n            file_list_lines.append(f\"{idx}. {file}\")\n        file_list_lines.append(\"</uploaded_files>\")\n        yield TextPart(text=\"\\n\".join(file_list_lines) + \"\\n\\n\")\n\n        # Text file extensions\n        text_extensions = {\n            \".txt\",\n            \".md\",\n            \".json\",\n            \".yaml\",\n            \".yml\",\n            \".xml\",\n            \".html\",\n            \".css\",\n            \".js\",\n            \".ts\",\n            \".py\",\n            \".sh\",\n            \".csv\",\n            \".log\",\n            \".rst\",\n            \".toml\",\n            \".ini\",\n        }\n\n        # Check model capabilities\n        config = load_config()\n        capabilities: set[ModelCapability] = set()\n        if config.default_model:\n            capabilities = config.models[config.default_model].capabilities or set()\n        is_vision = \"image_in\" in capabilities\n        is_video_in = \"video_in\" in capabilities\n\n        # Process each file\n        for file, mime_type in file_infos:\n            file_path = str(file)\n            ext = file.suffix.lower()\n\n            if is_vision and mime_type.startswith(\"image/\"):\n                try:\n                    content = file.read_bytes()\n                    with Image.open(io.BytesIO(content)) as img:\n                        pil_img: PILImage = img\n                        width, height = pil_img.size\n                        max_side = max(width, height)\n                        if max_side > 4096:\n                            scale = 4096 / max_side\n                            new_size = (int(width * scale), int(height * scale))\n                            pil_img = pil_img.resize(  # pyright: ignore[reportUnknownMemberType]\n                                new_size\n                            )\n                        buffer = io.BytesIO()\n                        pil_img.save(buffer, format=\"PNG\")\n                        encoded = base64.b64encode(buffer.getvalue()).decode(\"ascii\")\n                        tag = f'<image path=\"{file_path}\" content_type=\"{mime_type}\">'\n                        yield TextPart(text=tag)\n                        yield ImageURLPart(\n                            image_url=ImageURLPart.ImageURL(url=f\"data:image/png;base64,{encoded}\")\n                        )\n                        yield TextPart(text=\"</image>\\n\\n\")\n                except Exception:\n                    # Skip files that fail to encode - don't block the upload\n                    pass\n            elif is_video_in and mime_type.startswith(\"video/\"):\n                # For video files, emit a <video> tag for frontend display but don't embed content.\n                # The agent will use ReadMediaFile tool to read it, which handles video uploads\n                # properly.\n                yield TextPart(text=f'<video path=\"{file_path}\" content_type=\"{mime_type}\">')\n                yield TextPart(text=\"</video>\\n\\n\")\n            elif ext in text_extensions or mime_type.startswith(\"text/\"):\n                try:\n                    content = file.read_bytes()\n                    text_content = content.decode(\"utf-8\", errors=\"replace\")\n                    yield TextPart(text=f'<document path=\"{file_path}\" content_type=\"{mime_type}\">')\n                    yield TextPart(text=text_content)\n                    yield TextPart(text=\"</document>\\n\\n\")\n                except Exception:\n                    # Skip files that fail to decode - don't block the upload\n                    pass\n\n        # Mark files as sent\n        for file in files:\n            self._sent_files.add(file.name)\n\n    async def _handle_in_message(self, message: JSONRPCInMessage) -> str | None:\n        \"\"\"Handle inbound message to worker, encoding uploaded files.\"\"\"\n        match message:\n            case JSONRPCPromptMessage():\n                user_input: list[ContentPart] = []\n                async for part in self._encode_uploaded_files():\n                    user_input.append(part)\n                # Special marker for file-only uploads\n                if isinstance(message.params.user_input, str):\n                    if message.params.user_input != \"KIMI_FILE_UPLOAD_WITHOUT_MESSAGE\":\n                        user_input.append(TextPart(text=message.params.user_input))\n                else:\n                    user_input += message.params.user_input\n                return json.dumps(\n                    {\n                        \"jsonrpc\": \"2.0\",\n                        \"method\": \"prompt\",\n                        \"id\": message.id,\n                        \"params\": {\n                            \"user_input\": [part.model_dump(mode=\"json\") for part in user_input],\n                        },\n                    },\n                    ensure_ascii=False,\n                )\n            case _:\n                return None\n        return None\n\n    async def _broadcast(self, message: str) -> None:\n        \"\"\"Broadcast a message to all connected WebSockets.\"\"\"\n        disconnected: set[WebSocket] = set()\n\n        async with self._ws_lock:\n            websockets = list(self._websockets)\n            to_send: list[WebSocket] = []\n            for ws in websockets:\n                buffer = self._replay_buffers.get(ws)\n                if buffer is not None:\n                    buffer.append(message)\n                else:\n                    to_send.append(ws)\n\n        for ws in to_send:\n            try:\n                if ws.client_state == WebSocketState.CONNECTED:\n                    await ws.send_text(message)\n                else:\n                    disconnected.add(ws)\n            except Exception as e:\n                logger.warning(f\"websocket failed: {e.__class__.__name__} {e}\")\n                disconnected.add(ws)\n\n        if disconnected:\n            async with self._ws_lock:\n                self._websockets -= disconnected\n                self._websocket_count = len(self._websockets)\n                for ws in disconnected:\n                    self._replay_buffers.pop(ws, None)\n            logger.debug(\n                f\"Broadcast: removed {len(disconnected)} disconnected ws, \"\n                f\"remaining={self._websocket_count}\"\n            )\n\n    async def add_websocket_and_begin_replay(self, ws: WebSocket) -> None:\n        \"\"\"Atomically attach a WebSocket and enter replay mode for it.\"\"\"\n        async with self._ws_lock:\n            if ws not in self._websockets:\n                self._websockets.add(ws)\n                self._websocket_count = len(self._websockets)\n            self._replay_buffers.setdefault(ws, [])\n        logger.debug(f\"WebSocket added (replay mode), count={self._websocket_count}\")\n\n    async def end_replay(self, ws: WebSocket) -> None:\n        \"\"\"Flush buffered live messages for a websocket after history replay.\"\"\"\n        while True:\n            async with self._ws_lock:\n                buffer = self._replay_buffers.get(ws)\n                if buffer is None:\n                    return\n                if not buffer:\n                    self._replay_buffers.pop(ws, None)\n                    return\n                chunk = buffer.copy()\n                buffer.clear()\n\n            if ws.client_state != WebSocketState.CONNECTED:\n                logger.warning(\"end_replay: ws not connected, cleaning up replay buffer\")\n                async with self._ws_lock:\n                    self._replay_buffers.pop(ws, None)\n                return\n            for message in chunk:\n                try:\n                    await ws.send_text(message)\n                except Exception as e:\n                    # Send failed — pop the replay buffer so _broadcast()\n                    # sends directly (or detects disconnect) on the next call.\n                    # Do NOT remove ws from _websockets here; let _broadcast()\n                    # or session_stream's finally block handle cleanup.\n                    logger.warning(f\"end_replay: send_text failed during buffer flush: {e}\")\n                    async with self._ws_lock:\n                        self._replay_buffers.pop(ws, None)\n                    return\n\n    async def _close_all_websockets(self) -> None:\n        \"\"\"Close all connected WebSockets.\"\"\"\n        async with self._ws_lock:\n            websockets = list(self._websockets)\n            self._websockets.clear()\n            self._websocket_count = 0\n            self._replay_buffers.clear()\n\n        for ws in websockets:\n            try:\n                if ws.client_state == WebSocketState.CONNECTED:\n                    await ws.close(code=1001, reason=\"Session process exited\")\n            except Exception:\n                # Ignore errors closing already-disconnected WebSockets\n                pass\n\n    async def remove_websocket(self, ws: WebSocket) -> None:\n        \"\"\"Remove a WebSocket connection from this session.\"\"\"\n        async with self._ws_lock:\n            if ws in self._websockets:\n                self._websockets.discard(ws)\n                self._websocket_count = len(self._websockets)\n                logger.debug(f\"WebSocket removed, count={self._websocket_count}\")\n            self._replay_buffers.pop(ws, None)\n\n    async def send_message(self, message: str) -> None:\n        \"\"\"Send a message to the subprocess stdin.\"\"\"\n        await self.start()\n        process = self._process\n        assert process is not None\n        assert process.stdin is not None\n\n        # Handle in message\n        try:\n            in_message = JSONRPCInMessageAdapter.validate_json(message)\n            if isinstance(in_message, JSONRPCPromptMessage):\n                was_busy = self.is_busy\n                self._in_flight_prompt_ids.add(in_message.id)\n                if not was_busy:\n                    await self._emit_status(\"busy\", reason=\"prompt\")\n            elif isinstance(in_message, JSONRPCCancelMessage) and not self.is_busy:\n                # If not busy, return success to avoid errors\n                await self._broadcast(\n                    JSONRPCSuccessResponse(id=in_message.id, result={}).model_dump_json()\n                )\n                return\n\n            new_message = await self._handle_in_message(in_message)\n            if new_message is not None:\n                message = new_message\n        except ValueError as e:\n            logger.error(f\"{e.__class__.__name__} {e}: Invalid JSONRPC in message: {message}\")\n            return\n\n        process.stdin.write((message + \"\\n\").encode(\"utf-8\"))\n        await process.stdin.drain()\n\n\nclass KimiCLIRunner:\n    \"\"\"Manages multiple session processes.\"\"\"\n\n    def __init__(self) -> None:\n        \"\"\"Initialize the runner.\"\"\"\n        self._sessions: dict[UUID, SessionProcess] = {}\n        self._lock = asyncio.Lock()\n\n    def start(self) -> None:\n        \"\"\"Start the runner (no-op, sessions started on demand).\"\"\"\n        pass\n\n    async def stop(self) -> None:\n        \"\"\"Stop all running sessions.\"\"\"\n        tasks: list[asyncio.Task[None]] = []\n        for session in self._sessions.values():\n            if session.is_running:\n                tasks.append(asyncio.create_task(session.stop()))\n        if tasks:\n            _, pending = await asyncio.wait(tasks, timeout=5.0)\n            for t in pending:\n                t.cancel()\n                with contextlib.suppress(asyncio.CancelledError):\n                    await t\n\n    async def get_or_create_session(self, session_id: UUID) -> SessionProcess:\n        \"\"\"Get or create a session process.\"\"\"\n        async with self._lock:\n            if session_id not in self._sessions:\n                self._sessions[session_id] = SessionProcess(session_id)\n            return self._sessions[session_id]\n\n    def get_session(self, session_id: UUID) -> SessionProcess | None:\n        \"\"\"Get a session process if it exists.\"\"\"\n        return self._sessions.get(session_id)\n\n    async def detach_websocket(self, ws: WebSocket, session_id: UUID) -> None:\n        \"\"\"Detach a WebSocket from a session.\"\"\"\n        async with self._lock:\n            session = self._sessions.get(session_id)\n            if session:\n                await session.remove_websocket(ws)\n\n    async def restart_running_workers(\n        self,\n        *,\n        reason: str,\n        force: bool,\n    ) -> RestartWorkersSummary:\n        \"\"\"Restart all running workers to apply global config updates.\n\n        Args:\n            reason: Reason for the restart (e.g., \"config_update\")\n            force: If True, also restart busy sessions (may interrupt prompts)\n\n        Returns:\n            Summary of restarted and skipped sessions\n        \"\"\"\n        async with self._lock:\n            running = [(sid, proc) for sid, proc in self._sessions.items() if proc.is_running]\n\n        restarted: list[UUID] = []\n        skipped_busy: list[UUID] = []\n        tasks: list[asyncio.Task[None]] = []\n\n        for session_id, proc in running:\n            if proc.is_busy and not force:\n                skipped_busy.append(session_id)\n                continue\n            restarted.append(session_id)\n            tasks.append(asyncio.create_task(proc.restart_worker(reason=reason)))\n\n        if tasks:\n            await asyncio.gather(*tasks, return_exceptions=True)\n\n        return RestartWorkersSummary(\n            restarted_session_ids=restarted,\n            skipped_busy_session_ids=skipped_busy,\n        )\n\n\n@dataclass(slots=True)\nclass RestartWorkersSummary:\n    \"\"\"Summary of a restart_running_workers operation.\"\"\"\n\n    restarted_session_ids: list[UUID]\n    skipped_busy_session_ids: list[UUID]\n"
  },
  {
    "path": "src/kimi_cli/web/runner/worker.py",
    "content": "\"\"\"Worker module for running KimiCLI in a subprocess.\n\nThis module is the entry point for the subprocess that runs KimiCLI in wire mode.\nIt reads the session configuration from disk and runs KimiCLI.run_wire_stdio().\n\nUsage:\n    python -m kimi_cli.web.runner.worker <session_id>\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport sys\nfrom typing import Any\nfrom uuid import UUID\n\nfrom kimi_cli import logger\nfrom kimi_cli.app import KimiCLI, enable_logging\nfrom kimi_cli.cli.mcp import get_global_mcp_config_file\nfrom kimi_cli.exception import MCPConfigError\nfrom kimi_cli.web.store.sessions import load_session_by_id\n\n\nasync def run_worker(session_id: UUID) -> None:\n    \"\"\"Run the KimiCLI worker for a session.\"\"\"\n    # Find session by ID using the web store\n    joint_session = load_session_by_id(session_id)\n    if joint_session is None:\n        raise ValueError(f\"Session not found: {session_id}\")\n\n    # Get the kimi-cli session object\n    session = joint_session.kimi_cli_session\n\n    # Load default MCP config file if it exists\n    default_mcp_file = get_global_mcp_config_file()\n    mcp_configs: list[dict[str, Any]] = []\n    if default_mcp_file.exists():\n        raw = default_mcp_file.read_text(encoding=\"utf-8\")\n        try:\n            mcp_configs = [json.loads(raw)]\n        except json.JSONDecodeError:\n            logger.warning(\n                \"Invalid JSON in MCP config file: {path}\",\n                path=default_mcp_file,\n            )\n\n    # Create KimiCLI instance with MCP configuration\n    try:\n        kimi_cli = await KimiCLI.create(session, mcp_configs=mcp_configs or None)\n    except MCPConfigError as exc:\n        logger.warning(\n            \"Invalid MCP config in {path}: {error}. Starting without MCP.\",\n            path=default_mcp_file,\n            error=exc,\n        )\n        kimi_cli = await KimiCLI.create(session, mcp_configs=None)\n\n    # Run in wire stdio mode\n    await kimi_cli.run_wire_stdio()\n\n\ndef main() -> None:\n    \"\"\"Entry point for the worker subprocess.\"\"\"\n    from kimi_cli.utils.proctitle import set_process_title\n\n    set_process_title(\"kimi-code-worker\")\n\n    if len(sys.argv) < 2:\n        print(\"Usage: python -m kimi_cli.web.runner.worker <session_id>\", file=sys.stderr)\n        sys.exit(1)\n\n    try:\n        session_id = UUID(sys.argv[1])\n    except ValueError:\n        print(f\"Invalid session ID: {sys.argv[1]}\", file=sys.stderr)\n        sys.exit(1)\n\n    # Enable logging for the subprocess\n    enable_logging(debug=False)\n\n    # Run the async worker\n    asyncio.run(run_worker(session_id))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/kimi_cli/web/store/__init__.py",
    "content": "\"\"\"Session storage.\"\"\"\n"
  },
  {
    "path": "src/kimi_cli/web/store/sessions.py",
    "content": "\"\"\"Session storage with simple in-memory caching for web UI.\n\n## Design Philosophy\n\nThis module uses a simple cache-aside pattern with TTL fallback:\n\n1. **Cache on read**: First read populates cache, subsequent reads hit cache\n2. **Invalidate on write**: API mutations call invalidate_sessions_cache()\n3. **TTL fallback**: Cache expires after CACHE_TTL seconds as safety net\n\n## Applicable Scope\n\nThis design works well when:\n- Single worker process (e.g., `uvicorn app:app` without -w flag)\n- All mutations go through the same API\n- Occasional staleness (up to CACHE_TTL) from external changes is acceptable\n\"\"\"\n\nfrom __future__ import annotations\n\nimport time\nfrom dataclasses import dataclass\nfrom datetime import UTC, datetime\nfrom pathlib import Path\nfrom uuid import UUID\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\nfrom kimi_cli.metadata import WorkDirMeta, load_metadata\nfrom kimi_cli.session import Session as KimiCLISession\nfrom kimi_cli.web.models import Session\nfrom kimi_cli.wire.file import WireFile\n\n# Cache configuration\nCACHE_TTL = 5.0  # seconds - balance between freshness and performance\nSESSION_METADATA_FILENAME = \"metadata.json\"\n\n# Auto-archive configuration\nAUTO_ARCHIVE_DAYS = 15  # Sessions older than this will be auto-archived\n\n_sessions_cache: list[JointSession] | None = None\n_cache_timestamp: float = 0.0\n_sessions_index_cache: list[SessionIndexEntry] | None = None\n_index_cache_timestamp: float = 0.0\n\n\ndef invalidate_sessions_cache() -> None:\n    \"\"\"Clear the sessions cache.\n\n    Call this after any mutation (create/update/delete).\n    This ensures the next read sees fresh data.\n    \"\"\"\n    global _sessions_cache, _cache_timestamp, _sessions_index_cache, _index_cache_timestamp\n    _sessions_cache = None\n    _cache_timestamp = 0.0\n    _sessions_index_cache = None\n    _index_cache_timestamp = 0.0\n\n\nclass JointSession(Session):\n    \"\"\"Combined session model with both web UI and kimi-cli session data.\"\"\"\n\n    model_config = ConfigDict(arbitrary_types_allowed=True)\n\n    kimi_cli_session: KimiCLISession = Field(exclude=True)\n\n\nclass SessionMetadata(BaseModel):\n    \"\"\"Session metadata stored in metadata.json.\"\"\"\n\n    session_id: str\n    title: str = \"Untitled\"\n    title_generated: bool = False\n    title_generate_attempts: int = 0\n    wire_mtime: float | None = None\n    archived: bool = False\n    archived_at: float | None = None\n    auto_archive_exempt: bool = False  # True if user manually unarchived, exempt from auto-archive\n\n\n@dataclass(slots=True)\nclass SessionIndexEntry:\n    session_id: UUID\n    session_dir: Path\n    context_file: Path\n    work_dir: str\n    work_dir_meta: WorkDirMeta\n    last_updated: datetime\n    title: str\n    metadata: SessionMetadata | None\n\n\ndef load_session_metadata(session_dir: Path, session_id: str) -> SessionMetadata:\n    \"\"\"Load session metadata from metadata.json, or create default if not exists.\"\"\"\n    metadata_file = session_dir / SESSION_METADATA_FILENAME\n    if not metadata_file.exists():\n        return SessionMetadata(session_id=session_id)\n    try:\n        import json\n\n        data = json.loads(metadata_file.read_text(encoding=\"utf-8\"))\n        # Ensure session_id is set\n        data[\"session_id\"] = session_id\n        return SessionMetadata.model_validate(data)\n    except Exception:\n        return SessionMetadata(session_id=session_id)\n\n\ndef save_session_metadata(session_dir: Path, metadata: SessionMetadata) -> None:\n    \"\"\"Save session metadata to metadata.json.\"\"\"\n    if not session_dir.exists():\n        return\n    metadata_file = session_dir / SESSION_METADATA_FILENAME\n    try:\n        import json\n\n        metadata_file.write_text(\n            json.dumps(metadata.model_dump(), ensure_ascii=False, indent=2),\n            encoding=\"utf-8\",\n        )\n    except Exception:\n        pass\n\n\ndef _derive_title_from_wire(session_dir: Path) -> str:\n    wire_file = session_dir / \"wire.jsonl\"\n    if not wire_file.exists():\n        return \"Untitled\"\n\n    try:\n        import json\n        from textwrap import shorten\n\n        from kosong.message import Message\n\n        with open(wire_file, encoding=\"utf-8\") as f:\n            for line in f:\n                line = line.strip()\n                if not line:\n                    continue\n                try:\n                    record = json.loads(line)\n                    message = record.get(\"message\", {})\n                    if message.get(\"type\") == \"TurnBegin\":\n                        user_input = message.get(\"payload\", {}).get(\"user_input\")\n                        if user_input:\n                            msg = Message(role=\"user\", content=user_input)\n                            text = msg.extract_text(\" \")\n                            return shorten(text, width=300)\n                except json.JSONDecodeError:\n                    continue\n    except Exception:\n        pass\n    return \"Untitled\"\n\n\ndef _iter_session_dirs(wd: WorkDirMeta) -> list[tuple[Path, Path]]:\n    session_dirs: list[tuple[Path, Path]] = []\n\n    # Latest sessions\n    for context_file in wd.sessions_dir.glob(\"*/context.jsonl\"):\n        session_dir = context_file.parent\n        session_dirs.append((session_dir, context_file))\n\n    # Legacy sessions\n    for context_file in wd.sessions_dir.glob(\"*.jsonl\"):\n        session_dir = context_file.parent / context_file.stem\n        converted_context_file = session_dir / \"context.jsonl\"\n        if converted_context_file.exists():\n            continue\n        session_dirs.append((session_dir, context_file))\n\n    return session_dirs\n\n\ndef _ensure_title(entry: SessionIndexEntry, *, refresh: bool) -> None:\n    \"\"\"Ensure session has a title, updating metadata if needed.\n\n    Logic:\n    - If title exists and is not \"Untitled\": only update wire_mtime if changed\n    - If title is empty or \"Untitled\": derive from wire.jsonl, don't touch title_generated\n    \"\"\"\n    session_id_str = str(entry.session_id)\n    wire_file = entry.session_dir / \"wire.jsonl\"\n    wire_mtime = wire_file.stat().st_mtime if wire_file.exists() else None\n\n    # Load or create metadata\n    metadata = entry.metadata\n    if metadata is None:\n        metadata = load_session_metadata(entry.session_dir, session_id_str)\n        entry.metadata = metadata\n\n    # Case 1: title exists and is not \"Untitled\" - only update wire_mtime\n    if metadata.title and metadata.title != \"Untitled\":\n        entry.title = metadata.title\n        if metadata.wire_mtime != wire_mtime:\n            metadata = metadata.model_copy(update={\"wire_mtime\": wire_mtime})\n            save_session_metadata(entry.session_dir, metadata)\n            entry.metadata = metadata\n        return\n\n    # Case 2: title is empty or \"Untitled\"\n    # If not refreshing, just use current title\n    if not refresh:\n        entry.title = metadata.title if metadata.title else \"Untitled\"\n        return\n\n    # Derive title from wire.jsonl\n    title = _derive_title_from_wire(entry.session_dir)\n    entry.title = title\n\n    # Update metadata: set title and wire_mtime, but keep title_generated unchanged\n    metadata = metadata.model_copy(\n        update={\n            \"title\": title,\n            \"wire_mtime\": wire_mtime,\n        }\n    )\n    save_session_metadata(entry.session_dir, metadata)\n    entry.metadata = metadata\n\n\ndef _build_kimi_session(entry: SessionIndexEntry) -> KimiCLISession:\n    from kaos.path import KaosPath\n\n    from kimi_cli.session_state import load_session_state\n\n    return KimiCLISession(\n        id=str(entry.session_id),\n        work_dir=KaosPath.unsafe_from_local_path(Path(entry.work_dir)),\n        work_dir_meta=entry.work_dir_meta,\n        context_file=entry.context_file,\n        wire_file=WireFile(entry.session_dir / \"wire.jsonl\"),\n        state=load_session_state(entry.session_dir),\n        title=entry.title,\n        updated_at=entry.last_updated.timestamp(),\n    )\n\n\ndef _build_joint_session(entry: SessionIndexEntry) -> JointSession:\n    kimi_session = _build_kimi_session(entry)\n    archived = entry.metadata.archived if entry.metadata else False\n    return JointSession(\n        session_id=entry.session_id,\n        title=entry.title,\n        last_updated=entry.last_updated,\n        is_running=False,\n        status=None,\n        work_dir=entry.work_dir,\n        session_dir=str(entry.session_dir),\n        kimi_cli_session=kimi_session,\n        archived=archived,\n    )\n\n\ndef _should_auto_archive(last_updated: datetime, session_metadata: SessionMetadata) -> bool:\n    \"\"\"Check if a session should be auto-archived based on age and exemption status.\"\"\"\n    # Already archived, no need to auto-archive\n    if session_metadata.archived:\n        return False\n\n    # User manually unarchived this session, exempt from auto-archive\n    if session_metadata.auto_archive_exempt:\n        return False\n\n    # Check if session is older than AUTO_ARCHIVE_DAYS\n    now = datetime.now(tz=UTC)\n    age_days = (now - last_updated).days\n    return age_days >= AUTO_ARCHIVE_DAYS\n\n\ndef _build_sessions_index() -> list[SessionIndexEntry]:\n    \"\"\"Build the sessions index from disk.\n\n    Note: This function only reads data and does NOT perform auto-archive writes.\n    Auto-archive is handled separately by run_auto_archive() to avoid disk writes\n    during read operations.\n    \"\"\"\n    metadata = load_metadata()\n    entries: list[SessionIndexEntry] = []\n\n    for wd in metadata.work_dirs:\n        for session_dir, context_file in _iter_session_dirs(wd):\n            try:\n                session_id = UUID(session_dir.name)\n            except (ValueError, AttributeError, TypeError):\n                continue\n\n            if not context_file.exists():\n                continue\n\n            last_updated = datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC)\n            session_metadata = load_session_metadata(session_dir, str(session_id))\n            title = session_metadata.title if session_metadata.title else \"Untitled\"\n\n            entries.append(\n                SessionIndexEntry(\n                    session_id=session_id,\n                    session_dir=session_dir,\n                    context_file=context_file,\n                    work_dir=wd.path,\n                    work_dir_meta=wd,\n                    last_updated=last_updated,\n                    title=title,\n                    metadata=session_metadata,\n                )\n            )\n\n    entries.sort(key=lambda x: (x.last_updated, str(x.session_id)), reverse=True)\n    return entries\n\n\n# Track when auto-archive was last run to avoid running too frequently\n_last_auto_archive_time: float = 0.0\nAUTO_ARCHIVE_INTERVAL = 300.0  # Run auto-archive at most once every 5 minutes\n\n\ndef run_auto_archive() -> int:\n    \"\"\"Run auto-archive on old sessions.\n\n    This function is designed to be called periodically (e.g., on app startup,\n    or via a background task) rather than on every read operation.\n\n    Returns:\n        Number of sessions that were auto-archived.\n    \"\"\"\n    global _last_auto_archive_time\n\n    now = time.time()\n    if now - _last_auto_archive_time < AUTO_ARCHIVE_INTERVAL:\n        return 0\n\n    _last_auto_archive_time = now\n    archived_count = 0\n\n    # Load fresh index (bypass cache to get current state)\n    entries = _build_sessions_index()\n\n    for entry in entries:\n        if entry.metadata is None:\n            continue\n\n        if _should_auto_archive(entry.last_updated, entry.metadata):\n            updated_metadata = entry.metadata.model_copy(\n                update={\n                    \"archived\": True,\n                    \"archived_at\": time.time(),\n                }\n            )\n            save_session_metadata(entry.session_dir, updated_metadata)\n            archived_count += 1\n\n    # Invalidate cache if we archived anything\n    if archived_count > 0:\n        invalidate_sessions_cache()\n\n    return archived_count\n\n\ndef _load_sessions_index_cached() -> list[SessionIndexEntry]:\n    global _sessions_index_cache, _index_cache_timestamp\n\n    now = time.time()\n    if _sessions_index_cache is not None and (now - _index_cache_timestamp) < CACHE_TTL:\n        return _sessions_index_cache\n\n    _sessions_index_cache = _build_sessions_index()\n    _index_cache_timestamp = now\n    return _sessions_index_cache\n\n\ndef load_all_sessions() -> list[JointSession]:\n    \"\"\"Load all sessions from all work directories.\"\"\"\n    entries = _load_sessions_index_cached()\n    sessions: list[JointSession] = []\n\n    for entry in entries:\n        _ensure_title(entry, refresh=False)\n        sessions.append(_build_joint_session(entry))\n\n    sessions.sort(key=lambda x: x.last_updated, reverse=True)\n    return sessions\n\n\ndef load_all_sessions_cached() -> list[JointSession]:\n    \"\"\"Cached version of load_all_sessions().\n\n    Returns cached data if:\n    - Cache exists AND\n    - Cache is younger than CACHE_TTL\n\n    Otherwise, refreshes from disk and updates cache.\n    \"\"\"\n    global _sessions_cache, _cache_timestamp\n\n    now = time.time()\n    if _sessions_cache is not None and (now - _cache_timestamp) < CACHE_TTL:\n        return _sessions_cache\n\n    _sessions_cache = load_all_sessions()\n    _cache_timestamp = now\n    return _sessions_cache\n\n\ndef load_sessions_page(\n    *,\n    limit: int = 100,\n    offset: int = 0,\n    query: str | None = None,\n    archived: bool | None = None,\n) -> list[JointSession]:\n    \"\"\"Load a paginated list of sessions, optionally filtered by query and archived status.\n\n    Args:\n        limit: Maximum number of sessions to return.\n        offset: Number of sessions to skip.\n        query: Optional search query to filter by title or work_dir.\n        archived: Filter by archived status.\n            - None (default): Only return non-archived sessions.\n            - True: Only return archived sessions.\n            - False: Only return non-archived sessions.\n    \"\"\"\n    entries = list(_load_sessions_index_cached())\n\n    # Filter by archived status\n    if archived is None or archived is False:\n        # Default: only non-archived sessions\n        entries = [e for e in entries if not (e.metadata and e.metadata.archived)]\n    else:\n        # Only archived sessions\n        entries = [e for e in entries if e.metadata and e.metadata.archived]\n\n    if query:\n        query_text = query.strip().lower()\n        if query_text:\n            for entry in entries:\n                _ensure_title(entry, refresh=True)\n            entries = [\n                entry\n                for entry in entries\n                if query_text in entry.title.lower() or query_text in (entry.work_dir or \"\").lower()\n            ]\n\n    if offset < 0:\n        offset = 0\n    if limit <= 0:\n        limit = 100\n\n    page_entries = entries[offset : offset + limit]\n\n    if not query:\n        for entry in page_entries:\n            if entry.metadata is None or not entry.title or entry.title == \"Untitled\":\n                _ensure_title(entry, refresh=True)\n\n    return [_build_joint_session(entry) for entry in page_entries]\n\n\ndef load_session_by_id(id: UUID) -> JointSession | None:\n    \"\"\"Load a session by ID.\n\n    This function first checks the cache/disk scan, then falls back to\n    directly constructing the session from metadata if not found (handles\n    newly created sessions with empty context files).\n    \"\"\"\n    global_metadata = load_metadata()\n    session_id_str = str(id)\n\n    for wd in global_metadata.work_dirs:\n        session_dir = wd.sessions_dir / session_id_str\n        context_file = session_dir / \"context.jsonl\"\n\n        if context_file.exists():\n            last_updated = datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC)\n            session_metadata = load_session_metadata(session_dir, session_id_str)\n            title = session_metadata.title if session_metadata.title else \"Untitled\"\n            entry = SessionIndexEntry(\n                session_id=id,\n                session_dir=session_dir,\n                context_file=context_file,\n                work_dir=wd.path,\n                work_dir_meta=wd,\n                last_updated=last_updated,\n                title=title,\n                metadata=session_metadata,\n            )\n            _ensure_title(entry, refresh=True)\n            return _build_joint_session(entry)\n\n        # Legacy sessions: context.jsonl stored directly in sessions_dir\n        legacy_context = wd.sessions_dir / f\"{session_id_str}.jsonl\"\n        if legacy_context.exists():\n            last_updated = datetime.fromtimestamp(legacy_context.stat().st_mtime, tz=UTC)\n            session_metadata = load_session_metadata(session_dir, session_id_str)\n            title = session_metadata.title if session_metadata.title else \"Untitled\"\n            entry = SessionIndexEntry(\n                session_id=id,\n                session_dir=session_dir,\n                context_file=legacy_context,\n                work_dir=wd.path,\n                work_dir_meta=wd,\n                last_updated=last_updated,\n                title=title,\n                metadata=session_metadata,\n            )\n            _ensure_title(entry, refresh=True)\n            return _build_joint_session(entry)\n\n    return None\n\n\nif __name__ == \"__main__\":\n    start_time = time.time()\n    sessions = load_all_sessions()\n    print(f\"Found {len(sessions)} Sessions in {time.time() - start_time:.2f} seconds:\")\n    for session in sessions:\n        print(session.last_updated, session.session_id, session.title)\n"
  },
  {
    "path": "src/kimi_cli/wire/__init__.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport copy\n\nfrom kosong.message import MergeableMixin\n\nfrom kimi_cli.utils.aioqueue import Queue, QueueShutDown\nfrom kimi_cli.utils.broadcast import BroadcastQueue\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire.file import WireFile\nfrom kimi_cli.wire.types import ContentPart, ToolCallPart, WireMessage, is_wire_message\n\nWireMessageQueue = BroadcastQueue[WireMessage]\n\n\nclass Wire:\n    \"\"\"\n    A spmc channel for communication between the soul and the UI during a soul run.\n    \"\"\"\n\n    def __init__(self, *, file_backend: WireFile | None = None):\n        self._raw_queue = WireMessageQueue()\n        self._merged_queue = WireMessageQueue()\n\n        self._soul_side = WireSoulSide(self._raw_queue, self._merged_queue)\n\n        if file_backend is not None:\n            # record all complete Wire messages to the file backend\n            self._recorder = _WireRecorder(file_backend, self._merged_queue.subscribe())\n        else:\n            self._recorder = None\n\n    @property\n    def soul_side(self) -> WireSoulSide:\n        return self._soul_side\n\n    def ui_side(self, *, merge: bool) -> WireUISide:\n        \"\"\"\n        Create a UI side of the `Wire`.\n\n        Args:\n            merge: Whether to merge `Wire` messages as much as possible.\n        \"\"\"\n        if merge:\n            return WireUISide(self._merged_queue.subscribe())\n        else:\n            return WireUISide(self._raw_queue.subscribe())\n\n    def shutdown(self) -> None:\n        self.soul_side.flush()\n        logger.debug(\"Shutting down wire\")\n        self._raw_queue.shutdown()\n        self._merged_queue.shutdown()\n\n    async def join(self) -> None:\n        if self._recorder is None:\n            return\n        try:\n            await self._recorder.join()\n        except Exception:\n            logger.exception(\"Wire recorder failed to flush:\")\n\n\nclass WireSoulSide:\n    \"\"\"\n    The soul side of a `Wire`.\n    \"\"\"\n\n    def __init__(self, raw_queue: WireMessageQueue, merged_queue: WireMessageQueue):\n        self._raw_queue = raw_queue\n        self._merged_queue = merged_queue\n        self._merge_buffer: MergeableMixin | None = None\n\n    def send(self, msg: WireMessage) -> None:\n        if not isinstance(msg, ContentPart | ToolCallPart):\n            logger.debug(\"Sending wire message: {msg}\", msg=msg)\n\n        # send raw message\n        try:\n            self._raw_queue.publish_nowait(msg)\n        except QueueShutDown:\n            logger.info(\"Failed to send raw wire message, queue is shut down: {msg}\", msg=msg)\n\n        # merge and send merged message\n        match msg:\n            case MergeableMixin():\n                if self._merge_buffer is None:\n                    self._merge_buffer = copy.deepcopy(msg)\n                elif self._merge_buffer.merge_in_place(msg):\n                    pass\n                else:\n                    self.flush()\n                    self._merge_buffer = copy.deepcopy(msg)\n            case _:\n                self.flush()\n                self._send_merged(msg)\n\n    def flush(self) -> None:\n        buffer = self._merge_buffer\n        if buffer is None:\n            return\n        assert is_wire_message(buffer)\n        self._send_merged(buffer)\n        self._merge_buffer = None\n\n    def _send_merged(self, msg: WireMessage) -> None:\n        try:\n            self._merged_queue.publish_nowait(msg)\n        except QueueShutDown:\n            logger.info(\"Failed to send merged wire message, queue is shut down: {msg}\", msg=msg)\n\n\nclass WireUISide:\n    \"\"\"\n    The UI side of a `Wire`.\n    \"\"\"\n\n    def __init__(self, queue: Queue[WireMessage]):\n        self._queue = queue\n\n    async def receive(self) -> WireMessage:\n        msg = await self._queue.get()\n        if not isinstance(msg, ContentPart | ToolCallPart):\n            logger.debug(\"Receiving wire message: {msg}\", msg=msg)\n        return msg\n\n\nclass _WireRecorder:\n    def __init__(self, wire_file: WireFile, queue: Queue[WireMessage]) -> None:\n        self._wire_file = wire_file\n        self._task = asyncio.create_task(self._consume_loop(queue))\n\n    async def join(self) -> None:\n        with contextlib.suppress(asyncio.CancelledError):\n            await self._task\n\n    async def _consume_loop(self, queue: Queue[WireMessage]) -> None:\n        while True:\n            try:\n                msg = await queue.get()\n                await self._record(msg)\n            except QueueShutDown:\n                break\n\n    async def _record(self, msg: WireMessage) -> None:\n        await self._wire_file.append_message(msg)\n"
  },
  {
    "path": "src/kimi_cli/wire/file.py",
    "content": "from __future__ import annotations\n\nimport json\nimport time\nfrom collections.abc import AsyncIterator\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Literal\n\nimport aiofiles\nfrom pydantic import BaseModel, ConfigDict, ValidationError\n\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.wire.protocol import WIRE_PROTOCOL_LEGACY_VERSION, WIRE_PROTOCOL_VERSION\nfrom kimi_cli.wire.types import WireMessage, WireMessageEnvelope\n\n\nclass WireFileMetadata(BaseModel):\n    \"\"\"Metadata header stored as the first line in wire.jsonl.\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n    type: Literal[\"metadata\"] = \"metadata\"\n    protocol_version: str\n\n\nclass WireMessageRecord(BaseModel):\n    \"\"\"The persisted record of a `WireMessage`.\"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n    timestamp: float\n    message: WireMessageEnvelope\n\n    @classmethod\n    def from_wire_message(cls, msg: WireMessage, *, timestamp: float) -> WireMessageRecord:\n        return cls(timestamp=timestamp, message=WireMessageEnvelope.from_wire_message(msg))\n\n    def to_wire_message(self) -> WireMessage:\n        return self.message.to_wire_message()\n\n\ndef parse_wire_file_metadata(line: str) -> WireFileMetadata | None:\n    \"\"\"Parse a wire file metadata line; return None if the line is not metadata.\"\"\"\n    try:\n        return WireFileMetadata.model_validate_json(line)\n    except (ValidationError, ValueError):\n        return None\n\n\ndef parse_wire_file_line(line: str) -> WireFileMetadata | WireMessageRecord:\n    \"\"\"Parse a wire file line into metadata or a message record.\"\"\"\n    metadata = parse_wire_file_metadata(line)\n    if metadata is not None:\n        return metadata\n    return WireMessageRecord.model_validate_json(line)\n\n\n@dataclass(slots=True)\nclass WireFile:\n    path: Path\n    protocol_version: str = WIRE_PROTOCOL_VERSION\n\n    def __post_init__(self) -> None:\n        if self.path.exists():\n            version = _load_protocol_version(self.path)\n            self.protocol_version = version if version is not None else WIRE_PROTOCOL_LEGACY_VERSION\n        else:\n            self.protocol_version = WIRE_PROTOCOL_VERSION\n\n    def __str__(self) -> str:\n        return str(self.path)\n\n    @property\n    def version(self) -> str:\n        return self.protocol_version\n\n    def is_empty(self) -> bool:\n        if not self.path.exists():\n            return True\n        try:\n            with self.path.open(encoding=\"utf-8\") as f:\n                for line in f:\n                    line = line.strip()\n                    if not line:\n                        continue\n                    if parse_wire_file_metadata(line) is not None:\n                        continue\n                    return False\n        except OSError:\n            logger.exception(\"Failed to read wire file {file}:\", file=self.path)\n            return False\n        return True\n\n    async def iter_records(self) -> AsyncIterator[WireMessageRecord]:\n        if not self.path.exists():\n            return\n        try:\n            async with aiofiles.open(self.path, encoding=\"utf-8\") as f:\n                async for line in f:\n                    line = line.strip()\n                    if not line:\n                        continue\n                    try:\n                        parsed = parse_wire_file_line(line)\n                    except Exception:\n                        logger.exception(\n                            \"Failed to parse line in wire file {file}:\", file=self.path\n                        )\n                        continue\n                    if isinstance(parsed, WireFileMetadata):\n                        continue\n                    yield parsed\n        except Exception:\n            logger.exception(\"Failed to read wire file {file}:\", file=self.path)\n\n    async def append_message(self, msg: WireMessage, *, timestamp: float | None = None) -> None:\n        record = WireMessageRecord.from_wire_message(\n            msg,\n            timestamp=time.time() if timestamp is None else timestamp,\n        )\n        await self.append_record(record)\n\n    async def append_record(self, record: WireMessageRecord) -> None:\n        self.path.parent.mkdir(parents=True, exist_ok=True)\n        needs_header = not self.path.exists() or self.path.stat().st_size == 0\n        async with aiofiles.open(self.path, mode=\"a\", encoding=\"utf-8\") as f:\n            if needs_header:\n                metadata = WireFileMetadata(protocol_version=self.protocol_version)\n                await f.write(_dump_line(metadata))\n            await f.write(_dump_line(record))\n\n\ndef _dump_line(model: BaseModel) -> str:\n    return json.dumps(model.model_dump(mode=\"json\"), ensure_ascii=False) + \"\\n\"\n\n\ndef _load_protocol_version(path: Path) -> str | None:\n    try:\n        with path.open(encoding=\"utf-8\") as f:\n            for line in f:\n                line = line.strip()\n                if not line:\n                    continue\n                metadata = parse_wire_file_metadata(line)\n                if metadata is None:\n                    return None\n                return metadata.protocol_version\n    except OSError:\n        logger.exception(\"Failed to read wire file {file}:\", file=path)\n    return None\n"
  },
  {
    "path": "src/kimi_cli/wire/jsonrpc.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any, Literal\n\nfrom kosong.utils.typing import JsonType\nfrom pydantic import (\n    BaseModel,\n    ConfigDict,\n    TypeAdapter,\n    field_serializer,\n    field_validator,\n    model_serializer,\n)\n\nfrom kimi_cli.wire.serde import serialize_wire_message\nfrom kimi_cli.wire.types import (\n    ContentPart,\n    Event,\n    Request,\n    is_event,\n    is_request,\n)\n\n\nclass _MessageBase(BaseModel):\n    jsonrpc: Literal[\"2.0\"] = \"2.0\"\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n\nclass JSONRPCErrorObject(BaseModel):\n    code: int\n    message: str\n    data: JsonType | None = None\n\n\nclass JSONRPCMessage(_MessageBase):\n    \"\"\"The generic JSON-RPC message format used for validation.\"\"\"\n\n    method: str | None = None\n    id: str | None = None\n    params: JsonType | None = None\n    result: JsonType | None = None\n    error: JSONRPCErrorObject | None = None\n\n    def method_is_inbound(self) -> bool:\n        return self.method in JSONRPC_IN_METHODS\n\n    def is_request(self) -> bool:\n        return self.method is not None and self.id is not None\n\n    def is_notification(self) -> bool:\n        return self.method is not None and self.id is None\n\n    def is_response(self) -> bool:\n        return self.method is None and self.id is not None\n\n\nclass JSONRPCSuccessResponse(_MessageBase):\n    id: str\n    result: JsonType\n\n\nclass JSONRPCErrorResponse(_MessageBase):\n    id: str\n    error: JSONRPCErrorObject\n\n\nclass JSONRPCErrorResponseNullableID(_MessageBase):\n    id: str | None\n    error: JSONRPCErrorObject\n\n\nclass ClientInfo(BaseModel):\n    name: str\n    version: str | None = None\n\n\nclass ExternalTool(BaseModel):\n    name: str\n    description: str\n    parameters: dict[str, JsonType]\n\n\nclass ClientCapabilities(BaseModel):\n    \"\"\"Capabilities declared by the Wire client during initialization.\"\"\"\n\n    supports_question: bool = False\n    \"\"\"Whether the client can handle QuestionRequest messages.\"\"\"\n    supports_plan_mode: bool = False\n    \"\"\"Whether the client supports plan mode (EnterPlanMode / ExitPlanMode).\"\"\"\n\n\nclass JSONRPCInitializeMessage(_MessageBase):\n    class Params(BaseModel):\n        protocol_version: str\n        client: ClientInfo | None = None\n        external_tools: list[ExternalTool] | None = None\n        capabilities: ClientCapabilities | None = None\n\n    method: Literal[\"initialize\"] = \"initialize\"\n    id: str\n    params: Params\n\n\nclass JSONRPCPromptMessage(_MessageBase):\n    class Params(BaseModel):\n        user_input: str | list[ContentPart]\n\n    method: Literal[\"prompt\"] = \"prompt\"\n    id: str\n    params: Params\n\n    @model_serializer()\n    def _serialize(self) -> dict[str, Any]:\n        raise NotImplementedError(\"Prompt message serialization is not implemented.\")\n\n\nclass JSONRPCReplayMessage(_MessageBase):\n    method: Literal[\"replay\"] = \"replay\"\n    id: str\n    params: JsonType | None = None\n\n\nclass JSONRPCSteerMessage(_MessageBase):\n    class Params(BaseModel):\n        user_input: str | list[ContentPart]\n\n    method: Literal[\"steer\"] = \"steer\"\n    id: str\n    params: Params\n\n    @model_serializer()\n    def _serialize(self) -> dict[str, Any]:\n        raise NotImplementedError(\"Steer message serialization is not implemented.\")\n\n\nclass _SetPlanModeParams(BaseModel):\n    enabled: bool\n\n    model_config = ConfigDict(extra=\"ignore\")\n\n\nclass JSONRPCSetPlanModeMessage(_MessageBase):\n    method: Literal[\"set_plan_mode\"] = \"set_plan_mode\"\n    id: str\n    params: _SetPlanModeParams\n\n\nclass JSONRPCCancelMessage(_MessageBase):\n    method: Literal[\"cancel\"] = \"cancel\"\n    id: str\n    params: JsonType | None = None\n\n    @model_serializer()\n    def _serialize(self) -> dict[str, Any]:\n        raise NotImplementedError(\"Cancel message serialization is not implemented.\")\n\n\nclass JSONRPCEventMessage(_MessageBase):\n    method: Literal[\"event\"] = \"event\"\n    params: Event\n\n    @field_serializer(\"params\")\n    def _serialize_params(self, params: Event) -> dict[str, JsonType]:\n        return serialize_wire_message(params)\n\n    @field_validator(\"params\", mode=\"before\")\n    @classmethod\n    def _validate_params(cls, value: Any) -> Event:\n        if is_event(value):\n            return value\n        raise NotImplementedError(\"Event message deserialization is not implemented.\")\n\n\nclass JSONRPCRequestMessage(_MessageBase):\n    method: Literal[\"request\"] = \"request\"\n    id: str\n    params: Request\n\n    @field_serializer(\"params\")\n    def _serialize_params(self, params: Request) -> dict[str, JsonType]:\n        return serialize_wire_message(params)\n\n    @field_validator(\"params\", mode=\"before\")\n    @classmethod\n    def _validate_params(cls, value: Any) -> Request:\n        if is_request(value):\n            return value\n        raise NotImplementedError(\"Request message deserialization is not implemented.\")\n\n\ntype JSONRPCInMessage = (\n    JSONRPCSuccessResponse\n    | JSONRPCErrorResponse\n    | JSONRPCInitializeMessage\n    | JSONRPCPromptMessage\n    | JSONRPCSteerMessage\n    | JSONRPCReplayMessage\n    | JSONRPCSetPlanModeMessage\n    | JSONRPCCancelMessage\n)\nJSONRPCInMessageAdapter = TypeAdapter[JSONRPCInMessage](JSONRPCInMessage)\nJSONRPC_IN_METHODS = {\"initialize\", \"prompt\", \"steer\", \"replay\", \"set_plan_mode\", \"cancel\"}\n\ntype JSONRPCOutMessage = (\n    JSONRPCSuccessResponse\n    | JSONRPCErrorResponse\n    | JSONRPCErrorResponseNullableID\n    | JSONRPCEventMessage\n    | JSONRPCRequestMessage\n)\nJSONRPC_OUT_METHODS = {\"event\", \"request\"}\n\n\nclass ErrorCodes:\n    # Predefined JSON-RPC 2.0 error codes\n    PARSE_ERROR = -32700\n    \"\"\"Invalid JSON was received by the server.\"\"\"\n    INVALID_REQUEST = -32600\n    \"\"\"The JSON sent is not a valid Request object.\"\"\"\n    METHOD_NOT_FOUND = -32601\n    \"\"\"The method does not exist / is not available.\"\"\"\n    INVALID_PARAMS = -32602\n    \"\"\"Invalid method parameter(s).\"\"\"\n    INTERNAL_ERROR = -32603\n    \"\"\"Internal JSON-RPC error.\"\"\"\n\n    INVALID_STATE = -32000\n    \"\"\"The server is in an invalid state to process the request.\"\"\"\n    LLM_NOT_SET = -32001\n    \"\"\"The LLM is not set.\"\"\"\n    LLM_NOT_SUPPORTED = -32002\n    \"\"\"The specified LLM is not supported.\"\"\"\n    CHAT_PROVIDER_ERROR = -32003\n    \"\"\"There was an error from the chat provider.\"\"\"\n\n\nclass Statuses:\n    FINISHED = \"finished\"\n    \"\"\"The agent run has finished successfully.\"\"\"\n    CANCELLED = \"cancelled\"\n    \"\"\"The agent run was cancelled by the user.\"\"\"\n    MAX_STEPS_REACHED = \"max_steps_reached\"\n    \"\"\"The agent run reached the maximum number of steps.\"\"\"\n    STEERED = \"steered\"\n    \"\"\"A steer message was queued for injection into the active turn.\"\"\"\n"
  },
  {
    "path": "src/kimi_cli/wire/protocol.py",
    "content": "WIRE_PROTOCOL_VERSION: str = \"1.5\"\nWIRE_PROTOCOL_LEGACY_VERSION: str = \"1.1\"\n"
  },
  {
    "path": "src/kimi_cli/wire/serde.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any\n\nfrom kosong.utils.typing import JsonType\n\nfrom kimi_cli.wire.types import WireMessage, WireMessageEnvelope\n\n\ndef serialize_wire_message(msg: WireMessage) -> dict[str, JsonType]:\n    \"\"\"\n    Convert a `WireMessage` into a jsonifiable dict.\n    \"\"\"\n    envelope = WireMessageEnvelope.from_wire_message(msg)\n    return envelope.model_dump(mode=\"json\")\n\n\ndef deserialize_wire_message(data: dict[str, JsonType] | Any) -> WireMessage:\n    \"\"\"\n    Convert a jsonifiable dict into a `WireMessage`.\n\n    Raises:\n        ValueError: If the message type is unknown or the payload is invalid.\n    \"\"\"\n    envelope = WireMessageEnvelope.model_validate(data)\n    return envelope.to_wire_message()\n"
  },
  {
    "path": "src/kimi_cli/wire/server.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\nfrom typing import Any, cast\n\nimport acp  # type: ignore[reportMissingTypeStubs]\nimport pydantic\nfrom kosong.chat_provider import ChatProviderError\nfrom kosong.tooling import ToolError, ToolResult\nfrom kosong.utils.typing import JsonType\n\nfrom kimi_cli.constant import USER_AGENT\nfrom kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.soul.toolset import KimiToolset, WireExternalTool\nfrom kimi_cli.utils.aioqueue import Queue, QueueShutDown\nfrom kimi_cli.utils.logging import logger\nfrom kimi_cli.utils.signals import install_sigint_handler\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.types import (\n    ApprovalRequest,\n    ApprovalResponse,\n    QuestionNotSupported,\n    QuestionRequest,\n    QuestionResponse,\n    Request,\n    StatusUpdate,\n    ToolCallRequest,\n    is_event,\n    is_request,\n)\n\nfrom .jsonrpc import (\n    ClientInfo,\n    ErrorCodes,\n    JSONRPCCancelMessage,\n    JSONRPCErrorObject,\n    JSONRPCErrorResponse,\n    JSONRPCErrorResponseNullableID,\n    JSONRPCEventMessage,\n    JSONRPCInitializeMessage,\n    JSONRPCInMessage,\n    JSONRPCInMessageAdapter,\n    JSONRPCMessage,\n    JSONRPCOutMessage,\n    JSONRPCPromptMessage,\n    JSONRPCReplayMessage,\n    JSONRPCRequestMessage,\n    JSONRPCSetPlanModeMessage,\n    JSONRPCSteerMessage,\n    JSONRPCSuccessResponse,\n    Statuses,\n)\n\n# Maximum buffer size for the asyncio StreamReader used for stdio.\n# Passed as the `limit` argument to `acp.stdio_streams`, this caps how much\n# data can be buffered when reading from stdin (e.g., large tool or model\n# outputs sent over JSON-RPC). A 100MB limit is large enough for typical\n# interactive use while still protecting the process from unbounded memory\n# growth or buffer-overrun errors when peers send unexpectedly large payloads.\nSTDIO_BUFFER_LIMIT = 100 * 1024 * 1024\n\n\nclass WireServer:\n    def __init__(self, soul: Soul):\n        self._reader: asyncio.StreamReader | None = None\n        self._writer: asyncio.StreamWriter | None = None\n\n        # outward\n        self._write_task: asyncio.Task[None] | None = None\n        self._write_queue: Queue[JSONRPCOutMessage] = Queue()\n\n        # inward\n        self._dispatch_tasks: set[asyncio.Task[None]] = set()\n\n        # soul running stuffs\n        self._soul = soul\n        self._cancel_event: asyncio.Event | None = None\n        self._pending_requests: dict[str, Request] = {}\n        \"\"\"Maps JSON RPC message IDs to pending `Request`s.\"\"\"\n        self._client_supports_question: bool = False\n        \"\"\"Whether the Wire client supports QuestionRequest.\"\"\"\n        self._client_supports_plan_mode: bool = False\n        \"\"\"Whether the Wire client supports plan mode.\"\"\"\n\n    async def serve(self) -> None:\n        logger.info(\"Starting Wire server on stdio\")\n\n        self._reader, self._writer = await acp.stdio_streams(limit=STDIO_BUFFER_LIMIT)\n        self._write_task = asyncio.create_task(self._write_loop())\n        stop_event = asyncio.Event()\n        loop = asyncio.get_running_loop()\n        remove_sigint = install_sigint_handler(loop, stop_event.set)\n        read_task = asyncio.create_task(self._read_loop())\n        stop_task = asyncio.create_task(stop_event.wait())\n        tasks: set[asyncio.Task[Any]] = {read_task, stop_task}\n        pending = tasks\n        try:\n            done, pending = await asyncio.wait(\n                tasks,\n                return_when=asyncio.FIRST_COMPLETED,\n            )\n            if stop_event.is_set():\n                logger.info(\"Wire server interrupted, shutting down\")\n                if self._cancel_event is not None:\n                    self._cancel_event.set()\n                if not read_task.done():\n                    read_task.cancel()\n                    with contextlib.suppress(asyncio.CancelledError):\n                        await read_task\n            elif read_task in done:\n                read_task.result()\n        except KeyboardInterrupt:\n            logger.info(\"Wire server interrupted, shutting down\")\n            if self._cancel_event is not None:\n                self._cancel_event.set()\n        finally:\n            remove_sigint()\n            for task in pending:\n                task.cancel()\n                with contextlib.suppress(asyncio.CancelledError):\n                    await task\n            await self._shutdown()\n\n    async def _write_loop(self) -> None:\n        assert self._writer is not None\n\n        try:\n            while True:\n                try:\n                    msg = await self._write_queue.get()\n                except QueueShutDown:\n                    logger.debug(\"Send queue shut down, stopping Wire server write loop\")\n                    break\n                self._writer.write(msg.model_dump_json().encode(\"utf-8\") + b\"\\n\")\n                await self._writer.drain()\n        except asyncio.CancelledError:\n            raise\n        except Exception:\n            logger.exception(\"Wire server write loop error:\")\n            raise\n\n    async def _read_loop(self) -> None:\n        assert self._reader is not None\n\n        while True:\n            raw_line = await self._reader.readline()\n            if not raw_line:\n                logger.info(\"stdin closed, Wire server exiting\")\n                break\n            line = raw_line.decode(\"utf-8\", errors=\"replace\").strip()\n\n            try:\n                msg_json = json.loads(line)\n            except ValueError:\n                logger.error(\"Invalid JSON line: {line}\", line=line)\n                await self._send_msg(\n                    JSONRPCErrorResponseNullableID(\n                        id=None,\n                        error=JSONRPCErrorObject(\n                            code=ErrorCodes.PARSE_ERROR,\n                            message=\"Invalid JSON format\",\n                        ),\n                    )\n                )\n                continue\n\n            try:\n                generic_msg = JSONRPCMessage.model_validate(msg_json)\n            except pydantic.ValidationError as e:\n                logger.error(\"Invalid JSON-RPC message: {error}\", error=e)\n                await self._send_msg(\n                    JSONRPCErrorResponseNullableID(\n                        id=None,\n                        error=JSONRPCErrorObject(\n                            code=ErrorCodes.INVALID_REQUEST,\n                            message=\"Invalid request\",\n                        ),\n                    )\n                )\n                continue\n\n            if generic_msg.is_response():\n                # for responses, we skip the method check\n                try:\n                    msg = JSONRPCInMessageAdapter.validate_python(msg_json)\n                except pydantic.ValidationError as e:\n                    logger.error(\"Invalid JSON-RPC response: {error}\", error=e)\n                    await self._send_msg(\n                        JSONRPCErrorResponseNullableID(\n                            id=None,\n                            error=JSONRPCErrorObject(\n                                code=ErrorCodes.INVALID_REQUEST,\n                                message=\"Invalid response\",\n                            ),\n                        )\n                    )\n                    continue  # ignore invalid json-rpc responses\n\n                if not isinstance(msg, (JSONRPCSuccessResponse, JSONRPCErrorResponse)):\n                    logger.error(\n                        \"Invalid JSON-RPC response message: {msg}\",\n                        msg=msg_json,\n                    )\n                    continue  # ignore invalid response messages\n\n                task = asyncio.create_task(self._dispatch_msg(msg))\n                task.add_done_callback(self._dispatch_tasks.discard)\n                self._dispatch_tasks.add(task)\n                continue\n\n            if not generic_msg.method_is_inbound():\n                logger.error(\n                    \"Unexpected JSON-RPC method received: {method}\",\n                    method=generic_msg.method,\n                )\n                if generic_msg.id is not None:\n                    resp = JSONRPCErrorResponse(\n                        id=generic_msg.id,\n                        error=JSONRPCErrorObject(\n                            code=ErrorCodes.METHOD_NOT_FOUND,\n                            message=f\"Unexpected method received: {generic_msg.method}\",\n                        ),\n                    )\n                    await self._send_msg(resp)\n                continue  # ignore unexpected outbound methods\n\n            try:\n                msg = JSONRPCInMessageAdapter.validate_python(msg_json)\n            except pydantic.ValidationError as e:\n                logger.error(\"Invalid JSON-RPC inbound message: {error}\", error=e)\n                if generic_msg.id is not None:\n                    resp = JSONRPCErrorResponse(\n                        id=generic_msg.id,\n                        error=JSONRPCErrorObject(\n                            code=ErrorCodes.INVALID_PARAMS,\n                            message=f\"Invalid parameters for method `{generic_msg.method}`\",\n                        ),\n                    )\n                    await self._send_msg(resp)\n                continue  # ignore invalid inbound messages\n\n            task = asyncio.create_task(self._dispatch_msg(msg))\n            task.add_done_callback(self._dispatch_tasks.discard)\n            self._dispatch_tasks.add(task)\n\n    async def _shutdown(self) -> None:\n        for request in self._pending_requests.values():\n            if request.resolved:\n                continue\n            match request:\n                case ApprovalRequest():\n                    request.resolve(\"reject\")\n                case ToolCallRequest():\n                    request.resolve(\n                        ToolError(\n                            message=\"Wire connection closed before tool result was received.\",\n                            brief=\"Wire closed\",\n                        )\n                    )\n                case QuestionRequest():\n                    request.resolve({})\n        self._pending_requests.clear()\n\n        if self._cancel_event is not None:\n            self._cancel_event.set()\n            self._cancel_event = None\n\n        self._write_queue.shutdown()\n        if self._write_task is not None:\n            with contextlib.suppress(asyncio.CancelledError):\n                await self._write_task\n\n        await asyncio.gather(*self._dispatch_tasks, return_exceptions=True)\n        self._dispatch_tasks.clear()\n\n        if self._writer is not None:\n            self._writer.close()\n            with contextlib.suppress(Exception):\n                await self._writer.wait_closed()\n            self._writer = None\n\n        self._reader = None\n\n    async def _dispatch_msg(self, msg: JSONRPCInMessage) -> None:\n        resp: JSONRPCSuccessResponse | JSONRPCErrorResponse | None = None\n        try:\n            match msg:\n                case JSONRPCInitializeMessage():\n                    resp = await self._handle_initialize(msg)\n                case JSONRPCPromptMessage():\n                    resp = await self._handle_prompt(msg)\n                case JSONRPCReplayMessage():\n                    resp = await self._handle_replay(msg)\n                case JSONRPCSteerMessage():\n                    resp = await self._handle_steer(msg)\n                case JSONRPCSetPlanModeMessage():\n                    resp = await self._handle_set_plan_mode(msg)\n                case JSONRPCCancelMessage():\n                    resp = await self._handle_cancel(msg)\n                case JSONRPCSuccessResponse() | JSONRPCErrorResponse():\n                    await self._handle_response(msg)\n\n            if resp is not None:\n                await self._send_msg(resp)\n        except Exception:\n            logger.exception(\"Unexpected error dispatching JSONRPC message:\")\n            raise\n\n    async def _send_msg(self, msg: JSONRPCOutMessage) -> None:\n        try:\n            await self._write_queue.put(msg)\n        except QueueShutDown:\n            logger.error(\"Send queue shut down; dropping message: {msg}\", msg=msg)\n\n    @property\n    def _is_streaming(self) -> bool:\n        return self._cancel_event is not None\n\n    async def _handle_initialize(\n        self, msg: JSONRPCInitializeMessage\n    ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:\n        if self._is_streaming:\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(\n                    code=ErrorCodes.INVALID_STATE,\n                    message=\"An agent turn is already in progress\",\n                ),\n            )\n\n        accepted: list[str] = []\n        rejected: list[dict[str, str]] = []\n        toolset = None\n        if isinstance(self._soul, KimiSoul) and isinstance(self._soul.agent.toolset, KimiToolset):\n            toolset = self._soul.agent.toolset\n\n        if toolset and msg.params.external_tools:\n            for tool in msg.params.external_tools:\n                existing = toolset.find(tool.name)\n                if existing is not None and not isinstance(existing, WireExternalTool):\n                    rejected.append({\"name\": tool.name, \"reason\": \"conflicts with builtin tool\"})\n                    continue\n                ok, reason = toolset.register_external_tool(\n                    tool.name,\n                    tool.description,\n                    tool.parameters,\n                )\n                if ok:\n                    accepted.append(tool.name)\n                else:\n                    rejected.append({\"name\": tool.name, \"reason\": reason or \"invalid schema\"})\n\n        slash_commands: list[JsonType] = []\n        for cmd in self._soul.available_slash_commands:\n            slash_commands.append(\n                cast(\n                    JsonType,\n                    {\"name\": cmd.name, \"description\": cmd.description, \"aliases\": cmd.aliases},\n                )\n            )\n\n        from kimi_cli.constant import NAME, VERSION\n        from kimi_cli.wire.protocol import WIRE_PROTOCOL_VERSION\n\n        result: dict[str, JsonType] = {\n            \"protocol_version\": WIRE_PROTOCOL_VERSION,\n            \"server\": cast(JsonType, {\"name\": NAME, \"version\": VERSION}),\n            \"slash_commands\": cast(JsonType, slash_commands),\n        }\n        if accepted or rejected:\n            result[\"external_tools\"] = cast(\n                JsonType,\n                {\n                    \"accepted\": accepted,\n                    \"rejected\": rejected,\n                },\n            )\n\n        self._apply_wire_client_info(msg.params.client)\n\n        if msg.params.capabilities is not None:\n            self._client_supports_question = msg.params.capabilities.supports_question\n            self._client_supports_plan_mode = msg.params.capabilities.supports_plan_mode\n\n        if toolset is not None:\n            self._sync_ask_user_tool_visibility(toolset)\n            self._sync_plan_mode_tool_visibility(toolset)\n\n        result[\"capabilities\"] = cast(\n            JsonType,\n            {\"supports_question\": True},\n        )\n\n        return JSONRPCSuccessResponse(\n            id=msg.id,\n            result=result,\n        )\n\n    def _sync_ask_user_tool_visibility(self, toolset: KimiToolset) -> None:\n        \"\"\"Hide or unhide the AskUserQuestion tool based on client capabilities.\"\"\"\n        from kimi_cli.tools.ask_user import NAME as ASK_USER_TOOL_NAME\n\n        all_toolsets = [toolset]\n        if isinstance(self._soul, KimiSoul):\n            for subagent in self._soul.agent.runtime.labor_market.fixed_subagents.values():\n                if isinstance(subagent.toolset, KimiToolset):\n                    all_toolsets.append(subagent.toolset)\n\n        if self._client_supports_question:\n            for ts in all_toolsets:\n                ts.unhide(ASK_USER_TOOL_NAME)\n        else:\n            for ts in all_toolsets:\n                ts.hide(ASK_USER_TOOL_NAME)\n            logger.info(\n                \"Hid {tool} tool: client does not support questions\",\n                tool=ASK_USER_TOOL_NAME,\n            )\n\n    def _sync_plan_mode_tool_visibility(self, toolset: KimiToolset) -> None:\n        \"\"\"Hide or unhide plan mode tools based on client capabilities.\"\"\"\n        from kimi_cli.tools.plan import NAME as EXIT_PLAN_MODE_TOOL_NAME\n        from kimi_cli.tools.plan.enter import NAME as ENTER_PLAN_MODE_TOOL_NAME\n\n        plan_tool_names = [ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME]\n\n        all_toolsets = [toolset]\n        if isinstance(self._soul, KimiSoul):\n            for subagent in self._soul.agent.runtime.labor_market.fixed_subagents.values():\n                if isinstance(subagent.toolset, KimiToolset):\n                    all_toolsets.append(subagent.toolset)\n\n        if self._client_supports_plan_mode:\n            for ts in all_toolsets:\n                for name in plan_tool_names:\n                    ts.unhide(name)\n        else:\n            for ts in all_toolsets:\n                for name in plan_tool_names:\n                    ts.hide(name)\n            logger.info(\n                \"Hide plan mode tools: client does not support plan mode\",\n            )\n\n    def _apply_wire_client_info(self, client: ClientInfo | None) -> None:\n        if not isinstance(self._soul, KimiSoul):\n            return\n        llm = self._soul.runtime.llm\n        if llm is None:\n            return\n\n        ua_suffix = \"\"\n        if client is not None:\n            ua_suffix = client.name\n            if client.version:\n                ua_suffix += f\" {client.version}\"\n            ua_suffix = f\" ({ua_suffix.strip()})\"\n\n        from kosong.chat_provider.kimi import Kimi\n\n        if isinstance(llm.chat_provider, Kimi):\n            kimi_client = llm.chat_provider.client\n            headers = dict(kimi_client._custom_headers)  # pyright: ignore[reportPrivateUsage]\n            headers[\"User-Agent\"] = f\"{USER_AGENT}{ua_suffix}\"\n            kimi_client._custom_headers = headers  # pyright: ignore[reportPrivateUsage]\n\n    async def _handle_prompt(\n        self, msg: JSONRPCPromptMessage\n    ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:\n        if self._is_streaming:\n            # TODO: support queueing multiple inputs\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(\n                    code=ErrorCodes.INVALID_STATE, message=\"An agent turn is already in progress\"\n                ),\n            )\n\n        self._cancel_event = asyncio.Event()\n        try:\n            runtime = self._soul.runtime if isinstance(self._soul, KimiSoul) else None\n            await run_soul(\n                self._soul,\n                msg.params.user_input,\n                self._stream_wire_messages,\n                self._cancel_event,\n                runtime.session.wire_file if runtime else None,\n                runtime,\n            )\n            return JSONRPCSuccessResponse(\n                id=msg.id,\n                result={\"status\": Statuses.FINISHED},\n            )\n        except LLMNotSet:\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(code=ErrorCodes.LLM_NOT_SET, message=\"LLM is not set\"),\n            )\n        except LLMNotSupported as e:\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(code=ErrorCodes.LLM_NOT_SUPPORTED, message=str(e)),\n            )\n        except ChatProviderError as e:\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(code=ErrorCodes.CHAT_PROVIDER_ERROR, message=str(e)),\n            )\n        except MaxStepsReached as e:\n            return JSONRPCSuccessResponse(\n                id=msg.id,\n                result={\"status\": Statuses.MAX_STEPS_REACHED, \"steps\": e.n_steps},\n            )\n        except RunCancelled:\n            return JSONRPCSuccessResponse(\n                id=msg.id,\n                result={\"status\": Statuses.CANCELLED},\n            )\n        finally:\n            # Clean up any remaining pending requests from this turn.\n            # After run_soul() returns, the soul and all subagents are done,\n            # so any unresolved requests are stale.\n            stale_ids = [k for k, v in self._pending_requests.items() if not v.resolved]\n            for msg_id in stale_ids:\n                request = self._pending_requests.pop(msg_id)\n                match request:\n                    case ApprovalRequest():\n                        request.resolve(\"reject\")\n                    case ToolCallRequest():\n                        request.resolve(\n                            ToolError(\n                                message=\"Agent turn ended before tool result was received.\",\n                                brief=\"Turn ended\",\n                            )\n                        )\n                    case QuestionRequest():\n                        request.resolve({})\n            self._cancel_event = None\n\n    async def _handle_steer(\n        self, msg: JSONRPCSteerMessage\n    ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:\n        if not isinstance(self._soul, KimiSoul) or not self._is_streaming:\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(\n                    code=ErrorCodes.INVALID_STATE,\n                    message=\"No agent turn is in progress\",\n                ),\n            )\n\n        self._soul.steer(msg.params.user_input)\n        return JSONRPCSuccessResponse(\n            id=msg.id,\n            result={\"status\": Statuses.STEERED},\n        )\n\n    async def _handle_set_plan_mode(\n        self, msg: JSONRPCSetPlanModeMessage\n    ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:\n        if not isinstance(self._soul, KimiSoul):\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(\n                    code=ErrorCodes.INVALID_STATE,\n                    message=\"Plan mode is not supported\",\n                ),\n            )\n\n        new_state = await self._soul.set_plan_mode_from_manual(msg.params.enabled)\n\n        status = StatusUpdate(plan_mode=new_state)\n        await self._send_msg(JSONRPCEventMessage(params=status))\n        # Persist to wire file so replay reconstructs plan mode state\n        await self._soul.wire_file.append_message(status)\n        return JSONRPCSuccessResponse(\n            id=msg.id,\n            result={\"status\": \"ok\", \"plan_mode\": new_state},\n        )\n\n    async def _handle_replay(\n        self, msg: JSONRPCReplayMessage\n    ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:\n        if self._is_streaming:\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(\n                    code=ErrorCodes.INVALID_STATE, message=\"An agent turn is already in progress\"\n                ),\n            )\n\n        wire_file = self._soul.wire_file if isinstance(self._soul, KimiSoul) else None\n\n        self._cancel_event = asyncio.Event()\n        events = 0\n        requests = 0\n        try:\n            if wire_file is None or not wire_file.path.exists():\n                return JSONRPCSuccessResponse(\n                    id=msg.id,\n                    result={\"status\": Statuses.FINISHED, \"events\": 0, \"requests\": 0},\n                )\n\n            async for record in wire_file.iter_records():\n                if self._cancel_event.is_set():\n                    return JSONRPCSuccessResponse(\n                        id=msg.id,\n                        result={\n                            \"status\": Statuses.CANCELLED,\n                            \"events\": events,\n                            \"requests\": requests,\n                        },\n                    )\n\n                try:\n                    wire_msg = record.to_wire_message()\n                except Exception:\n                    logger.exception(\n                        \"Failed to deserialize wire record for replay: {file}\",\n                        file=wire_file.path,\n                    )\n                    continue\n\n                if is_request(wire_msg):\n                    await self._send_msg(JSONRPCRequestMessage(id=wire_msg.id, params=wire_msg))\n                    requests += 1\n                elif is_event(wire_msg):\n                    await self._send_msg(JSONRPCEventMessage(params=wire_msg))\n                    events += 1\n                else:\n                    # Not reachable for valid WireMessage, but keep a guard for corrupted data.\n                    logger.warning(\n                        \"Skipping non-wire message during replay: {msg}\",\n                        msg=wire_msg,\n                    )\n\n                await asyncio.sleep(0)  # yield control for cancel handling\n\n            if self._cancel_event.is_set():\n                return JSONRPCSuccessResponse(\n                    id=msg.id,\n                    result={\n                        \"status\": Statuses.CANCELLED,\n                        \"events\": events,\n                        \"requests\": requests,\n                    },\n                )\n\n            return JSONRPCSuccessResponse(\n                id=msg.id,\n                result={\"status\": Statuses.FINISHED, \"events\": events, \"requests\": requests},\n            )\n        except Exception:\n            logger.exception(\"Replay failed:\")\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(\n                    code=ErrorCodes.INTERNAL_ERROR,\n                    message=\"Replay failed\",\n                ),\n            )\n        finally:\n            self._cancel_event = None\n\n    async def _handle_cancel(\n        self, msg: JSONRPCCancelMessage\n    ) -> JSONRPCSuccessResponse | JSONRPCErrorResponse:\n        if not self._is_streaming:\n            return JSONRPCErrorResponse(\n                id=msg.id,\n                error=JSONRPCErrorObject(\n                    code=ErrorCodes.INVALID_STATE, message=\"No agent turn is in progress\"\n                ),\n            )\n\n        assert self._cancel_event is not None\n        self._cancel_event.set()\n        return JSONRPCSuccessResponse(\n            id=msg.id,\n            result={},\n        )\n\n    async def _handle_response(self, msg: JSONRPCSuccessResponse | JSONRPCErrorResponse) -> None:\n        request = self._pending_requests.pop(msg.id, None)\n        if request is None:\n            logger.error(\"No pending request for response id={id}\", id=msg.id)\n            return\n\n        match request:\n            case ApprovalRequest():\n                if isinstance(msg, JSONRPCErrorResponse):\n                    request.resolve(\"reject\")\n                    return\n\n                try:\n                    result = ApprovalResponse.model_validate(msg.result)\n                except pydantic.ValidationError as e:\n                    logger.error(\n                        \"Invalid response result for request id={id}: {error}\",\n                        id=msg.id,\n                        error=e,\n                    )\n                    request.resolve(\"reject\")\n                    return\n\n                if result.request_id != request.id:\n                    logger.warning(\n                        \"Approval response id mismatch: request={request_id}, \"\n                        \"response={response_id}\",\n                        request_id=request.id,\n                        response_id=result.request_id,\n                    )\n                request.resolve(result.response)\n            case ToolCallRequest():\n                if isinstance(msg, JSONRPCErrorResponse):\n                    error = msg.error.message\n                    request.resolve(\n                        ToolError(\n                            message=error,\n                            brief=\"External tool error\",\n                        )\n                    )\n                    return\n\n                try:\n                    tool_result = ToolResult.model_validate(msg.result)\n                except pydantic.ValidationError as e:\n                    logger.error(\n                        \"Invalid tool result for request id={id}: {error}\",\n                        id=msg.id,\n                        error=e,\n                    )\n                    request.resolve(\n                        ToolError(\n                            message=\"Invalid tool result payload from client.\",\n                            brief=\"Invalid tool result\",\n                        )\n                    )\n                    return\n                if tool_result.tool_call_id != request.id:\n                    logger.warning(\n                        \"Tool result id mismatch: request={request_id}, result={result_id}\",\n                        request_id=request.id,\n                        result_id=tool_result.tool_call_id,\n                    )\n                request.resolve(tool_result.return_value)\n            case QuestionRequest():\n                if isinstance(msg, JSONRPCErrorResponse):\n                    request.resolve({})\n                    return\n\n                try:\n                    result = QuestionResponse.model_validate(msg.result)\n                except pydantic.ValidationError as e:\n                    logger.error(\n                        \"Invalid question response for request id={id}: {error}\",\n                        id=msg.id,\n                        error=e,\n                    )\n                    request.resolve({})\n                    return\n\n                if result.request_id != request.id:\n                    logger.warning(\n                        \"Question response id mismatch: request={request_id}, \"\n                        \"response={response_id}\",\n                        request_id=request.id,\n                        response_id=result.request_id,\n                    )\n                request.resolve(result.answers)\n\n    async def _stream_wire_messages(self, wire: Wire) -> None:\n        wire_ui = wire.ui_side(merge=False)\n        while True:\n            msg = await wire_ui.receive()\n            match msg:\n                case ApprovalRequest():\n                    await self._request_approval(msg)\n                case ToolCallRequest():\n                    await self._request_external_tool(msg)\n                case QuestionRequest():\n                    await self._request_question(msg)\n                case _:\n                    await self._send_msg(JSONRPCEventMessage(method=\"event\", params=msg))\n\n    async def _request_approval(self, request: ApprovalRequest) -> None:\n        msg_id = request.id  # just use the approval request id as message id\n        self._pending_requests[msg_id] = request\n        await self._send_msg(JSONRPCRequestMessage(id=msg_id, params=request))\n        # Do NOT await request.wait() here.  The approval future is awaited by\n        # the tool that created the request (inside the soul task).  Blocking the\n        # UI loop would prevent ALL subsequent Wire messages — from every\n        # concurrent subagent — from reaching stdout, causing a cascade deadlock\n        # when the approval response is lost (e.g. no WebSocket connected).\n\n    async def _request_external_tool(self, request: ToolCallRequest) -> None:\n        msg_id = request.id\n        self._pending_requests[msg_id] = request\n        await self._send_msg(JSONRPCRequestMessage(id=msg_id, params=request))\n        # Same rationale as _request_approval: do not block the UI loop.\n\n    async def _request_question(self, request: QuestionRequest) -> None:\n        if not self._client_supports_question:\n            # Client does not support interactive questions; signal the tool\n            # so it can tell the LLM to use an alternative approach.\n            request.set_exception(QuestionNotSupported())\n            return\n        msg_id = request.id\n        self._pending_requests[msg_id] = request\n        await self._send_msg(JSONRPCRequestMessage(id=msg_id, params=request))\n        # Same rationale as _request_approval: do not block the UI loop.\n"
  },
  {
    "path": "src/kimi_cli/wire/types.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom typing import Any, Literal, TypeGuard, cast\n\nfrom kosong.chat_provider import TokenUsage\nfrom kosong.message import (\n    AudioURLPart,\n    ContentPart,\n    ImageURLPart,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    ToolCallPart,\n    VideoURLPart,\n)\nfrom kosong.tooling import (\n    BriefDisplayBlock,\n    DisplayBlock,\n    ToolResult,\n    ToolReturnValue,\n    UnknownDisplayBlock,\n)\nfrom kosong.utils.typing import JsonType\nfrom pydantic import BaseModel, Field, field_serializer, field_validator\n\nfrom kimi_cli.tools.display import (\n    BackgroundTaskDisplayBlock,\n    DiffDisplayBlock,\n    ShellDisplayBlock,\n    TodoDisplayBlock,\n    TodoDisplayItem,\n)\nfrom kimi_cli.utils.typing import flatten_union\n\n\nclass TurnBegin(BaseModel):\n    \"\"\"\n    Indicates the beginning of a new agent turn.\n    This event must be sent before any other event in the turn.\n    \"\"\"\n\n    user_input: str | list[ContentPart]\n\n\nclass SteerInput(BaseModel):\n    \"\"\"\n    Indicates that the user appended follow-up input to the current running turn.\n    This event is emitted after the current step finishes and the input is appended\n    to context, before the next step begins.\n    \"\"\"\n\n    user_input: str | list[ContentPart]\n\n\nclass TurnEnd(BaseModel):\n    \"\"\"\n    Indicates the end of the current agent turn.\n    This event must be sent after all other events in the turn.\n    If the turn is interrupted, this event may be omitted.\n    \"\"\"\n\n    pass\n\n\nclass StepBegin(BaseModel):\n    \"\"\"\n    Indicates the beginning of a new agent step.\n    This event must be sent before any other event in the step.\n    \"\"\"\n\n    n: int\n    \"\"\"The step number.\"\"\"\n\n\nclass StepInterrupted(BaseModel):\n    \"\"\"Indicates the current step was interrupted, either by user intervention or an error.\"\"\"\n\n    pass\n\n\nclass CompactionBegin(BaseModel):\n    \"\"\"\n    Indicates that a compaction just began.\n    This event must be sent during a step, which means, between `StepBegin` and the next\n    `StepBegin` or `StepInterrupted`. And, there must be a `CompactionEnd` directly following\n    this event.\n    \"\"\"\n\n    pass\n\n\nclass CompactionEnd(BaseModel):\n    \"\"\"\n    Indicates that a compaction just ended.\n    This event must be sent directly after a `CompactionBegin` event.\n    \"\"\"\n\n    pass\n\n\nclass MCPLoadingBegin(BaseModel):\n    \"\"\"Indicates that MCP tool loading is in progress.\"\"\"\n\n    pass\n\n\nclass MCPLoadingEnd(BaseModel):\n    \"\"\"Indicates that MCP tool loading has finished.\"\"\"\n\n    pass\n\n\nclass MCPServerSnapshot(BaseModel):\n    \"\"\"A snapshot of one MCP server during startup.\"\"\"\n\n    name: str\n    status: Literal[\"pending\", \"connecting\", \"connected\", \"failed\", \"unauthorized\"]\n    tools: tuple[str, ...] = ()\n\n\nclass MCPStatusSnapshot(BaseModel):\n    \"\"\"A snapshot of MCP startup progress.\"\"\"\n\n    loading: bool\n    connected: int\n    total: int\n    tools: int\n    servers: tuple[MCPServerSnapshot, ...] = ()\n\n\nclass StatusUpdate(BaseModel):\n    \"\"\"\n    An update on the current status of the soul.\n    None fields indicate no change from the previous status.\n    \"\"\"\n\n    context_usage: float | None = None\n    \"\"\"The usage of the context, in percentage.\"\"\"\n    context_tokens: int | None = None\n    \"\"\"The number of tokens currently in the context.\"\"\"\n    max_context_tokens: int | None = None\n    \"\"\"The maximum number of tokens the context can hold.\"\"\"\n    token_usage: TokenUsage | None = None\n    \"\"\"The token usage statistics of the current step.\"\"\"\n    message_id: str | None = None\n    \"\"\"The message ID of the current step.\"\"\"\n    plan_mode: bool | None = None\n    \"\"\"Whether plan mode (read-only) is active. None means no change.\"\"\"\n    mcp_status: MCPStatusSnapshot | None = None\n    \"\"\"The current MCP startup snapshot. None means no change.\"\"\"\n\n\nclass Notification(BaseModel):\n    \"\"\"A generic system notification for UI and client consumption.\"\"\"\n\n    id: str\n    category: str\n    type: str\n    source_kind: str\n    source_id: str\n    title: str\n    body: str\n    severity: str\n    created_at: float\n    payload: dict[str, JsonType] = Field(default_factory=dict)\n\n\nclass SubagentEvent(BaseModel):\n    \"\"\"\n    An event from a subagent.\n    \"\"\"\n\n    task_tool_call_id: str\n    \"\"\"The ID of the task tool call associated with this subagent.\"\"\"\n    event: Event\n    \"\"\"The event from the subagent.\"\"\"\n    # TODO: maybe restrict the event types? to exclude approval request, etc.\n\n    @field_serializer(\"event\", when_used=\"json\")\n    def _serialize_event(self, event: Event) -> dict[str, Any]:\n        envelope = WireMessageEnvelope.from_wire_message(event)\n        return envelope.model_dump(mode=\"json\")\n\n    @field_validator(\"event\", mode=\"before\")\n    @classmethod\n    def _validate_event(cls, value: Any) -> Event:\n        if is_wire_message(value):\n            if is_event(value):\n                return value\n            raise ValueError(\"SubagentEvent event must be an Event\")\n\n        if not isinstance(value, dict):\n            raise ValueError(\"SubagentEvent event must be a dict\")\n        event_type = cast(dict[str, Any], value).get(\"type\")\n        event_payload = cast(dict[str, Any], value).get(\"payload\")\n        envelope = WireMessageEnvelope.model_validate(\n            {\"type\": event_type, \"payload\": event_payload}\n        )\n        event = envelope.to_wire_message()\n        if not is_event(event):\n            raise ValueError(\"SubagentEvent event must be an Event\")\n        return event\n\n\nclass ApprovalResponse(BaseModel):\n    \"\"\"\n    Indicates that an approval request has been resolved.\n    \"\"\"\n\n    type Kind = Literal[\"approve\", \"approve_for_session\", \"reject\"]\n\n    request_id: str\n    \"\"\"The ID of the resolved approval request.\"\"\"\n    response: Kind\n    \"\"\"The response to the approval request.\"\"\"\n\n\nclass ApprovalRequest(BaseModel):\n    \"\"\"\n    A request for user approval before proceeding with an action.\n    \"\"\"\n\n    id: str\n    tool_call_id: str\n    sender: str\n    action: str\n    description: str\n    display: list[DisplayBlock] = Field(default_factory=list[DisplayBlock])\n    \"\"\"Defaults to an empty list for backwards-compatible wire.jsonl loading.\"\"\"\n\n    # Note that the above fields are just a copy of `kimi_cli.soul.approval.Request`, but\n    # we cannot directly use that class here because we want to avoid dependency from Wire\n    # to Soul.\n\n    def __init__(self, **kwargs: Any) -> None:\n        super().__init__(**kwargs)\n        self._future: asyncio.Future[ApprovalResponse.Kind] | None = None\n\n    def _get_future(self) -> asyncio.Future[ApprovalResponse.Kind]:\n        if self._future is None:\n            self._future = asyncio.get_event_loop().create_future()\n        return self._future\n\n    async def wait(self) -> ApprovalResponse.Kind:\n        \"\"\"\n        Wait for the request to be resolved or cancelled.\n\n        Returns:\n            ApprovalResponse.Kind: The response to the approval request.\n        \"\"\"\n        return await self._get_future()\n\n    def resolve(self, response: ApprovalResponse.Kind) -> None:\n        \"\"\"\n        Resolve the approval request with the given response.\n        This will cause the `wait()` method to return the response.\n        \"\"\"\n        future = self._get_future()\n        if not future.done():\n            future.set_result(response)\n\n    @property\n    def resolved(self) -> bool:\n        \"\"\"Whether the request is resolved.\"\"\"\n        return self._future is not None and self._future.done()\n\n\nclass QuestionOption(BaseModel):\n    \"\"\"A single option for a question.\"\"\"\n\n    label: str\n    \"\"\"The display text for this option.\"\"\"\n    description: str = \"\"\n    \"\"\"Explanation of what this option means.\"\"\"\n\n\nclass QuestionItem(BaseModel):\n    \"\"\"A single question to ask the user.\"\"\"\n\n    question: str\n    \"\"\"The complete question text.\"\"\"\n    header: str = \"\"\n    \"\"\"Short label displayed as a tag (max 12 chars).\"\"\"\n    options: list[QuestionOption]\n    \"\"\"The available choices for this question (2-4 options).\"\"\"\n    multi_select: bool = False\n    \"\"\"Whether multiple options can be selected.\"\"\"\n    body: str = \"\"\n    \"\"\"Optional body content (markdown) displayed above options.\"\"\"\n    other_label: str = \"\"\n    \"\"\"Custom label for the synthetic 'Other' free-text option. Empty uses default.\"\"\"\n    other_description: str = \"\"\n    \"\"\"Custom description for the synthetic 'Other' option. Empty uses default.\"\"\"\n\n\nclass QuestionResponse(BaseModel):\n    \"\"\"Response to a question request.\"\"\"\n\n    request_id: str\n    \"\"\"The ID of the resolved question request.\"\"\"\n    answers: dict[str, str]\n    \"\"\"Mapping from question text to selected option label(s). Multi-select answers are\n    comma-separated.\"\"\"\n\n\nclass QuestionNotSupported(Exception):\n    \"\"\"Raised when the connected client does not support interactive questions.\"\"\"\n\n\nclass QuestionRequest(BaseModel):\n    \"\"\"\n    A request to ask the user structured questions during execution.\n    \"\"\"\n\n    id: str\n    \"\"\"The unique request ID.\"\"\"\n    tool_call_id: str\n    \"\"\"The ID of the tool call that initiated this question.\"\"\"\n    questions: list[QuestionItem]\n    \"\"\"The questions to ask the user (1-4 questions).\"\"\"\n\n    def __init__(self, **kwargs: Any) -> None:\n        super().__init__(**kwargs)\n        self._future: asyncio.Future[dict[str, str]] | None = None\n\n    def _get_future(self) -> asyncio.Future[dict[str, str]]:\n        if self._future is None:\n            self._future = asyncio.get_event_loop().create_future()\n        return self._future\n\n    async def wait(self) -> dict[str, str]:\n        \"\"\"\n        Wait for the question to be answered.\n\n        Returns:\n            dict[str, str]: Mapping from question text to answer.\n        \"\"\"\n        return await self._get_future()\n\n    def resolve(self, answers: dict[str, str]) -> None:\n        \"\"\"\n        Resolve the question request with the given answers.\n        This will cause the `wait()` method to return the answers.\n        \"\"\"\n        future = self._get_future()\n        if not future.done():\n            future.set_result(answers)\n\n    def set_exception(self, exc: BaseException) -> None:\n        \"\"\"Resolve the question request with an exception.\"\"\"\n        future = self._get_future()\n        if not future.done():\n            future.set_exception(exc)\n\n    @property\n    def resolved(self) -> bool:\n        \"\"\"Whether the question request is resolved.\"\"\"\n        return self._future is not None and self._future.done()\n\n\nclass ToolCallRequest(BaseModel):\n    \"\"\"\n    A tool call request routed to the Wire client for execution.\n    \"\"\"\n\n    id: str\n    \"\"\"The ID of the tool call.\"\"\"\n    name: str\n    \"\"\"The name of the tool to call.\"\"\"\n    arguments: str | None\n    \"\"\"Arguments of the tool call in JSON string format.\"\"\"\n\n    def __init__(self, **kwargs: Any) -> None:\n        super().__init__(**kwargs)\n        self._future: asyncio.Future[ToolReturnValue] | None = None\n\n    def _get_future(self) -> asyncio.Future[ToolReturnValue]:\n        if self._future is None:\n            self._future = asyncio.get_event_loop().create_future()\n        return self._future\n\n    @staticmethod\n    def from_tool_call(tool_call: ToolCall) -> ToolCallRequest:\n        return ToolCallRequest(\n            id=tool_call.id,\n            name=tool_call.function.name,\n            arguments=tool_call.function.arguments,\n        )\n\n    async def wait(self) -> ToolReturnValue:\n        \"\"\"\n        Wait for the tool call to be resolved or cancelled.\n\n        Returns:\n            ToolReturnValue: The tool execution result.\n        \"\"\"\n        return await self._get_future()\n\n    def resolve(self, result: ToolReturnValue) -> None:\n        \"\"\"\n        Resolve the tool call with the given result.\n        This will cause the `wait()` method to return the result.\n        \"\"\"\n        future = self._get_future()\n        if not future.done():\n            future.set_result(result)\n\n    @property\n    def resolved(self) -> bool:\n        \"\"\"Whether the tool call is resolved.\"\"\"\n        return self._future is not None and self._future.done()\n\n\ntype Event = (\n    TurnBegin\n    | SteerInput\n    | TurnEnd\n    | StepBegin\n    | StepInterrupted\n    | CompactionBegin\n    | CompactionEnd\n    | MCPLoadingBegin\n    | MCPLoadingEnd\n    | StatusUpdate\n    | Notification\n    | ContentPart\n    | ToolCall\n    | ToolCallPart\n    | ToolResult\n    | ApprovalResponse\n    | SubagentEvent\n)\n\"\"\"Any event, including control flow and content/tooling events.\"\"\"\n\n\ntype Request = ApprovalRequest | ToolCallRequest | QuestionRequest\n\"\"\"Any request. Request is a message that expects a response.\"\"\"\n\ntype WireMessage = Event | Request\n\"\"\"Any message sent over the `Wire`.\"\"\"\n\n\n_EVENT_TYPES = cast(tuple[type[Event], ...], flatten_union(Event))\n_REQUEST_TYPES = cast(tuple[type[Request], ...], flatten_union(Request))\n_WIRE_MESSAGE_TYPES = cast(tuple[type[WireMessage], ...], flatten_union(WireMessage))\n\n\ndef is_event(msg: Any) -> TypeGuard[Event]:\n    \"\"\"Check if the message is an Event.\"\"\"\n    return isinstance(msg, _EVENT_TYPES)\n\n\ndef is_request(msg: Any) -> TypeGuard[Request]:\n    \"\"\"Check if the message is a Request.\"\"\"\n    return isinstance(msg, _REQUEST_TYPES)\n\n\ndef is_wire_message(msg: Any) -> TypeGuard[WireMessage]:\n    \"\"\"Check if the message is a WireMessage.\"\"\"\n    return isinstance(msg, _WIRE_MESSAGE_TYPES)\n\n\n_NAME_TO_WIRE_MESSAGE_TYPE: dict[str, type[WireMessage]] = {\n    cls.__name__: cls for cls in _WIRE_MESSAGE_TYPES\n}\n# for backwards compatibility with Wire v1\n_NAME_TO_WIRE_MESSAGE_TYPE[\"ApprovalRequestResolved\"] = ApprovalResponse\n\n\nclass WireMessageEnvelope(BaseModel):\n    type: str\n    payload: dict[str, JsonType]\n\n    @classmethod\n    def from_wire_message(cls, msg: WireMessage) -> WireMessageEnvelope:\n        typename: str | None = None\n        for name, typ in _NAME_TO_WIRE_MESSAGE_TYPE.items():\n            if issubclass(type(msg), typ):\n                typename = name\n                break\n        assert typename is not None, f\"Unknown wire message type: {type(msg)}\"\n        return cls(\n            type=typename,\n            payload=msg.model_dump(mode=\"json\"),\n        )\n\n    def to_wire_message(self) -> WireMessage:\n        \"\"\"\n        Convert the envelope back into a `WireMessage`.\n\n        Raises:\n            ValueError: If the message type is unknown or the payload is invalid.\n        \"\"\"\n        msg_type = _NAME_TO_WIRE_MESSAGE_TYPE.get(self.type)\n        if msg_type is None:\n            raise ValueError(f\"Unknown wire message type: {self.type}\")\n        return msg_type.model_validate(self.payload)\n\n\n__all__ = [\n    # `WireMessage` variants\n    \"TurnBegin\",\n    \"SteerInput\",\n    \"TurnEnd\",\n    \"StepBegin\",\n    \"StepInterrupted\",\n    \"CompactionBegin\",\n    \"CompactionEnd\",\n    \"MCPLoadingBegin\",\n    \"MCPLoadingEnd\",\n    \"StatusUpdate\",\n    \"MCPServerSnapshot\",\n    \"MCPStatusSnapshot\",\n    \"Notification\",\n    \"ContentPart\",\n    \"ToolCall\",\n    \"ToolCallPart\",\n    \"ToolResult\",\n    \"ApprovalResponse\",\n    \"SubagentEvent\",\n    \"ApprovalRequest\",\n    \"ToolCallRequest\",\n    \"QuestionOption\",\n    \"QuestionItem\",\n    \"QuestionResponse\",\n    \"QuestionRequest\",\n    \"QuestionNotSupported\",\n    # helpers\n    \"WireMessageEnvelope\",\n    # `StatusUpdate`-related\n    \"TokenUsage\",\n    # `ContentPart` types\n    \"TextPart\",\n    \"ThinkPart\",\n    \"ImageURLPart\",\n    \"AudioURLPart\",\n    \"VideoURLPart\",\n    # `ToolResult`-related\n    \"ToolReturnValue\",\n    # `DisplayBlock` types\n    \"DisplayBlock\",\n    \"UnknownDisplayBlock\",\n    \"BriefDisplayBlock\",\n    \"DiffDisplayBlock\",\n    \"TodoDisplayBlock\",\n    \"TodoDisplayItem\",\n    \"ShellDisplayBlock\",\n    \"BackgroundTaskDisplayBlock\",\n]\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/acp/__init__.py",
    "content": ""
  },
  {
    "path": "tests/acp/conftest.py",
    "content": "\"\"\"ACP test configuration and fixtures.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom collections.abc import AsyncIterator\nfrom pathlib import Path\nfrom typing import Any\n\nimport acp\nimport pytest\nimport pytest_asyncio\n\n\ndef _repo_root() -> Path:\n    return Path(__file__).resolve().parents[2]\n\n\ndef _kimi_bin() -> str:\n    \"\"\"Return the path to the kimi entry-point script inside the venv.\"\"\"\n    return str(_repo_root() / \".venv\" / \"bin\" / \"kimi\")\n\n\nclass ACPTestClient:\n    \"\"\"Minimal ACP client for tests — collects session_update callbacks.\"\"\"\n\n    def __init__(self) -> None:\n        self.updates: list[Any] = []\n        self.conn: acp.Agent | None = None\n\n    def on_connect(self, conn: acp.Agent) -> None:\n        self.conn = conn\n\n    async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None:\n        self.updates.append(update)\n\n    async def request_permission(\n        self,\n        options: list[acp.schema.PermissionOption],\n        session_id: str,\n        tool_call: acp.schema.ToolCallUpdate,\n        **kwargs: Any,\n    ) -> acp.schema.RequestPermissionResponse:\n        return acp.schema.RequestPermissionResponse(\n            outcome=acp.schema.AllowedOutcome(\n                outcome=\"selected\",\n                option_id=\"allow\",\n            )\n        )\n\n    async def read_text_file(\n        self,\n        path: str,\n        session_id: str,\n        limit: int | None = None,\n        line: int | None = None,\n        **kwargs: Any,\n    ) -> Any:\n        raise NotImplementedError\n\n    async def write_text_file(self, content: str, path: str, session_id: str, **kwargs: Any) -> Any:\n        raise NotImplementedError\n\n    async def create_terminal(\n        self,\n        command: str,\n        session_id: str,\n        args: list[str] | None = None,\n        cwd: str | None = None,\n        env: list[acp.schema.EnvVariable] | None = None,\n        output_byte_limit: int | None = None,\n        **kwargs: Any,\n    ) -> Any:\n        raise NotImplementedError\n\n    async def terminal_output(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:\n        raise NotImplementedError\n\n    async def wait_for_terminal_exit(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:\n        raise NotImplementedError\n\n    async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:\n        raise NotImplementedError\n\n    async def release_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:\n        raise NotImplementedError\n\n    async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:\n        raise NotImplementedError\n\n    async def ext_notification(self, method: str, params: dict[str, Any]) -> None:\n        pass\n\n\n@pytest.fixture\ndef acp_share_dir(tmp_path: Path) -> Path:\n    \"\"\"Create a share dir with _scripted_echo config at config.toml.\"\"\"\n    share_dir = tmp_path / \"share\"\n    share_dir.mkdir()\n\n    scripts = [\n        \"text: Hello from scripted echo!\",\n        \"text: Second response from scripted echo.\",\n    ]\n    scripts_path = tmp_path / \"scripts.json\"\n    scripts_path.write_text(json.dumps(scripts), encoding=\"utf-8\")\n\n    trace_env = os.getenv(\"KIMI_SCRIPTED_ECHO_TRACE\", \"0\")\n    config_data = {\n        \"default_model\": \"scripted\",\n        \"models\": {\n            \"scripted\": {\n                \"provider\": \"scripted_provider\",\n                \"model\": \"scripted_echo\",\n                \"max_context_size\": 100000,\n            }\n        },\n        \"providers\": {\n            \"scripted_provider\": {\n                \"type\": \"_scripted_echo\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\n                    \"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_path),\n                    \"KIMI_SCRIPTED_ECHO_TRACE\": trace_env,\n                },\n            }\n        },\n    }\n\n    import tomlkit\n\n    config_path = share_dir / \"config.toml\"\n    config_path.write_text(tomlkit.dumps(config_data), encoding=\"utf-8\")\n\n    # Provide pre-authenticated credentials for integration tests.\n    # _check_auth() only verifies token file exists with non-empty access_token —\n    # no network validation. Auth logic is unit-tested separately via mocks in\n    # test_acp_server_auth.py. These tests target protocol behavior, not auth.\n    import time as _time\n\n    credentials_dir = share_dir / \"credentials\"\n    credentials_dir.mkdir(parents=True, exist_ok=True)\n    (credentials_dir / \"kimi-code.json\").write_text(\n        json.dumps(\n            {\n                \"access_token\": \"test-token-for-ci\",\n                \"refresh_token\": \"test-refresh-token\",\n                \"expires_at\": _time.time()\n                + 86400 * 365,  # 1 year, _check_auth doesn't check expiry\n                \"scope\": \"openid\",\n                \"token_type\": \"Bearer\",\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n    return share_dir\n\n\n@pytest_asyncio.fixture\nasync def acp_client(\n    acp_share_dir: Path, tmp_path: Path\n) -> AsyncIterator[tuple[acp.ClientSideConnection, ACPTestClient]]:\n    \"\"\"Spawn a kimi ACP subprocess and return the SDK connection + test client.\"\"\"\n    test_client = ACPTestClient()\n    env = {\n        **os.environ,\n        \"KIMI_SHARE_DIR\": str(acp_share_dir),\n    }\n\n    async with acp.spawn_agent_process(\n        test_client,\n        _kimi_bin(),\n        \"acp\",\n        env=env,\n        cwd=str(_repo_root()),\n        use_unstable_protocol=True,\n    ) as (conn, process):\n        yield conn, test_client\n"
  },
  {
    "path": "tests/acp/test_protocol_v1.py",
    "content": "\"\"\"Protocol V1 consistency tests using the real ACP SDK client.\"\"\"\n\nfrom __future__ import annotations\n\nimport acp\nimport pytest\n\nfrom kimi_cli.acp.version import CURRENT_VERSION\n\nfrom .conftest import ACPTestClient\n\npytestmark = pytest.mark.asyncio\n\n\nasync def test_initialize_returns_negotiated_version(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n):\n    \"\"\"initialize(protocol_version=1) returns version 1 with expected fields.\"\"\"\n    conn, _ = acp_client\n    resp = await conn.initialize(protocol_version=1)\n\n    assert resp.protocol_version == 1\n    assert resp.agent_capabilities is not None\n    assert resp.agent_capabilities.prompt_capabilities is not None\n    assert resp.agent_info is not None\n    assert resp.agent_info.name == \"Kimi Code CLI\"\n\n\nasync def test_initialize_with_higher_version(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n):\n    \"\"\"initialize(protocol_version=99) returns the server's current max version.\"\"\"\n    conn, _ = acp_client\n    resp = await conn.initialize(protocol_version=99)\n\n    assert resp.protocol_version == CURRENT_VERSION.protocol_version\n\n\nasync def test_new_session_response_shape(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n    tmp_path,\n):\n    \"\"\"new_session returns session_id, modes, and models.\"\"\"\n    conn, _ = acp_client\n    await conn.initialize(protocol_version=1)\n\n    work_dir = tmp_path / \"workdir\"\n    work_dir.mkdir(exist_ok=True)\n    resp = await conn.new_session(cwd=str(work_dir))\n\n    assert isinstance(resp.session_id, str)\n    assert len(resp.session_id) > 0\n    assert resp.modes is not None\n    assert resp.models is not None\n\n\nasync def test_prompt_with_scripted_echo(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n    tmp_path,\n):\n    \"\"\"Full flow: initialize → new_session → prompt returns a valid response.\"\"\"\n    conn, test_client = acp_client\n    await conn.initialize(protocol_version=1)\n\n    work_dir = tmp_path / \"workdir\"\n    work_dir.mkdir(exist_ok=True)\n    session_resp = await conn.new_session(cwd=str(work_dir))\n\n    resp = await conn.prompt(\n        prompt=[acp.text_block(\"Say hello\")],\n        session_id=session_resp.session_id,\n    )\n\n    assert resp.stop_reason in (\"end_turn\", \"max_tokens\", \"max_turn_requests\")\n    # The scripted echo provider should have sent session updates\n    assert len(test_client.updates) > 0\n\n\nasync def test_list_sessions(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n    tmp_path,\n):\n    \"\"\"After creating a session and prompting, list_sessions returns it.\"\"\"\n    conn, _ = acp_client\n    await conn.initialize(protocol_version=1)\n\n    work_dir = tmp_path / \"workdir\"\n    work_dir.mkdir(exist_ok=True)\n    session_resp = await conn.new_session(cwd=str(work_dir))\n\n    # Must prompt first; Session.list() skips empty sessions\n    await conn.prompt(\n        prompt=[acp.text_block(\"Hello\")],\n        session_id=session_resp.session_id,\n    )\n\n    list_resp = await conn.list_sessions(cwd=str(work_dir))\n    session_ids = [s.session_id for s in list_resp.sessions]\n    assert session_resp.session_id in session_ids\n\n\nasync def test_resume_session(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n    tmp_path,\n):\n    \"\"\"initialize → new_session → prompt → resume_session returns modes and models.\"\"\"\n    conn, _ = acp_client\n    await conn.initialize(protocol_version=1)\n\n    work_dir = tmp_path / \"workdir\"\n    work_dir.mkdir(exist_ok=True)\n    session_resp = await conn.new_session(cwd=str(work_dir))\n\n    # Must prompt first so the session is persisted\n    await conn.prompt(\n        prompt=[acp.text_block(\"Hello\")],\n        session_id=session_resp.session_id,\n    )\n\n    resume_resp = await conn.resume_session(\n        cwd=str(work_dir),\n        session_id=session_resp.session_id,\n    )\n\n    assert resume_resp.modes is not None\n    assert resume_resp.modes.current_mode_id == \"default\"\n    assert len(resume_resp.modes.available_modes) > 0\n    assert resume_resp.models is not None\n    assert isinstance(resume_resp.models.current_model_id, str)\n    assert len(resume_resp.models.available_models) > 0\n\n\nasync def test_resume_session_not_found(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n    tmp_path,\n):\n    \"\"\"resume_session with a non-existent session_id raises an error.\"\"\"\n    conn, _ = acp_client\n    await conn.initialize(protocol_version=1)\n\n    work_dir = tmp_path / \"workdir\"\n    work_dir.mkdir(exist_ok=True)\n\n    with pytest.raises(acp.RequestError):\n        await conn.resume_session(\n            cwd=str(work_dir),\n            session_id=\"non-existent-session-id\",\n        )\n\n\nasync def test_cancel_session(\n    acp_client: tuple[acp.ClientSideConnection, ACPTestClient],\n    tmp_path,\n):\n    \"\"\"cancel on an idle session completes without error.\"\"\"\n    conn, _ = acp_client\n    await conn.initialize(protocol_version=1)\n\n    work_dir = tmp_path / \"workdir\"\n    work_dir.mkdir(exist_ok=True)\n    session_resp = await conn.new_session(cwd=str(work_dir))\n\n    # cancel should not raise\n    await conn.cancel(session_id=session_resp.session_id)\n"
  },
  {
    "path": "tests/acp/test_session_notifications.py",
    "content": "from __future__ import annotations\n\nimport acp\nimport pytest\n\nfrom kimi_cli.acp.session import ACPSession\nfrom kimi_cli.wire.types import Notification, TextPart, TurnBegin, TurnEnd\n\n\nclass _FakeConn:\n    def __init__(self) -> None:\n        from typing import Any\n\n        self.updates: list[tuple[str, Any]] = []\n\n    async def session_update(self, session_id: str, update: object) -> None:\n        self.updates.append((session_id, update))\n\n\nclass _FakeCLI:\n    async def run(self, _user_input, _cancel_event):\n        yield TurnBegin(user_input=[TextPart(text=\"hello\")])\n        yield Notification(\n            id=\"n1234567\",\n            category=\"task\",\n            type=\"task.completed\",\n            source_kind=\"background_task\",\n            source_id=\"b1234567\",\n            title=\"Background task completed: build project\",\n            body=\"Task ID: b1234567\\nStatus: completed\",\n            severity=\"success\",\n            created_at=123.456,\n            payload={\"task_id\": \"b1234567\"},\n        )\n        yield TextPart(text=\"done\")\n        yield TurnEnd()\n\n\n@pytest.mark.asyncio\nasync def test_acp_session_surfaces_notification_as_message_chunk() -> None:\n    conn = _FakeConn()\n    session = ACPSession(\"session-1\", _FakeCLI(), conn)  # type: ignore[arg-type]\n\n    response = await session.prompt([acp.text_block(\"hello\")])\n\n    assert response.stop_reason == \"end_turn\"\n    assert len(conn.updates) == 2\n    notification_update = conn.updates[0][1]\n    text_update = conn.updates[1][1]\n    assert notification_update.content.text.startswith(\n        \"[Notification] Background task completed: build project\"\n    )\n    assert \"Task ID: b1234567\" in notification_update.content.text\n    assert text_update.content.text == \"done\"\n"
  },
  {
    "path": "tests/acp/test_version.py",
    "content": "\"\"\"Unit tests for ACP version negotiation.\"\"\"\n\nfrom __future__ import annotations\n\nimport dataclasses\n\nfrom kimi_cli.acp.version import (\n    CURRENT_VERSION,\n    MIN_PROTOCOL_VERSION,\n    SUPPORTED_VERSIONS,\n    negotiate_version,\n)\n\n\ndef test_negotiate_current_version():\n    \"\"\"Client sends protocol_version=1 → server returns v1.\"\"\"\n    result = negotiate_version(1)\n    assert result.protocol_version == 1\n    assert result is CURRENT_VERSION\n\n\ndef test_negotiate_future_version():\n    \"\"\"Client sends protocol_version=99 → server returns the highest supported version.\"\"\"\n    result = negotiate_version(99)\n    max_supported = max(SUPPORTED_VERSIONS.keys())\n    assert result.protocol_version == max_supported\n    assert result is SUPPORTED_VERSIONS[max_supported]\n\n\ndef test_negotiate_zero_version():\n    \"\"\"Client sends protocol_version=0 (below minimum) → server returns CURRENT_VERSION.\"\"\"\n    result = negotiate_version(0)\n    assert result is CURRENT_VERSION\n\n\ndef test_negotiate_negative_version():\n    \"\"\"Client sends negative protocol_version → server returns CURRENT_VERSION.\"\"\"\n    result = negotiate_version(-1)\n    assert result is CURRENT_VERSION\n\n\ndef test_version_spec_immutable():\n    \"\"\"CURRENT_VERSION is a frozen dataclass and cannot be mutated.\"\"\"\n    assert dataclasses.is_dataclass(CURRENT_VERSION)\n    assert CURRENT_VERSION.__dataclass_params__.frozen  # type: ignore[attr-defined]\n\n\ndef test_supported_versions_contains_current():\n    \"\"\"SUPPORTED_VERSIONS includes at least the CURRENT_VERSION.\"\"\"\n    assert CURRENT_VERSION.protocol_version in SUPPORTED_VERSIONS\n    assert SUPPORTED_VERSIONS[CURRENT_VERSION.protocol_version] is CURRENT_VERSION\n\n\ndef test_min_protocol_version_consistency():\n    \"\"\"MIN_PROTOCOL_VERSION is the smallest key in SUPPORTED_VERSIONS.\"\"\"\n    assert min(SUPPORTED_VERSIONS.keys()) == MIN_PROTOCOL_VERSION\n"
  },
  {
    "path": "tests/auth/test_ascii_header.py",
    "content": "\"\"\"Tests for _ascii_header_value and _common_headers in oauth module.\n\nRegression tests for the issue where Linux kernel version strings containing\ntrailing whitespace/newlines (e.g. platform.version() returning\n\"#101-Ubuntu SMP ...\\n\") would be included in HTTP headers, causing\nconnection errors.\n\"\"\"\n\nfrom unittest.mock import patch\n\nfrom kimi_cli.auth.oauth import _ascii_header_value, _common_headers\n\n\nclass TestAsciiHeaderValue:\n    \"\"\"Test cases for _ascii_header_value.\"\"\"\n\n    def test_plain_ascii(self) -> None:\n        assert _ascii_header_value(\"hello\") == \"hello\"\n\n    def test_strips_trailing_newline(self) -> None:\n        \"\"\"Regression: Linux platform.version() may contain trailing newline.\"\"\"\n        assert _ascii_header_value(\"6.8.0-101\\n\") == \"6.8.0-101\"\n\n    def test_non_ascii_sanitized(self) -> None:\n        assert _ascii_header_value(\"héllo\") == \"hllo\"\n\n    def test_all_non_ascii_returns_fallback(self) -> None:\n        assert _ascii_header_value(\"你好\") == \"unknown\"\n\n\nclass TestCommonHeaders:\n    \"\"\"Test that _common_headers returns clean header values.\"\"\"\n\n    @patch(\"kimi_cli.auth.oauth.platform\")\n    @patch(\"kimi_cli.auth.oauth.get_device_id\", return_value=\"abc123\")\n    def test_no_whitespace_in_header_values(self, _mock_device_id, mock_platform) -> None:\n        \"\"\"All header values must be free of leading/trailing whitespace.\"\"\"\n        mock_platform.node.return_value = \"myhost\"\n        mock_platform.version.return_value = \"#101-Ubuntu SMP\\n\"\n        headers = _common_headers()\n        for key, value in headers.items():\n            assert value == value.strip(), f\"Header {key!r} has untrimmed whitespace: {value!r}\"\n"
  },
  {
    "path": "tests/background/test_manager.py",
    "content": "from __future__ import annotations\n\nimport time\n\nimport pytest\n\nfrom kimi_cli.background import TaskRuntime, TaskSpec\nfrom kimi_cli.notifications import NotificationDelivery, NotificationEvent, NotificationView\n\n\ndef test_create_bash_task_persists_starting_state(runtime, monkeypatch):\n    manager = runtime.background_tasks\n\n    monkeypatch.setattr(manager, \"_launch_worker\", lambda task_dir: 4242)\n\n    view = manager.create_bash_task(\n        command=\"sleep 1\",\n        description=\"short sleep\",\n        timeout_s=10,\n        tool_call_id=\"tool-1\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n    )\n\n    assert view.spec.id.startswith(\"b\")\n    assert view.runtime.status == \"starting\"\n    assert view.runtime.worker_pid == 4242\n\n\ndef test_create_bash_task_respects_max_running_tasks(runtime, monkeypatch):\n    runtime.config.background.max_running_tasks = 1\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b1111999\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"already running\",\n        tool_call_id=\"tool-limit\",\n        command=\"sleep 10\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    store.write_runtime(spec.id, TaskRuntime(status=\"running\", updated_at=time.time()))\n\n    monkeypatch.setattr(manager, \"_launch_worker\", lambda task_dir: 4242)\n\n    with pytest.raises(RuntimeError, match=\"Too many background tasks\"):\n        manager.create_bash_task(\n            command=\"sleep 1\",\n            description=\"short sleep\",\n            timeout_s=10,\n            tool_call_id=\"tool-1b\",\n            shell_name=\"bash\",\n            shell_path=\"/bin/bash\",\n            cwd=str(runtime.session.work_dir),\n        )\n\n\ndef test_create_bash_task_does_not_overwrite_worker_terminal_state(runtime, monkeypatch):\n    manager = runtime.background_tasks\n    store = manager.store\n\n    def _launch_and_finish(task_dir):\n        task_id = task_dir.name\n        store.write_runtime(\n            task_id,\n            TaskRuntime(\n                status=\"completed\",\n                worker_pid=4242,\n                exit_code=0,\n                finished_at=time.time(),\n                updated_at=time.time(),\n            ),\n        )\n        return 4242\n\n    monkeypatch.setattr(manager, \"_launch_worker\", _launch_and_finish)\n\n    view = manager.create_bash_task(\n        command=\"echo done\",\n        description=\"instant completion\",\n        timeout_s=10,\n        tool_call_id=\"tool-race\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n    )\n\n    assert view.runtime.status == \"completed\"\n    assert view.runtime.exit_code == 0\n    assert view.runtime.worker_pid == 4242\n\n\ndef test_create_bash_task_records_failed_runtime_when_worker_launch_fails(runtime, monkeypatch):\n    manager = runtime.background_tasks\n\n    def _boom(_task_dir):\n        raise RuntimeError(\"launch boom\")\n\n    monkeypatch.setattr(manager, \"_launch_worker\", _boom)\n\n    with pytest.raises(RuntimeError, match=\"launch boom\"):\n        manager.create_bash_task(\n            command=\"sleep 1\",\n            description=\"broken worker\",\n            timeout_s=10,\n            tool_call_id=\"tool-launch-fail\",\n            shell_name=\"bash\",\n            shell_path=\"/bin/bash\",\n            cwd=str(runtime.session.work_dir),\n        )\n\n    views = manager.store.list_views()\n    assert len(views) == 1\n    assert views[0].runtime.status == \"failed\"\n    assert views[0].runtime.failure_reason == \"Failed to launch worker: launch boom\"\n\n\ndef test_get_task_missing_does_not_create_directory(runtime):\n    manager = runtime.background_tasks\n\n    assert manager.get_task(\"bmissing01\") is None\n    assert not manager.store.task_path(\"bmissing01\").exists()\n\n\ndef test_recover_marks_stale_running_task_as_lost(runtime):\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b1111111\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"stale task\",\n        tool_call_id=\"tool-2\",\n        command=\"sleep 10\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    runtime_state = TaskRuntime(\n        status=\"running\",\n        worker_pid=111,\n        heartbeat_at=time.time() - 60,\n        updated_at=time.time() - 60,\n    )\n    store.write_runtime(spec.id, runtime_state)\n\n    manager.recover()\n\n    recovered = store.merged_view(spec.id)\n    assert recovered.runtime.status == \"lost\"\n    assert recovered.runtime.failure_reason == \"Background worker heartbeat expired\"\n\n\ndef test_recover_marks_stale_starting_task_without_heartbeat_as_lost(runtime):\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b1111112\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"stale starting task\",\n        tool_call_id=\"tool-2b\",\n        command=\"sleep 10\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    runtime_state = TaskRuntime(\n        status=\"starting\",\n        worker_pid=222,\n        started_at=time.time() - 60,\n        updated_at=time.time() - 60,\n    )\n    store.write_runtime(spec.id, runtime_state)\n\n    manager.recover()\n\n    recovered = store.merged_view(spec.id)\n    assert recovered.runtime.status == \"lost\"\n    assert recovered.runtime.failure_reason == \"Background worker never heartbeat after startup\"\n\n\ndef test_recover_marks_stale_kill_requested_task_as_killed(runtime):\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b1111113\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"stale kill task\",\n        tool_call_id=\"tool-2c\",\n        command=\"sleep 10\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    store.write_runtime(\n        spec.id,\n        TaskRuntime(\n            status=\"running\",\n            worker_pid=333,\n            heartbeat_at=time.time() - 60,\n            updated_at=time.time() - 60,\n        ),\n    )\n    control = store.read_control(spec.id).model_copy(\n        update={\"kill_requested_at\": time.time() - 30, \"kill_reason\": \"user stop\"}\n    )\n    store.write_control(spec.id, control)\n\n    manager.recover()\n\n    recovered = store.merged_view(spec.id)\n    assert recovered.runtime.status == \"killed\"\n    assert recovered.runtime.interrupted is True\n    assert recovered.runtime.failure_reason == \"user stop\"\n\n\ndef test_publish_terminal_notifications_creates_notification(runtime):\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b2222222\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"completed task\",\n        tool_call_id=\"tool-3\",\n        command=\"echo done\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    store.write_runtime(\n        spec.id,\n        TaskRuntime(\n            status=\"completed\", exit_code=0, finished_at=time.time(), updated_at=time.time()\n        ),\n    )\n\n    published = manager.publish_terminal_notifications(limit=4)\n    assert len(published) == 1\n    notification = runtime.notifications.store.merged_view(published[0])\n    assert notification.event.source_id == spec.id\n    assert notification.event.type == \"task.completed\"\n    assert notification.event.payload[\"task_id\"] == spec.id\n\n\ndef test_publish_terminal_notifications_marks_timeout_distinctly(runtime):\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b2222223\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"timed out task\",\n        tool_call_id=\"tool-3b\",\n        command=\"sleep 10\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=1,\n    )\n    store.create_task(spec)\n    store.write_runtime(\n        spec.id,\n        TaskRuntime(\n            status=\"failed\",\n            interrupted=True,\n            timed_out=True,\n            finished_at=time.time(),\n            updated_at=time.time(),\n            failure_reason=\"Command timed out after 1s\",\n        ),\n    )\n\n    published = manager.publish_terminal_notifications(limit=4)\n    assert len(published) == 1\n    notification = runtime.notifications.store.merged_view(published[0])\n    assert notification.event.source_id == spec.id\n    assert notification.event.type == \"task.timed_out\"\n    assert notification.event.title == \"Background task timed out: timed out task\"\n    assert notification.event.payload[\"timed_out\"] is True\n    assert notification.event.payload[\"terminal_reason\"] == \"timed_out\"\n\n\ndef test_reconcile_recovers_and_publishes_lost_notification(runtime):\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b2222224\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"recovered lost task\",\n        tool_call_id=\"tool-3c\",\n        command=\"sleep 10\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    store.write_runtime(\n        spec.id,\n        TaskRuntime(\n            status=\"running\",\n            worker_pid=333,\n            heartbeat_at=time.time() - 60,\n            updated_at=time.time() - 60,\n        ),\n    )\n\n    published = manager.reconcile(limit=4)\n\n    assert len(published) == 1\n    notification = runtime.notifications.store.merged_view(published[0])\n    assert notification.event.type == \"task.lost\"\n    assert notification.event.source_id == spec.id\n\n\ndef test_reconcile_does_not_republish_same_terminal_notification(runtime):\n    manager = runtime.background_tasks\n    store = manager.store\n    spec = TaskSpec(\n        id=\"b2222225\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"one-shot completed task\",\n        tool_call_id=\"tool-3d\",\n        command=\"echo done\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    store.write_runtime(\n        spec.id,\n        TaskRuntime(\n            status=\"completed\",\n            exit_code=0,\n            finished_at=time.time(),\n            updated_at=time.time(),\n        ),\n    )\n\n    first = manager.reconcile(limit=4)\n    second = manager.reconcile(limit=4)\n\n    assert len(first) == 1\n    assert second == []\n\n\ndef test_publish_terminal_notifications_limit_skips_deduped_results(runtime, monkeypatch):\n    manager = runtime.background_tasks\n    store = manager.store\n    now = time.time()\n    task_ids: list[str] = []\n    for index in range(2):\n        task_id = f\"b222223{index}\"\n        task_ids.append(task_id)\n        spec = TaskSpec(\n            id=task_id,\n            kind=\"bash\",\n            session_id=runtime.session.id,\n            description=f\"completed task {index}\",\n            tool_call_id=f\"tool-3e-{index}\",\n            command=\"echo done\",\n            shell_name=\"bash\",\n            shell_path=\"/bin/bash\",\n            cwd=str(runtime.session.work_dir),\n            timeout_s=60,\n        )\n        store.create_task(spec)\n        store.write_runtime(\n            spec.id,\n            TaskRuntime(\n                status=\"completed\",\n                exit_code=0,\n                finished_at=now - index,\n                updated_at=now - index,\n            ),\n        )\n\n    existing = NotificationView(\n        event=NotificationEvent(\n            id=\"n-existing\",\n            category=\"task\",\n            type=\"task.completed\",\n            source_kind=\"background_task\",\n            source_id=task_ids[0],\n            title=\"Background task completed: completed task 0\",\n            body=\"Task ID: b2222230\",\n            severity=\"success\",\n            dedupe_key=f\"background_task:{task_ids[0]}:completed\",\n        ),\n        delivery=NotificationDelivery(),\n    )\n    created_ids: dict[str, str] = {}\n\n    monkeypatch.setattr(manager._notifications, \"find_by_dedupe_key\", lambda _key: None)\n\n    def _publish(event: NotificationEvent) -> NotificationView:\n        if event.source_id == task_ids[0]:\n            return existing\n        created_ids[event.source_id] = event.id\n        return NotificationView(event=event, delivery=NotificationDelivery())\n\n    monkeypatch.setattr(manager._notifications, \"publish\", _publish)\n\n    published = manager.publish_terminal_notifications(limit=1)\n\n    assert published == [created_ids[task_ids[1]]]\n\n\n@pytest.mark.asyncio\nasync def test_manager_launches_real_worker_and_waits(runtime):\n    manager = runtime.background_tasks\n\n    view = manager.create_bash_task(\n        command=\"python3 -c \\\"print('bg-ok')\\\"\",\n        description=\"real worker smoke\",\n        timeout_s=30,\n        tool_call_id=\"tool-7\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n    )\n    waited = await manager.wait(view.spec.id, timeout_s=10)\n\n    assert waited.runtime.status == \"completed\"\n    assert waited.runtime.exit_code == 0\n    assert \"bg-ok\" in manager.store.output_path(view.spec.id).read_text(encoding=\"utf-8\")\n\n\n@pytest.mark.asyncio\nasync def test_manager_surfaces_timeout_failure(runtime):\n    manager = runtime.background_tasks\n\n    view = manager.create_bash_task(\n        command=\"sleep 2\",\n        description=\"real worker timeout\",\n        timeout_s=1,\n        tool_call_id=\"tool-8\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n    )\n    waited = await manager.wait(view.spec.id, timeout_s=10)\n\n    assert waited.runtime.status == \"failed\"\n    assert waited.runtime.interrupted is True\n    assert waited.runtime.timed_out is True\n    assert waited.runtime.failure_reason == \"Command timed out after 1s\"\n"
  },
  {
    "path": "tests/background/test_store.py",
    "content": "from __future__ import annotations\n\nfrom kimi_cli.background import BackgroundTaskStore, TaskSpec\n\n\ndef test_create_task_and_merge_view(runtime):\n    store = BackgroundTaskStore(runtime.session.context_file.parent / \"tasks\")\n    spec = TaskSpec(\n        id=\"b1234567\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"run tests\",\n        tool_call_id=\"call-1\",\n        command=\"pytest -q\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n\n    view = store.merged_view(spec.id)\n    assert view.spec.id == \"b1234567\"\n    assert view.runtime.status == \"created\"\n    assert view.control.kill_requested_at is None\n    assert view.consumer.last_seen_output_size == 0\n    assert view.consumer.last_viewed_at is None\n\n\ndef test_read_output_and_tail(runtime):\n    store = BackgroundTaskStore(runtime.session.context_file.parent / \"tasks\")\n    spec = TaskSpec(\n        id=\"b7654321\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"build app\",\n        tool_call_id=\"call-2\",\n        command=\"make build\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    store.output_path(spec.id).write_text(\"line1\\nline2\\nline3\\n\", encoding=\"utf-8\")\n\n    chunk = store.read_output(spec.id, 0, 7, status=\"running\")\n    assert chunk.text == \"line1\\nl\"\n    assert chunk.next_offset == 7\n    assert chunk.eof is False\n\n    tail = store.tail_output(spec.id, max_bytes=100, max_lines=2)\n    assert tail == \"line2\\nline3\"\n\n\ndef test_reading_missing_task_does_not_create_directory(runtime):\n    store = BackgroundTaskStore(runtime.session.context_file.parent / \"tasks\")\n\n    runtime_state = store.read_runtime(\"bmissing01\")\n    control = store.read_control(\"bmissing01\")\n    consumer = store.read_consumer(\"bmissing01\")\n\n    assert runtime_state.status == \"created\"\n    assert control.kill_requested_at is None\n    assert consumer.last_seen_output_size == 0\n    assert not store.task_path(\"bmissing01\").exists()\n\n\ndef test_list_views_skips_invalid_task_directories(runtime):\n    store = BackgroundTaskStore(runtime.session.context_file.parent / \"tasks\")\n    valid = TaskSpec(\n        id=\"b8888888\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"valid task\",\n        tool_call_id=\"call-3\",\n        command=\"echo ok\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(valid)\n\n    invalid_dir = store.root / \"b-invalid\"\n    invalid_dir.mkdir(parents=True, exist_ok=True)\n    (invalid_dir / \"output.log\").write_text(\"orphaned\\n\", encoding=\"utf-8\")\n\n    assert store.list_task_ids() == [\"b8888888\"]\n    views = store.list_views()\n    assert len(views) == 1\n    assert views[0].spec.id == \"b8888888\"\n"
  },
  {
    "path": "tests/background/test_worker.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport time\n\nimport pytest\n\nfrom kimi_cli.background import (\n    BackgroundTaskStore,\n    TaskControl,\n    TaskSpec,\n    run_background_task_worker,\n)\nfrom kimi_cli.background.worker import terminate_process_tree_windows\n\n\n@pytest.mark.asyncio\nasync def test_worker_completes_successfully(runtime):\n    store = BackgroundTaskStore(runtime.session.context_file.parent / \"tasks\")\n    spec = TaskSpec(\n        id=\"b3333333\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"echo hello\",\n        tool_call_id=\"tool-4\",\n        command=\"echo hello\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n\n    await run_background_task_worker(store.task_dir(spec.id), heartbeat_interval_ms=50)\n\n    view = store.merged_view(spec.id)\n    assert view.runtime.status == \"completed\"\n    assert view.runtime.exit_code == 0\n    assert \"hello\" in store.output_path(spec.id).read_text(encoding=\"utf-8\")\n\n\n@pytest.mark.asyncio\nasync def test_worker_respects_kill_control(runtime):\n    store = BackgroundTaskStore(runtime.session.context_file.parent / \"tasks\")\n    spec = TaskSpec(\n        id=\"b4444444\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"sleep task\",\n        tool_call_id=\"tool-5\",\n        command=\"sleep 5\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n\n    worker_task = asyncio.create_task(\n        run_background_task_worker(\n            store.task_dir(spec.id),\n            heartbeat_interval_ms=50,\n            control_poll_interval_ms=20,\n            kill_grace_period_ms=50,\n        )\n    )\n    await asyncio.sleep(0.2)\n    store.write_control(\n        spec.id,\n        TaskControl(\n            kill_requested_at=time.time(),\n            kill_reason=\"stop test\",\n            force=False,\n        ),\n    )\n    await worker_task\n\n    view = store.merged_view(spec.id)\n    assert view.runtime.status == \"killed\"\n    assert view.runtime.interrupted is True\n    assert view.runtime.failure_reason == \"stop test\"\n\n\n@pytest.mark.asyncio\nasync def test_worker_marks_timeout_as_failed(runtime):\n    store = BackgroundTaskStore(runtime.session.context_file.parent / \"tasks\")\n    spec = TaskSpec(\n        id=\"b5554444\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"timeout task\",\n        tool_call_id=\"tool-6\",\n        command=\"sleep 2\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=1,\n    )\n    store.create_task(spec)\n\n    await run_background_task_worker(\n        store.task_dir(spec.id),\n        heartbeat_interval_ms=50,\n        control_poll_interval_ms=20,\n        kill_grace_period_ms=50,\n    )\n\n    view = store.merged_view(spec.id)\n    assert view.runtime.status == \"failed\"\n    assert view.runtime.interrupted is True\n    assert view.runtime.timed_out is True\n    assert view.runtime.failure_reason == \"Command timed out after 1s\"\n\n\ndef test_terminate_process_tree_windows_uses_taskkill_tree(monkeypatch):\n    calls: list[list[str]] = []\n\n    def _run(args, **kwargs):\n        calls.append(args)\n        return None\n\n    monkeypatch.setattr(\"kimi_cli.background.worker.subprocess.run\", _run)\n\n    terminate_process_tree_windows(1234, force=False)\n    terminate_process_tree_windows(1234, force=True)\n\n    assert calls == [\n        [\"taskkill\", \"/PID\", \"1234\", \"/T\"],\n        [\"taskkill\", \"/PID\", \"1234\", \"/T\", \"/F\"],\n    ]\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"Test configuration and fixtures.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport platform\nimport tempfile\nfrom collections.abc import Generator\nfrom contextlib import contextmanager\nfrom pathlib import Path\n\nimport pytest\nfrom kaos import get_current_kaos, reset_current_kaos, set_current_kaos\nfrom kaos.local import LocalKaos\nfrom kaos.path import KaosPath\nfrom kosong.chat_provider.mock import MockChatProvider\nfrom kosong.tooling.empty import EmptyToolset\nfrom pydantic import SecretStr\n\nfrom kimi_cli.auth.oauth import OAuthManager\nfrom kimi_cli.background import BackgroundTaskManager\nfrom kimi_cli.config import Config, MoonshotSearchConfig, get_default_config\nfrom kimi_cli.llm import ALL_MODEL_CAPABILITIES, LLM\nfrom kimi_cli.metadata import WorkDirMeta\nfrom kimi_cli.notifications import NotificationManager\nfrom kimi_cli.session import Session\nfrom kimi_cli.session_state import SessionState\nfrom kimi_cli.soul.agent import Agent, BuiltinSystemPromptArgs, LaborMarket, Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.soul.denwarenji import DenwaRenji\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.tools.background import (\n    TaskList,\n    TaskOutput,\n    TaskStop,\n)\nfrom kimi_cli.tools.dmail import SendDMail\nfrom kimi_cli.tools.file.glob import Glob\nfrom kimi_cli.tools.file.grep_local import Grep\nfrom kimi_cli.tools.file.read import ReadFile\nfrom kimi_cli.tools.file.read_media import ReadMediaFile\nfrom kimi_cli.tools.file.replace import StrReplaceFile\nfrom kimi_cli.tools.file.write import WriteFile\nfrom kimi_cli.tools.multiagent.create import CreateSubagent\nfrom kimi_cli.tools.multiagent.task import Task\nfrom kimi_cli.tools.shell import Shell\nfrom kimi_cli.tools.think import Think\nfrom kimi_cli.tools.todo import SetTodoList\nfrom kimi_cli.tools.web.fetch import FetchURL\nfrom kimi_cli.tools.web.search import SearchWeb\nfrom kimi_cli.utils.environment import Environment\nfrom kimi_cli.wire.file import WireFile\n\n\n@pytest.fixture\ndef config() -> Config:\n    \"\"\"Create a Config instance.\"\"\"\n    conf = get_default_config()\n    conf.services.moonshot_search = MoonshotSearchConfig(\n        base_url=\"https://api.kimi.com/coding/v1/search\",\n        api_key=SecretStr(\"test-api-key\"),\n    )\n    return conf\n\n\n@pytest.fixture\ndef llm() -> LLM:\n    \"\"\"Create a LLM instance.\"\"\"\n    return LLM(\n        chat_provider=MockChatProvider([]),\n        max_context_size=100_000,\n        capabilities=ALL_MODEL_CAPABILITIES,\n    )\n\n\n@pytest.fixture\ndef temp_work_dir() -> Generator[KaosPath]:\n    \"\"\"Create a temporary working directory for tests.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        original_cwd = Path.cwd()\n        p = Path(tmpdir).resolve()\n        os.chdir(p)\n        token = set_current_kaos(LocalKaos())\n        try:\n            yield KaosPath.unsafe_from_local_path(p)\n        finally:\n            reset_current_kaos(token)\n            os.chdir(original_cwd)\n\n\n@pytest.fixture\ndef temp_share_dir() -> Generator[Path]:\n    \"\"\"Create a temporary shared directory for tests.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        yield Path(tmpdir)\n\n\n@pytest.fixture\ndef builtin_args(temp_work_dir: KaosPath) -> BuiltinSystemPromptArgs:\n    \"\"\"Create builtin arguments with temporary work directory.\"\"\"\n    return BuiltinSystemPromptArgs(\n        KIMI_NOW=\"1970-01-01T00:00:00+00:00\",\n        KIMI_WORK_DIR=temp_work_dir,\n        KIMI_WORK_DIR_LS=\"Test ls content\",\n        KIMI_AGENTS_MD=\"Test agents content\",\n        KIMI_SKILLS=\"No skills found.\",\n        KIMI_ADDITIONAL_DIRS_INFO=\"\",\n    )\n\n\n@pytest.fixture\ndef denwa_renji() -> DenwaRenji:\n    \"\"\"Create a DenwaRenji instance.\"\"\"\n    return DenwaRenji()\n\n\n@pytest.fixture\ndef session(temp_work_dir: KaosPath, temp_share_dir: Path) -> Session:\n    \"\"\"Create a Session instance.\"\"\"\n    return Session(\n        id=\"test\",\n        work_dir=temp_work_dir,\n        work_dir_meta=WorkDirMeta(path=str(temp_work_dir), kaos=get_current_kaos().name),\n        context_file=temp_share_dir / \"context.jsonl\",\n        wire_file=WireFile(path=temp_share_dir / \"wire.jsonl\"),\n        state=SessionState(),\n        title=\"Test Session\",\n        updated_at=0.0,\n    )\n\n\n@pytest.fixture\ndef approval() -> Approval:\n    \"\"\"Create a Approval instance.\"\"\"\n    return Approval(yolo=True)\n\n\n@pytest.fixture\ndef labor_market() -> LaborMarket:\n    \"\"\"Create a LaborMarket instance.\"\"\"\n    return LaborMarket()\n\n\n@pytest.fixture\ndef environment() -> Environment:\n    \"\"\"Create an Environment instance.\"\"\"\n    if platform.system() == \"Windows\":\n        return Environment(\n            os_kind=\"Windows\",\n            os_arch=\"x86_64\",\n            os_version=\"1.0\",\n            shell_name=\"Windows PowerShell\",\n            shell_path=KaosPath(\"powershell.exe\"),\n        )\n    else:\n        return Environment(\n            os_kind=\"Unix\",\n            os_arch=\"aarch64\",\n            os_version=\"1.0\",\n            shell_name=\"bash\",\n            shell_path=KaosPath(\"/bin/bash\"),\n        )\n\n\n@pytest.fixture\ndef runtime(\n    config: Config,\n    llm: LLM,\n    builtin_args: BuiltinSystemPromptArgs,\n    denwa_renji: DenwaRenji,\n    session: Session,\n    approval: Approval,\n    labor_market: LaborMarket,\n    environment: Environment,\n) -> Runtime:\n    \"\"\"Create a Runtime instance.\"\"\"\n    notifications = NotificationManager(\n        session.context_file.parent / \"notifications\", config.notifications\n    )\n    rt = Runtime(\n        config=config,\n        llm=llm,\n        builtin_args=builtin_args,\n        denwa_renji=denwa_renji,\n        session=session,\n        approval=approval,\n        labor_market=labor_market,\n        environment=environment,\n        notifications=notifications,\n        background_tasks=BackgroundTaskManager(\n            session,\n            config.background,\n            notifications=notifications,\n        ),\n        skills={},\n        oauth=OAuthManager(config),\n        additional_dirs=[],\n        role=\"root\",\n    )\n    rt.labor_market.add_fixed_subagent(\n        \"mocker\",\n        Agent(\n            name=\"Mocker\",\n            system_prompt=\"You are a mock agent for testing.\",\n            toolset=EmptyToolset(),\n            runtime=rt.copy_for_fixed_subagent(),\n        ),\n        \"The mock agent for testing purposes.\",\n    )\n    return rt\n\n\n@pytest.fixture\ndef toolset() -> KimiToolset:\n    return KimiToolset()\n\n\n@contextmanager\ndef tool_call_context(tool_name: str) -> Generator[None]:\n    \"\"\"Create a tool call context.\"\"\"\n    from kimi_cli.soul.toolset import current_tool_call\n    from kimi_cli.wire.types import ToolCall\n\n    token = current_tool_call.set(\n        ToolCall(id=\"test\", function=ToolCall.FunctionBody(name=tool_name, arguments=None))\n    )\n    try:\n        yield\n    finally:\n        current_tool_call.reset(token)\n\n\n@pytest.fixture\ndef task_tool(runtime: Runtime) -> Task:\n    \"\"\"Create a Task tool instance.\"\"\"\n    return Task(runtime)\n\n\n@pytest.fixture\ndef create_subagent_tool(toolset: KimiToolset, runtime: Runtime) -> CreateSubagent:\n    \"\"\"Create a CreateSubagent tool instance.\"\"\"\n    return CreateSubagent(toolset, runtime)\n\n\n@pytest.fixture\ndef send_dmail_tool(denwa_renji: DenwaRenji) -> SendDMail:\n    \"\"\"Create a SendDMail tool instance.\"\"\"\n    return SendDMail(denwa_renji)\n\n\n@pytest.fixture\ndef think_tool() -> Think:\n    \"\"\"Create a Think tool instance.\"\"\"\n    return Think()\n\n\n@pytest.fixture\ndef set_todo_list_tool() -> SetTodoList:\n    \"\"\"Create a SetTodoList tool instance.\"\"\"\n    return SetTodoList()\n\n\n@pytest.fixture\ndef shell_tool(approval: Approval, environment: Environment, runtime: Runtime) -> Generator[Shell]:\n    \"\"\"Create a Shell tool instance.\"\"\"\n    with tool_call_context(\"Shell\"):\n        yield Shell(approval, environment, runtime)\n\n\n@pytest.fixture\ndef task_list_tool(runtime: Runtime) -> Generator[TaskList]:\n    with tool_call_context(\"TaskList\"):\n        yield TaskList(runtime)\n\n\n@pytest.fixture\ndef task_output_tool(runtime: Runtime) -> TaskOutput:\n    with tool_call_context(\"TaskOutput\"):\n        return TaskOutput(runtime)\n\n\n@pytest.fixture\ndef task_stop_tool(runtime: Runtime, approval: Approval) -> Generator[TaskStop]:\n    with tool_call_context(\"TaskStop\"):\n        yield TaskStop(runtime, approval)\n\n\n@pytest.fixture\ndef read_file_tool(runtime: Runtime) -> ReadFile:\n    \"\"\"Create a ReadFile tool instance.\"\"\"\n    return ReadFile(runtime)\n\n\n@pytest.fixture\ndef read_media_file_tool(runtime: Runtime) -> ReadMediaFile:\n    \"\"\"Create a ReadMediaFile tool instance.\"\"\"\n    return ReadMediaFile(runtime)\n\n\n@pytest.fixture\ndef glob_tool(runtime: Runtime) -> Glob:\n    \"\"\"Create a Glob tool instance.\"\"\"\n    return Glob(runtime)\n\n\n@pytest.fixture\ndef grep_tool() -> Grep:\n    \"\"\"Create a Grep tool instance.\"\"\"\n    return Grep()\n\n\n@pytest.fixture\ndef write_file_tool(runtime: Runtime, approval: Approval) -> Generator[WriteFile]:\n    \"\"\"Create a WriteFile tool instance.\"\"\"\n    with tool_call_context(\"WriteFile\"):\n        yield WriteFile(runtime, approval)\n\n\n@pytest.fixture\ndef str_replace_file_tool(runtime: Runtime, approval: Approval) -> Generator[StrReplaceFile]:\n    \"\"\"Create a StrReplaceFile tool instance.\"\"\"\n    with tool_call_context(\"StrReplaceFile\"):\n        yield StrReplaceFile(runtime, approval)\n\n\n@pytest.fixture\ndef search_web_tool(config: Config, runtime: Runtime) -> SearchWeb:\n    \"\"\"Create a SearchWeb tool instance.\"\"\"\n    return SearchWeb(config, runtime)\n\n\n@pytest.fixture\ndef fetch_url_tool(config: Config, runtime: Runtime) -> FetchURL:\n    \"\"\"Create a FetchURL tool instance.\"\"\"\n    return FetchURL(config, runtime)\n\n\n# misc fixtures\n\n\n@pytest.fixture\ndef outside_file() -> Generator[Path]:\n    \"\"\"Return a path to a file outside the working directory.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        yield Path(tmpdir) / \"outside_file.txt\"\n"
  },
  {
    "path": "tests/core/test_agent_flow.py",
    "content": "from __future__ import annotations\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.skill.flow import Flow, FlowParseError, FlowValidationError, parse_choice\nfrom kimi_cli.skill.flow.d2 import parse_d2_flowchart\nfrom kimi_cli.skill.flow.mermaid import parse_mermaid_flowchart\n\n\ndef test_parse_flowchart_basic() -> None:\n    flow = parse_mermaid_flowchart(\n        \"\\n\".join(\n            [\n                \"flowchart TD\",\n                \"A([BEGIN]) --> B[Search stdrc]\",\n                \"B --> C{Enough?}\",\n                \"C -->|yes| D([END])\",\n                \"C -->|no| B\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"A\",\n            \"end_id\": \"D\",\n            \"nodes\": {\n                \"A\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"B\": {\"kind\": \"task\", \"label\": \"Search stdrc\"},\n                \"C\": {\"kind\": \"decision\", \"label\": \"Enough?\"},\n                \"D\": {\"kind\": \"end\", \"label\": \"END\"},\n            },\n            \"outgoing\": {\n                \"A\": [{\"dst\": \"B\", \"label\": None}],\n                \"B\": [{\"dst\": \"C\", \"label\": None}],\n                \"C\": [\n                    {\"dst\": \"B\", \"label\": \"no\"},\n                    {\"dst\": \"D\", \"label\": \"yes\"},\n                ],\n                \"D\": [],\n            },\n        }\n    )\n\n\ndef test_parse_flowchart_implicit_nodes() -> None:\n    flow = parse_mermaid_flowchart(\n        \"\\n\".join(\n            [\n                \"flowchart TD\",\n                \"BEGIN --> TASK\",\n                \"TASK --> END\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"BEGIN\",\n            \"end_id\": \"END\",\n            \"nodes\": {\n                \"BEGIN\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"END\": {\"kind\": \"end\", \"label\": \"END\"},\n                \"TASK\": {\"kind\": \"task\", \"label\": \"TASK\"},\n            },\n            \"outgoing\": {\n                \"BEGIN\": [{\"dst\": \"TASK\", \"label\": None}],\n                \"END\": [],\n                \"TASK\": [{\"dst\": \"END\", \"label\": None}],\n            },\n        }\n    )\n\n\ndef test_parse_flowchart_quoted_label() -> None:\n    flow = parse_mermaid_flowchart(\n        \"\\n\".join(\n            [\n                \"flowchart TD\",\n                'A([\"BEGIN\"]) --> B[\"hello | world\"]',\n                \"B --> C([END])\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"A\",\n            \"end_id\": \"C\",\n            \"nodes\": {\n                \"A\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"B\": {\"kind\": \"task\", \"label\": \"hello | world\"},\n                \"C\": {\"kind\": \"end\", \"label\": \"END\"},\n            },\n            \"outgoing\": {\n                \"A\": [{\"dst\": \"B\", \"label\": None}],\n                \"B\": [{\"dst\": \"C\", \"label\": None}],\n                \"C\": [],\n            },\n        }\n    )\n\n\ndef test_parse_flowchart_multi_edges_require_labels() -> None:\n    with pytest.raises(FlowValidationError):\n        parse_mermaid_flowchart(\n            \"\\n\".join(\n                [\n                    \"flowchart TD\",\n                    \"A([BEGIN]) --> B[Pick]\",\n                    \"B --> C([END])\",\n                    \"B --> D([END])\",\n                ]\n            )\n        )\n\n\ndef test_parse_d2_flowchart_typical_example() -> None:\n    flow = parse_d2_flowchart(\n        \"\\n\".join(\n            [\n                'a: \"append a random line to file test.txt\"',\n                \"a.shape: rectangle\",\n                \"a.foo.bar\",\n                'b: \"does test.txt contain more than 3 lines?\" {',\n                \"  sub1 -> sub2\",\n                \"  sub2: {\",\n                \"    1\",\n                \"  }\",\n                \"}\",\n                \"BEGIN -> a -> b\",\n                \"b -> a: no\",\n                \"not_used\",\n                \"b -> END: yes\",\n                \"b -> END: yes2\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"BEGIN\",\n            \"end_id\": \"END\",\n            \"nodes\": {\n                \"BEGIN\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"END\": {\"kind\": \"end\", \"label\": \"END\"},\n                \"a\": {\"kind\": \"task\", \"label\": \"append a random line to file test.txt\"},\n                \"a.foo.bar\": {\"kind\": \"task\", \"label\": \"a.foo.bar\"},\n                \"b\": {\"kind\": \"decision\", \"label\": \"does test.txt contain more than 3 lines?\"},\n                \"not_used\": {\"kind\": \"task\", \"label\": \"not_used\"},\n            },\n            \"outgoing\": {\n                \"BEGIN\": [{\"dst\": \"a\", \"label\": None}],\n                \"END\": [],\n                \"a\": [{\"dst\": \"b\", \"label\": None}],\n                \"a.foo.bar\": [],\n                \"b\": [\n                    {\"dst\": \"END\", \"label\": \"yes\"},\n                    {\"dst\": \"END\", \"label\": \"yes2\"},\n                    {\"dst\": \"a\", \"label\": \"no\"},\n                ],\n                \"not_used\": [],\n            },\n        }\n    )\n\n\ndef test_parse_d2_flowchart_markdown_block_label() -> None:\n    flow = parse_d2_flowchart(\n        \"\\n\".join(\n            [\n                \"BEGIN -> explanation -> END\",\n                \"explanation: |md\",\n                \"  # I can do headers\",\n                \"  - lists\",\n                \"  - lists\",\n                \"\",\n                \"  And other normal markdown stuff\",\n                \"|\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"BEGIN\",\n            \"end_id\": \"END\",\n            \"nodes\": {\n                \"BEGIN\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"END\": {\"kind\": \"end\", \"label\": \"END\"},\n                \"explanation\": {\n                    \"kind\": \"task\",\n                    \"label\": (\n                        \"# I can do headers\\n- lists\\n- lists\\n\\nAnd other normal markdown stuff\"\n                    ),\n                },\n            },\n            \"outgoing\": {\n                \"BEGIN\": [{\"dst\": \"explanation\", \"label\": None}],\n                \"END\": [],\n                \"explanation\": [{\"dst\": \"END\", \"label\": None}],\n            },\n        }\n    )\n\n\ndef test_parse_d2_flowchart_markdown_block_escapes_quotes() -> None:\n    flow = parse_d2_flowchart(\n        \"\\n\".join(\n            [\n                \"BEGIN -> note -> END\",\n                \"note: |md\",\n                '  Use \"quotes\" and \\\\\\\\ paths',\n                \"|\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"BEGIN\",\n            \"end_id\": \"END\",\n            \"nodes\": {\n                \"BEGIN\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"END\": {\"kind\": \"end\", \"label\": \"END\"},\n                \"note\": {\"kind\": \"task\", \"label\": 'Use \"quotes\" and \\\\\\\\ paths'},\n            },\n            \"outgoing\": {\n                \"BEGIN\": [{\"dst\": \"note\", \"label\": None}],\n                \"END\": [],\n                \"note\": [{\"dst\": \"END\", \"label\": None}],\n            },\n        }\n    )\n\n\ndef test_parse_d2_flowchart_markdown_block_with_comment() -> None:\n    flow = parse_d2_flowchart(\n        \"\\n\".join(\n            [\n                \"BEGIN -> note -> END\",\n                \"note: |md # keep this as markdown\",\n                \"  A: B\",\n                \"|\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"BEGIN\",\n            \"end_id\": \"END\",\n            \"nodes\": {\n                \"BEGIN\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"END\": {\"kind\": \"end\", \"label\": \"END\"},\n                \"note\": {\"kind\": \"task\", \"label\": \"A: B\"},\n            },\n            \"outgoing\": {\n                \"BEGIN\": [{\"dst\": \"note\", \"label\": None}],\n                \"END\": [],\n                \"note\": [{\"dst\": \"END\", \"label\": None}],\n            },\n        }\n    )\n\n\ndef test_parse_d2_flowchart_markdown_block_dedent() -> None:\n    flow = parse_d2_flowchart(\n        \"\\n\".join(\n            [\n                \"BEGIN -> note -> END\",\n                \"note: |md\",\n                \"    line one\",\n                \"      line two\",\n                \"    line three\",\n                \"|\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"BEGIN\",\n            \"end_id\": \"END\",\n            \"nodes\": {\n                \"BEGIN\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"END\": {\"kind\": \"end\", \"label\": \"END\"},\n                \"note\": {\n                    \"kind\": \"task\",\n                    \"label\": \"line one\\n  line two\\nline three\",\n                },\n            },\n            \"outgoing\": {\n                \"BEGIN\": [{\"dst\": \"note\", \"label\": None}],\n                \"END\": [],\n                \"note\": [{\"dst\": \"END\", \"label\": None}],\n            },\n        }\n    )\n\n\ndef test_parse_d2_flowchart_markdown_block_unclosed() -> None:\n    with pytest.raises(FlowParseError):\n        parse_d2_flowchart(\n            \"\\n\".join(\n                [\n                    \"BEGIN -> note -> END\",\n                    \"note: |md\",\n                    \"  missing terminator\",\n                ]\n            )\n        )\n\n\ndef test_parse_flowchart_ignores_style_and_shapes() -> None:\n    flow = parse_mermaid_flowchart(\n        \"\\n\".join(\n            [\n                \"flowchart TB\",\n                \"classDef highlight fill:#f9f,stroke:#333,stroke-width:2px;\",\n                \"A([BEGIN]) --> B[Working tree clean?]\",\n                \"B -- yes --> C{Prep PR}\",\n                \"B -- no --> D([END])\",\n                \"C --> D\",\n                \"class B highlight\",\n                \"style C fill:#bbf\",\n            ]\n        )\n    )\n\n    assert _flow_snapshot(flow) == snapshot(\n        {\n            \"begin_id\": \"A\",\n            \"end_id\": \"D\",\n            \"nodes\": {\n                \"A\": {\"kind\": \"begin\", \"label\": \"BEGIN\"},\n                \"B\": {\"kind\": \"decision\", \"label\": \"Working tree clean?\"},\n                \"C\": {\"kind\": \"task\", \"label\": \"Prep PR\"},\n                \"D\": {\"kind\": \"end\", \"label\": \"END\"},\n            },\n            \"outgoing\": {\n                \"A\": [{\"dst\": \"B\", \"label\": None}],\n                \"B\": [\n                    {\"dst\": \"C\", \"label\": \"yes\"},\n                    {\"dst\": \"D\", \"label\": \"no\"},\n                ],\n                \"C\": [{\"dst\": \"D\", \"label\": None}],\n                \"D\": [],\n            },\n        }\n    )\n\n\ndef test_parse_choice_last_match() -> None:\n    assert parse_choice(\"Answer <choice>a</choice> <choice>b</choice>\") == \"b\"\n    assert parse_choice(\"No choice tag\") is None\n\n\ndef _flow_snapshot(flow: Flow) -> dict[str, object]:\n    return {\n        \"begin_id\": flow.begin_id,\n        \"end_id\": flow.end_id,\n        \"nodes\": {\n            node_id: {\"kind\": flow.nodes[node_id].kind, \"label\": flow.nodes[node_id].label}\n            for node_id in sorted(flow.nodes)\n        },\n        \"outgoing\": {\n            node_id: [\n                {\"dst\": edge.dst, \"label\": edge.label}\n                for edge in sorted(\n                    flow.outgoing.get(node_id, []),\n                    key=lambda edge: (edge.dst, edge.label or \"\"),\n                )\n            ]\n            for node_id in sorted(flow.nodes)\n        },\n    }\n"
  },
  {
    "path": "tests/core/test_agent_spec.py",
    "content": "from __future__ import annotations\n\nimport re\nimport tempfile\nfrom collections.abc import Generator\nfrom pathlib import Path\nfrom typing import Any\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.agentspec import DEFAULT_AGENT_FILE, load_agent_spec\nfrom kimi_cli.exception import AgentSpecError\n\n\ndef test_load_default_agent_spec():\n    \"\"\"Test loading the default agent specification.\"\"\"\n    spec = load_agent_spec(DEFAULT_AGENT_FILE)\n\n    assert spec.name == snapshot(\"\")\n    assert spec.system_prompt_path == DEFAULT_AGENT_FILE.parent / \"system.md\"\n    assert spec.system_prompt_args == snapshot({\"ROLE_ADDITIONAL\": \"\"})\n    assert spec.exclude_tools == snapshot([])\n    assert spec.tools == snapshot(\n        [\n            \"kimi_cli.tools.multiagent:Task\",\n            \"kimi_cli.tools.ask_user:AskUserQuestion\",\n            \"kimi_cli.tools.todo:SetTodoList\",\n            \"kimi_cli.tools.shell:Shell\",\n            \"kimi_cli.tools.background:TaskList\",\n            \"kimi_cli.tools.background:TaskOutput\",\n            \"kimi_cli.tools.background:TaskStop\",\n            \"kimi_cli.tools.file:ReadFile\",\n            \"kimi_cli.tools.file:ReadMediaFile\",\n            \"kimi_cli.tools.file:Glob\",\n            \"kimi_cli.tools.file:Grep\",\n            \"kimi_cli.tools.file:WriteFile\",\n            \"kimi_cli.tools.file:StrReplaceFile\",\n            \"kimi_cli.tools.web:SearchWeb\",\n            \"kimi_cli.tools.web:FetchURL\",\n            \"kimi_cli.tools.plan:ExitPlanMode\",\n            \"kimi_cli.tools.plan.enter:EnterPlanMode\",\n        ]\n    )\n    subagents = {\n        name: (spec.path.relative_to(DEFAULT_AGENT_FILE.parent).as_posix(), spec.description)\n        for name, spec in spec.subagents.items()\n    }\n    assert subagents == snapshot(\n        {\"coder\": (\"sub.yaml\", \"Good at general software engineering tasks.\")}\n    )\n\n    subagent_specs = {name: load_agent_spec(spec.path) for name, spec in spec.subagents.items()}\n    assert subagent_specs[\"coder\"].name == snapshot(\"\")\n    assert subagent_specs[\"coder\"].system_prompt_path == DEFAULT_AGENT_FILE.parent / \"system.md\"\n    assert subagent_specs[\"coder\"].system_prompt_args == snapshot(\n        {\n            \"ROLE_ADDITIONAL\": \"You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You need to provide a comprehensive summary on what you have done and learned in your final message. If you wrote or modified any files, you must mention them in the summary.\\n\"  # noqa: E501\n        }\n    )\n    assert subagent_specs[\"coder\"].exclude_tools == snapshot(\n        [\n            \"kimi_cli.tools.multiagent:Task\",\n            \"kimi_cli.tools.multiagent:CreateSubagent\",\n            \"kimi_cli.tools.dmail:SendDMail\",\n            \"kimi_cli.tools.todo:SetTodoList\",\n            \"kimi_cli.tools.plan:ExitPlanMode\",\n            \"kimi_cli.tools.plan.enter:EnterPlanMode\",\n        ]\n    )\n    assert subagent_specs[\"coder\"].tools == snapshot(\n        [\n            \"kimi_cli.tools.multiagent:Task\",\n            \"kimi_cli.tools.ask_user:AskUserQuestion\",\n            \"kimi_cli.tools.todo:SetTodoList\",\n            \"kimi_cli.tools.shell:Shell\",\n            \"kimi_cli.tools.background:TaskList\",\n            \"kimi_cli.tools.background:TaskOutput\",\n            \"kimi_cli.tools.background:TaskStop\",\n            \"kimi_cli.tools.file:ReadFile\",\n            \"kimi_cli.tools.file:ReadMediaFile\",\n            \"kimi_cli.tools.file:Glob\",\n            \"kimi_cli.tools.file:Grep\",\n            \"kimi_cli.tools.file:WriteFile\",\n            \"kimi_cli.tools.file:StrReplaceFile\",\n            \"kimi_cli.tools.web:SearchWeb\",\n            \"kimi_cli.tools.web:FetchURL\",\n            \"kimi_cli.tools.plan:ExitPlanMode\",\n            \"kimi_cli.tools.plan.enter:EnterPlanMode\",\n        ]\n    )\n    sub_subagents = {\n        name: (spec.path.relative_to(DEFAULT_AGENT_FILE.parent).as_posix(), spec.description)\n        for name, spec in subagent_specs[\"coder\"].subagents.items()\n    }\n    assert sub_subagents == snapshot({})\n\n\ndef test_load_agent_spec_basic(agent_file: Path):\n    \"\"\"Test loading a basic agent specification.\"\"\"\n    spec = load_agent_spec(agent_file)\n\n    assert spec.name == snapshot(\"Test Agent\")\n    assert spec.system_prompt_path == agent_file.parent / \"system.md\"\n    assert spec.tools == snapshot([\"kimi_cli.tools.think:Think\"])\n\n\ndef test_load_agent_spec_missing_name(agent_file_no_name: Path):\n    \"\"\"Test missing agent name raises AgentSpecError.\"\"\"\n    with pytest.raises(AgentSpecError, match=\"Agent name is required\"):\n        load_agent_spec(agent_file_no_name)\n\n\ndef test_load_agent_spec_missing_system_prompt(agent_file_no_prompt: Path):\n    \"\"\"Test missing system prompt path raises AgentSpecError.\"\"\"\n    with pytest.raises(AgentSpecError, match=\"System prompt path is required\"):\n        load_agent_spec(agent_file_no_prompt)\n\n\ndef test_load_agent_spec_missing_tools(agent_file_no_tools: Path):\n    \"\"\"Test missing tools raises AgentSpecError.\"\"\"\n    with pytest.raises(AgentSpecError, match=\"Tools are required\"):\n        load_agent_spec(agent_file_no_tools)\n\n\ndef test_load_agent_spec_with_exclude_tools(agent_file_with_tools: Path):\n    \"\"\"Test loading agent spec with excluded tools.\"\"\"\n    spec = load_agent_spec(agent_file_with_tools)\n\n    assert spec.tools == snapshot([\"kimi_cli.tools.think:Think\", \"kimi_cli.tools.shell:Shell\"])\n    assert spec.exclude_tools == snapshot([\"kimi_cli.tools.shell:Shell\"])\n\n\ndef test_load_agent_spec_extension(agent_file_extending: Path):\n    \"\"\"Test loading agent spec with extension.\"\"\"\n    spec = load_agent_spec(agent_file_extending)\n\n    assert spec.name == snapshot(\"Extended Agent\")\n    assert spec.tools == snapshot([\"kimi_cli.tools.think:Think\"])\n\n\ndef test_load_agent_spec_default_extension():\n    \"\"\"Test loading agent spec with default extension.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create extending agent\n        extending_agent = tmpdir / \"extending.yaml\"\n        extending_agent.write_text(\"\"\"\nversion: 1\nagent:\n  extend: default\n  system_prompt_args:\n    CUSTOM_ARG: \"custom_value\"\n  exclude_tools:\n    - \"kimi_cli.tools.web:SearchWeb\"\n    - \"kimi_cli.tools.web:FetchURL\"\n\"\"\")\n\n        spec = load_agent_spec(extending_agent)\n\n        assert spec.name == snapshot(\"\")\n        assert spec.system_prompt_path == DEFAULT_AGENT_FILE.parent / \"system.md\"\n        assert spec.system_prompt_args == snapshot(\n            {\"ROLE_ADDITIONAL\": \"\", \"CUSTOM_ARG\": \"custom_value\"}\n        )\n        assert spec.tools == snapshot(\n            [\n                \"kimi_cli.tools.multiagent:Task\",\n                \"kimi_cli.tools.ask_user:AskUserQuestion\",\n                \"kimi_cli.tools.todo:SetTodoList\",\n                \"kimi_cli.tools.shell:Shell\",\n                \"kimi_cli.tools.background:TaskList\",\n                \"kimi_cli.tools.background:TaskOutput\",\n                \"kimi_cli.tools.background:TaskStop\",\n                \"kimi_cli.tools.file:ReadFile\",\n                \"kimi_cli.tools.file:ReadMediaFile\",\n                \"kimi_cli.tools.file:Glob\",\n                \"kimi_cli.tools.file:Grep\",\n                \"kimi_cli.tools.file:WriteFile\",\n                \"kimi_cli.tools.file:StrReplaceFile\",\n                \"kimi_cli.tools.web:SearchWeb\",\n                \"kimi_cli.tools.web:FetchURL\",\n                \"kimi_cli.tools.plan:ExitPlanMode\",\n                \"kimi_cli.tools.plan.enter:EnterPlanMode\",\n            ]\n        )\n        assert spec.exclude_tools == snapshot(\n            [\"kimi_cli.tools.web:SearchWeb\", \"kimi_cli.tools.web:FetchURL\"]\n        )\n        assert \"coder\" in spec.subagents\n\n\ndef test_load_agent_spec_unsupported_version():\n    \"\"\"Test loading agent spec with unsupported version raises ValueError.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\"\"\"\nversion: 2\nagent:\n  name: \"Test Agent\"\n  system_prompt_path: ./system.md\n  tools: [\"kimi_cli.tools.think:Think\"]\n\"\"\")\n\n        with pytest.raises(AgentSpecError, match=\"Unsupported agent spec version: 2\"):\n            load_agent_spec(agent_yaml)\n\n\ndef test_load_agent_spec_nonexistent_file():\n    \"\"\"Test loading nonexistent agent spec file raises AssertionError.\"\"\"\n    nonexistent = Path(\"/nonexistent/agent.yaml\")\n    with pytest.raises(\n        AgentSpecError,\n        match=re.compile(r\"Agent spec file not found: [\\\\/]nonexistent[\\\\/]agent.yaml\"),\n    ):\n        load_agent_spec(nonexistent)\n\n\n# Fixtures for test files\n\n\n@pytest.fixture\ndef agent_file() -> Generator[Path, Any, Any]:\n    \"\"\"Create a basic agent configuration file.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create system.md\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"You are a test agent\")\n\n        # Create agent.yaml\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\"\"\"\nversion: 1\nagent:\n  name: \"Test Agent\"\n  system_prompt_path: ./system.md\n  tools: [\"kimi_cli.tools.think:Think\"]\n\"\"\")\n\n        yield agent_yaml\n\n\n@pytest.fixture\ndef agent_file_no_name() -> Generator[Path, Any, Any]:\n    \"\"\"Create an agent configuration file without name.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create system.md\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"You are a test agent\")\n\n        # Create agent.yaml\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\"\"\"\nversion: 1\nagent:\n  system_prompt_path: ./system.md\n  tools: [\"kimi_cli.tools.think:Think\"]\n\"\"\")\n\n        yield agent_yaml\n\n\n@pytest.fixture\ndef agent_file_no_prompt() -> Generator[Path, Any, Any]:\n    \"\"\"Create an agent configuration file without system prompt path.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create agent.yaml\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\"\"\"\nversion: 1\nagent:\n  name: \"Test Agent\"\n  tools: [\"kimi_cli.tools.think:Think\"]\n\"\"\")\n\n        yield agent_yaml\n\n\n@pytest.fixture\ndef agent_file_no_tools() -> Generator[Path, Any, Any]:\n    \"\"\"Create an agent configuration file without tools.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create system.md\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"You are a test agent\")\n\n        # Create agent.yaml\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\"\"\"\nversion: 1\nagent:\n  name: \"Test Agent\"\n  system_prompt_path: ./system.md\n\"\"\")\n\n        yield agent_yaml\n\n\n@pytest.fixture\ndef agent_file_with_tools() -> Generator[Path, Any, Any]:\n    \"\"\"Create an agent configuration file with tools and exclude_tools.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create system.md\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"You are a test agent\")\n\n        # Create agent.yaml\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\"\"\"\nversion: 1\nagent:\n  name: \"Test Agent\"\n  system_prompt_path: ./system.md\n  tools: [\"kimi_cli.tools.think:Think\", \"kimi_cli.tools.shell:Shell\"]\n  exclude_tools: [\"kimi_cli.tools.shell:Shell\"]\n\"\"\")\n\n        yield agent_yaml\n\n\n@pytest.fixture\ndef agent_file_extending() -> Generator[Path, Any, Any]:\n    \"\"\"Create an agent configuration file that extends another.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create base agent\n        base_agent = tmpdir / \"base.yaml\"\n        base_agent.write_text(\"\"\"\nversion: 1\nagent:\n  name: \"Base Agent\"\n  system_prompt_path: ./system.md\n  tools: [\"kimi_cli.tools.think:Think\"]\n\"\"\")\n\n        # Create system.md\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"Base system prompt\")\n\n        # Create extending agent\n        extending_agent = tmpdir / \"extending.yaml\"\n        extending_agent.write_text(\"\"\"\nversion: 1\nagent:\n  extend: ./base.yaml\n  name: \"Extended Agent\"\n  system_prompt_args:\n    CUSTOM_ARG: \"custom_value\"\n\"\"\")\n\n        yield extending_agent\n"
  },
  {
    "path": "tests/core/test_ask_user_plan_mode.py",
    "content": "\"\"\"Tests for AskUserQuestion description stability under plan mode.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.tools.ask_user import _BASE_DESCRIPTION, AskUserQuestion\n\n\nclass TestAskUserDescriptionStability:\n    def test_description_stays_static_when_soul_toggles_plan_mode(\n        self, runtime: Runtime, tmp_path: Path\n    ) -> None:\n        \"\"\"KimiSoul plan mode toggles must not alter AskUserQuestion's description.\"\"\"\n        toolset = KimiToolset()\n        tool = AskUserQuestion()\n        toolset.add(tool)\n\n        agent = Agent(\n            name=\"Test Agent\",\n            system_prompt=\"Test system prompt.\",\n            toolset=toolset,\n            runtime=runtime,\n        )\n        soul = KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n        before = tool.base.description\n        soul._set_plan_mode(True, source=\"tool\")\n        during = tool.base.description\n        soul._set_plan_mode(False, source=\"tool\")\n        after = tool.base.description\n\n        assert before == _BASE_DESCRIPTION\n        assert during == _BASE_DESCRIPTION\n        assert after == _BASE_DESCRIPTION\n"
  },
  {
    "path": "tests/core/test_config.py",
    "content": "from __future__ import annotations\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.config import (\n    Config,\n    get_default_config,\n    load_config,\n    load_config_from_string,\n)\nfrom kimi_cli.exception import ConfigError\n\n\ndef test_default_config():\n    config = get_default_config()\n    assert config == snapshot(Config())\n\n\ndef test_default_config_dump():\n    config = get_default_config()\n    assert config.model_dump() == snapshot(\n        {\n            \"default_model\": \"\",\n            \"default_thinking\": False,\n            \"default_yolo\": False,\n            \"default_editor\": \"\",\n            \"models\": {},\n            \"providers\": {},\n            \"loop_control\": {\n                \"max_steps_per_turn\": 100,\n                \"max_retries_per_step\": 3,\n                \"max_ralph_iterations\": 0,\n                \"reserved_context_size\": 50000,\n                \"compaction_trigger_ratio\": 0.85,\n            },\n            \"background\": {\n                \"max_running_tasks\": 4,\n                \"read_max_bytes\": 30000,\n                \"notification_tail_lines\": 20,\n                \"notification_tail_chars\": 3000,\n                \"wait_poll_interval_ms\": 500,\n                \"worker_heartbeat_interval_ms\": 5000,\n                \"worker_stale_after_ms\": 15000,\n                \"kill_grace_period_ms\": 2000,\n                \"keep_alive_on_exit\": False,\n            },\n            \"notifications\": {\n                \"claim_stale_after_ms\": 15000,\n            },\n            \"services\": {\"moonshot_search\": None, \"moonshot_fetch\": None},\n            \"mcp\": {\"client\": {\"tool_call_timeout_ms\": 60000}},\n        }\n    )\n\n\ndef test_load_config_text_toml():\n    config = load_config_from_string('default_model = \"\"\\n')\n    assert config == get_default_config()\n\n\ndef test_load_config_text_json():\n    config = load_config_from_string('{\"default_model\": \"\"}')\n    assert config == get_default_config()\n\n\ndef test_load_config_sets_source_file(tmp_path):\n    config_file = tmp_path / \"custom.toml\"\n\n    config = load_config(config_file)\n\n    assert config.source_file == config_file.resolve()\n    assert not config.is_from_default_location\n\n\ndef test_load_config_text_has_no_source_file():\n    config = load_config_from_string('{\"default_model\": \"\"}')\n\n    assert config.source_file is None\n\n\ndef test_load_config_text_invalid():\n    with pytest.raises(ConfigError, match=\"Invalid configuration text\"):\n        load_config_from_string(\"not valid {\")\n\n\ndef test_load_config_invalid_ralph_iterations():\n    with pytest.raises(ConfigError, match=\"max_ralph_iterations\"):\n        load_config_from_string('{\"loop_control\": {\"max_ralph_iterations\": -2}}')\n\n\ndef test_load_config_reserved_context_size():\n    config = load_config_from_string('{\"loop_control\": {\"reserved_context_size\": 30000}}')\n    assert config.loop_control.reserved_context_size == 30000\n\n\ndef test_load_config_max_steps_per_turn():\n    config = load_config_from_string(\"[loop_control]\\nmax_steps_per_turn = 42\\n\")\n    assert config.loop_control.max_steps_per_turn == 42\n\n\ndef test_load_config_max_steps_per_run():\n    config = load_config_from_string('{\"loop_control\": {\"max_steps_per_run\": 7}}')\n    assert config.loop_control.max_steps_per_turn == 7\n\n\ndef test_load_config_reserved_context_size_too_low():\n    with pytest.raises(ConfigError, match=\"reserved_context_size\"):\n        load_config_from_string('{\"loop_control\": {\"reserved_context_size\": 500}}')\n\n\ndef test_load_config_compaction_trigger_ratio():\n    config = load_config_from_string('{\"loop_control\": {\"compaction_trigger_ratio\": 0.8}}')\n    assert config.loop_control.compaction_trigger_ratio == 0.8\n\n\ndef test_load_config_compaction_trigger_ratio_default():\n    config = load_config_from_string(\"{}\")\n    assert config.loop_control.compaction_trigger_ratio == 0.85\n\n\ndef test_load_config_compaction_trigger_ratio_too_low():\n    with pytest.raises(ConfigError, match=\"compaction_trigger_ratio\"):\n        load_config_from_string('{\"loop_control\": {\"compaction_trigger_ratio\": 0.3}}')\n\n\ndef test_load_config_compaction_trigger_ratio_too_high():\n    with pytest.raises(ConfigError, match=\"compaction_trigger_ratio\"):\n        load_config_from_string('{\"loop_control\": {\"compaction_trigger_ratio\": 1.0}}')\n"
  },
  {
    "path": "tests/core/test_context.py",
    "content": "\"\"\"Tests for Context class, focusing on system prompt persistence in context.jsonl.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nimport pytest\nfrom kosong.message import Message, Role\n\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.wire.types import TextPart\n\n\ndef _write_lines(path: Path, lines: list[dict]) -> None:\n    \"\"\"Write JSON lines to a file.\"\"\"\n    path.write_text(\n        \"\".join(json.dumps(line) + \"\\n\" for line in lines),\n        encoding=\"utf-8\",\n    )\n\n\ndef _read_lines(path: Path) -> list[dict]:\n    \"\"\"Read all JSON lines from a file.\"\"\"\n    return [\n        json.loads(line) for line in path.read_text(encoding=\"utf-8\").splitlines() if line.strip()\n    ]\n\n\ndef _message_dict(role: Role, text: str) -> dict:\n    \"\"\"Create a serialized message dict.\"\"\"\n    return json.loads(\n        Message(role=role, content=[TextPart(text=text)]).model_dump_json(exclude_none=True)\n    )\n\n\n# --- write_system_prompt tests ---\n\n\n@pytest.mark.asyncio\nasync def test_write_system_prompt_empty_file(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    path.touch()\n    ctx = Context(file_backend=path)\n\n    await ctx.write_system_prompt(\"You are a helpful assistant.\")\n\n    assert ctx.system_prompt == \"You are a helpful assistant.\"\n    lines = _read_lines(path)\n    assert len(lines) == 1\n    assert lines[0] == {\"role\": \"_system_prompt\", \"content\": \"You are a helpful assistant.\"}\n\n\n@pytest.mark.asyncio\nasync def test_write_system_prompt_nonexistent_file(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    ctx = Context(file_backend=path)\n\n    await ctx.write_system_prompt(\"Test prompt\")\n\n    assert ctx.system_prompt == \"Test prompt\"\n    assert path.exists()\n    lines = _read_lines(path)\n    assert lines[0][\"role\"] == \"_system_prompt\"\n\n\n@pytest.mark.asyncio\nasync def test_write_system_prompt_prepends_to_existing(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    msg = _message_dict(\"user\", \"Hello\")\n    _write_lines(path, [msg])\n\n    ctx = Context(file_backend=path)\n    await ctx.write_system_prompt(\"Prepended prompt\")\n\n    lines = _read_lines(path)\n    assert len(lines) == 2\n    assert lines[0] == {\"role\": \"_system_prompt\", \"content\": \"Prepended prompt\"}\n    assert lines[1] == msg\n\n\n# --- restore tests ---\n\n\n@pytest.mark.asyncio\nasync def test_restore_reads_system_prompt(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(\n        path,\n        [\n            {\"role\": \"_system_prompt\", \"content\": \"Frozen prompt\"},\n            _message_dict(\"user\", \"Hello\"),\n        ],\n    )\n\n    ctx = Context(file_backend=path)\n    restored = await ctx.restore()\n\n    assert restored is True\n    assert ctx.system_prompt == \"Frozen prompt\"\n\n\n@pytest.mark.asyncio\nasync def test_restore_without_system_prompt(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(path, [_message_dict(\"user\", \"Hello\")])\n\n    ctx = Context(file_backend=path)\n    await ctx.restore()\n\n    assert ctx.system_prompt is None\n\n\n@pytest.mark.asyncio\nasync def test_restore_system_prompt_excluded_from_history(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(\n        path,\n        [\n            {\"role\": \"_system_prompt\", \"content\": \"System prompt here\"},\n            _message_dict(\"user\", \"Hello\"),\n            _message_dict(\"assistant\", \"Hi there\"),\n        ],\n    )\n\n    ctx = Context(file_backend=path)\n    await ctx.restore()\n\n    assert ctx.system_prompt == \"System prompt here\"\n    assert len(ctx.history) == 2\n    assert all(msg.role in (\"user\", \"assistant\") for msg in ctx.history)\n\n\n@pytest.mark.asyncio\nasync def test_restore_with_all_record_types(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(\n        path,\n        [\n            {\"role\": \"_system_prompt\", \"content\": \"The system prompt\"},\n            _message_dict(\"user\", \"Hello\"),\n            {\"role\": \"_usage\", \"token_count\": 42},\n            {\"role\": \"_checkpoint\", \"id\": 0},\n            _message_dict(\"assistant\", \"World\"),\n            {\"role\": \"_checkpoint\", \"id\": 1},\n        ],\n    )\n\n    ctx = Context(file_backend=path)\n    await ctx.restore()\n\n    assert ctx.system_prompt == \"The system prompt\"\n    assert ctx.token_count == 42\n    assert ctx.n_checkpoints == 2\n    assert len(ctx.history) == 2\n\n\n# --- clear tests ---\n\n\n@pytest.mark.asyncio\nasync def test_clear_resets_system_prompt(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(\n        path,\n        [\n            {\"role\": \"_system_prompt\", \"content\": \"Will be cleared\"},\n            _message_dict(\"user\", \"Hello\"),\n        ],\n    )\n\n    ctx = Context(file_backend=path)\n    await ctx.restore()\n    assert ctx.system_prompt == \"Will be cleared\"\n\n    await ctx.clear()\n\n    assert ctx.system_prompt is None\n    assert len(ctx.history) == 0\n\n\n# --- revert_to tests ---\n\n\n@pytest.mark.asyncio\nasync def test_revert_preserves_system_prompt(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(\n        path,\n        [\n            {\"role\": \"_system_prompt\", \"content\": \"Preserved prompt\"},\n            _message_dict(\"user\", \"Before checkpoint\"),\n            {\"role\": \"_checkpoint\", \"id\": 0},\n            _message_dict(\"user\", \"After checkpoint\"),\n            {\"role\": \"_checkpoint\", \"id\": 1},\n        ],\n    )\n\n    ctx = Context(file_backend=path)\n    await ctx.restore()\n    assert ctx.system_prompt == \"Preserved prompt\"\n    assert len(ctx.history) == 2\n\n    # revert_to(1) removes checkpoint 1 and everything after it;\n    # both messages (before checkpoint 0 and between 0 and 1) are preserved\n    await ctx.revert_to(1)\n\n    assert ctx.system_prompt == \"Preserved prompt\"\n    assert len(ctx.history) == 2\n\n    # revert_to(0) removes checkpoint 0 and everything after it;\n    # only the first message is preserved\n    await ctx.revert_to(0)\n\n    assert ctx.system_prompt == \"Preserved prompt\"\n    assert len(ctx.history) == 1\n\n\n@pytest.mark.asyncio\nasync def test_revert_preserves_system_prompt_in_file(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(\n        path,\n        [\n            {\"role\": \"_system_prompt\", \"content\": \"File preserved\"},\n            _message_dict(\"user\", \"Message 1\"),\n            {\"role\": \"_checkpoint\", \"id\": 0},\n            _message_dict(\"user\", \"Message 2\"),\n            {\"role\": \"_checkpoint\", \"id\": 1},\n        ],\n    )\n\n    ctx = Context(file_backend=path)\n    await ctx.restore()\n    await ctx.revert_to(1)\n\n    lines = _read_lines(path)\n    assert lines[0] == {\"role\": \"_system_prompt\", \"content\": \"File preserved\"}\n\n\n# --- round-trip tests ---\n\n\n@pytest.mark.asyncio\nasync def test_write_system_prompt_then_restore(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    path.touch()\n\n    ctx1 = Context(file_backend=path)\n    await ctx1.write_system_prompt(\"Round trip prompt\")\n\n    ctx2 = Context(file_backend=path)\n    await ctx2.restore()\n\n    assert ctx2.system_prompt == \"Round trip prompt\"\n\n\n@pytest.mark.asyncio\nasync def test_write_append_messages_then_restore(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    path.touch()\n\n    ctx1 = Context(file_backend=path)\n    await ctx1.write_system_prompt(\"Frozen system prompt\")\n    await ctx1.append_message(Message(role=\"user\", content=[TextPart(text=\"Hello\")]))\n    await ctx1.append_message(Message(role=\"assistant\", content=[TextPart(text=\"Hi\")]))\n\n    ctx2 = Context(file_backend=path)\n    await ctx2.restore()\n\n    assert ctx2.system_prompt == \"Frozen system prompt\"\n    assert len(ctx2.history) == 2\n    assert ctx2.history[0].role == \"user\"\n    assert ctx2.history[1].role == \"assistant\"\n\n\n@pytest.mark.asyncio\nasync def test_prepend_then_restore(tmp_path: Path) -> None:\n    \"\"\"Legacy session migration: prepend system prompt to existing messages, then restore.\"\"\"\n    path = tmp_path / \"context.jsonl\"\n    _write_lines(\n        path,\n        [\n            _message_dict(\"user\", \"Old message 1\"),\n            _message_dict(\"assistant\", \"Old reply 1\"),\n        ],\n    )\n\n    ctx1 = Context(file_backend=path)\n    await ctx1.write_system_prompt(\"Migrated prompt\")\n\n    ctx2 = Context(file_backend=path)\n    await ctx2.restore()\n\n    assert ctx2.system_prompt == \"Migrated prompt\"\n    assert len(ctx2.history) == 2\n\n\n# --- file format verification ---\n\n\n@pytest.mark.asyncio\nasync def test_system_prompt_is_first_line_in_file(tmp_path: Path) -> None:\n    path = tmp_path / \"context.jsonl\"\n    path.touch()\n\n    ctx = Context(file_backend=path)\n    await ctx.write_system_prompt(\"First line prompt\")\n    await ctx.append_message(Message(role=\"user\", content=[TextPart(text=\"Second line\")]))\n    await ctx.checkpoint(add_user_message=False)\n    await ctx.update_token_count(100)\n\n    lines = _read_lines(path)\n    assert lines[0][\"role\"] == \"_system_prompt\"\n    assert lines[0][\"content\"] == \"First line prompt\"\n    assert lines[1][\"role\"] == \"user\"\n    assert lines[2][\"role\"] == \"_checkpoint\"\n    assert lines[3][\"role\"] == \"_usage\"\n"
  },
  {
    "path": "tests/core/test_create_llm.py",
    "content": "from __future__ import annotations\n\nfrom inline_snapshot import snapshot\nfrom kosong.chat_provider.echo import EchoChatProvider\nfrom kosong.chat_provider.kimi import Kimi\nfrom kosong.contrib.chat_provider.openai_responses import OpenAIResponses\nfrom pydantic import SecretStr\n\nfrom kimi_cli.config import LLMModel, LLMProvider\nfrom kimi_cli.llm import augment_provider_with_env_vars, create_llm\n\n\ndef test_augment_provider_with_env_vars_kimi(monkeypatch):\n    provider = LLMProvider(\n        type=\"kimi\",\n        base_url=\"https://original.test/v1\",\n        api_key=SecretStr(\"orig-key\"),\n    )\n    model = LLMModel(\n        provider=\"kimi\",\n        model=\"kimi-base\",\n        max_context_size=4096,\n        capabilities=None,\n    )\n\n    monkeypatch.setenv(\"KIMI_BASE_URL\", \"https://env.test/v1\")\n    monkeypatch.setenv(\"KIMI_API_KEY\", \"env-key\")\n    monkeypatch.setenv(\"KIMI_MODEL_NAME\", \"kimi-env-model\")\n    monkeypatch.setenv(\"KIMI_MODEL_MAX_CONTEXT_SIZE\", \"8192\")\n    monkeypatch.setenv(\"KIMI_MODEL_CAPABILITIES\", \"Image_In,THINKING,unknown\")\n\n    augment_provider_with_env_vars(provider, model)\n\n    assert provider == snapshot(\n        LLMProvider(\n            type=\"kimi\",\n            base_url=\"https://env.test/v1\",\n            api_key=SecretStr(\"env-key\"),\n        )\n    )\n    assert model == snapshot(\n        LLMModel(\n            provider=\"kimi\",\n            model=\"kimi-env-model\",\n            max_context_size=8192,\n            capabilities={\"image_in\", \"thinking\"},\n        )\n    )\n\n\ndef test_create_llm_kimi_model_parameters(monkeypatch):\n    provider = LLMProvider(\n        type=\"kimi\",\n        base_url=\"https://api.test/v1\",\n        api_key=SecretStr(\"test-key\"),\n    )\n    model = LLMModel(\n        provider=\"kimi\",\n        model=\"kimi-base\",\n        max_context_size=4096,\n        capabilities=None,\n    )\n\n    monkeypatch.setenv(\"KIMI_MODEL_TEMPERATURE\", \"0.2\")\n    monkeypatch.setenv(\"KIMI_MODEL_TOP_P\", \"0.8\")\n    monkeypatch.setenv(\"KIMI_MODEL_MAX_TOKENS\", \"1234\")\n\n    llm = create_llm(provider, model)\n    assert llm is not None\n    assert isinstance(llm.chat_provider, Kimi)\n\n    assert llm.chat_provider.model_parameters == snapshot(\n        {\n            \"base_url\": \"https://api.test/v1/\",\n            \"temperature\": 0.2,\n            \"top_p\": 0.8,\n            \"max_tokens\": 1234,\n        }\n    )\n\n\ndef test_create_llm_echo_provider():\n    provider = LLMProvider(type=\"_echo\", base_url=\"\", api_key=SecretStr(\"\"))\n    model = LLMModel(provider=\"_echo\", model=\"echo\", max_context_size=1234)\n\n    llm = create_llm(provider, model)\n    assert llm is not None\n    assert isinstance(llm.chat_provider, EchoChatProvider)\n    assert llm.max_context_size == 1234\n\n\ndef test_create_llm_anthropic_with_session_id():\n    from kosong.contrib.chat_provider.anthropic import Anthropic\n\n    provider = LLMProvider(\n        type=\"anthropic\",\n        base_url=\"https://api.anthropic.com\",\n        api_key=SecretStr(\"test-key\"),\n    )\n    model = LLMModel(\n        provider=\"anthropic\",\n        model=\"claude-sonnet-4-20250514\",\n        max_context_size=200000,\n    )\n\n    llm = create_llm(provider, model, session_id=\"sess-abc-123\")\n    assert llm is not None\n    assert isinstance(llm.chat_provider, Anthropic)\n    assert llm.chat_provider._metadata == snapshot({\"user_id\": \"sess-abc-123\"})\n\n\ndef test_create_llm_anthropic_without_session_id():\n    from kosong.contrib.chat_provider.anthropic import Anthropic\n\n    provider = LLMProvider(\n        type=\"anthropic\",\n        base_url=\"https://api.anthropic.com\",\n        api_key=SecretStr(\"test-key\"),\n    )\n    model = LLMModel(\n        provider=\"anthropic\",\n        model=\"claude-sonnet-4-20250514\",\n        max_context_size=200000,\n    )\n\n    llm = create_llm(provider, model)\n    assert llm is not None\n    assert isinstance(llm.chat_provider, Anthropic)\n    assert llm.chat_provider._metadata is None\n\n\ndef test_create_llm_requires_base_url_for_kimi():\n    provider = LLMProvider(type=\"kimi\", base_url=\"\", api_key=SecretStr(\"test-key\"))\n    model = LLMModel(provider=\"kimi\", model=\"kimi-base\", max_context_size=4096)\n\n    assert create_llm(provider, model) is None\n\n\ndef test_create_llm_openai_responses_thinking_false_no_reasoning_in_params():\n    \"\"\"thinking=False should call with_thinking(\"off\"), which sets reasoning_effort=None.\n    The OpenAIResponses provider handles this by omitting reasoning from the request.\"\"\"\n    provider = LLMProvider(\n        type=\"openai_responses\",\n        base_url=\"https://openrouter.ai/api/v1\",\n        api_key=SecretStr(\"test-key\"),\n    )\n    model = LLMModel(\n        provider=\"openrouter_custom\",\n        model=\"minimax/minimax-m2.5\",\n        max_context_size=128000,\n        capabilities=None,\n    )\n\n    llm = create_llm(provider, model, thinking=False)\n\n    assert llm is not None\n    assert isinstance(llm.chat_provider, OpenAIResponses)\n    # with_thinking(\"off\") sets reasoning_effort=None in generation kwargs,\n    # but generate() will omit reasoning from the actual API request when effort is None.\n    assert llm.chat_provider.model_parameters == snapshot(\n        {\n            \"base_url\": \"https://openrouter.ai/api/v1/\",\n            \"reasoning_effort\": None,\n        }\n    )\n"
  },
  {
    "path": "tests/core/test_default_agent.py",
    "content": "from __future__ import annotations\n\n# ruff: noqa\n\nimport platform\nimport pytest\nfrom inline_snapshot import snapshot\nfrom kosong.tooling import Tool\n\nfrom kimi_cli.agentspec import DEFAULT_AGENT_FILE\nfrom kimi_cli.soul.agent import load_agent\nfrom kimi_cli.soul.agent import Runtime\n\n\n@pytest.mark.skipif(platform.system() == \"Windows\", reason=\"Skipping test on Windows\")\nasync def test_default_agent(runtime: Runtime):\n    agent = await load_agent(DEFAULT_AGENT_FILE, runtime, mcp_configs=[])\n    assert agent.system_prompt.replace(\n        f\"{runtime.builtin_args.KIMI_WORK_DIR}\", \"/path/to/work/dir\"\n    ) == snapshot(\n        \"\"\"\\\nYou are Kimi Code CLI, an interactive general AI agent running on a user's computer.\n\nYour primary goal is to answer questions and/or finish tasks safely and efficiently, adhering strictly to the following system instructions and the user's requirements, leveraging the available tools flexibly.\n\n\n\n# Prompt and Tool Use\n\nThe user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly.\n\nWhen handling the user's request, you may call available tools to accomplish the task. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools.\n\nYou have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance.\n\nThe results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information.\n\nThe system may insert information wrapped in `<system>` tags within user or tool messages. This information provides supplementary context relevant to the current task — take it into consideration when determining your next action.\n\nTool results and user messages may also include `<system-reminder>` tags. Unlike `<system>` tags, these are **authoritative system directives** that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode).\n\nIf the `Shell`, `TaskList`, `TaskOutput`, and `TaskStop` tools are available and you are the root agent, you can use Background Bash for long-running shell commands. Launch it via `Shell` with `run_in_background=true` and a short `description`. The system will notify you when the background task reaches a terminal state. Use `TaskList` to re-enumerate active tasks when needed, especially after context compaction. Use `TaskOutput` to inspect progress or wait for completion, and use `TaskStop` only when you need to cancel the task. For human users in the interactive shell, the only task-management slash command is `/task`. Do not tell users to run `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented slash subcommands. If you are a subagent or these tools are not available, do not assume you can create or control background tasks.\n\nWhen responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise.\n\n# General Guidelines for Coding\n\nWhen building something from scratch, you should:\n\n- Understand the user's requirements.\n- Ask the user for clarification if there is anything unclear.\n- Design the architecture and make a plan for the implementation.\n- Write the code in a modular and maintainable way.\n\nWhen working on an existing codebase, you should:\n\n- Understand the codebase and the user's requirements. Identify the ultimate goal and the most important criteria to achieve the goal.\n- For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes.\n- For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests.\n- For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes.\n- Make MINIMAL changes to achieve the goal. This is very important to your performance.\n- Follow the coding style of existing code in the project.\n\nDO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations.\n\n# General Guidelines for Research and Data Processing\n\nThe user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must:\n\n- Understand the user's requirements thoroughly, ask for clarification before you start if needed.\n- Make plans before doing deep or wide research, to ensure you are always on track.\n- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy.\n- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment.\n- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected.\n- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation.\n\n# Working Environment\n\n## Operating System\n\nThe operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory.\n\n## Date and Time\n\nThe current date and time in ISO format is `1970-01-01T00:00:00+00:00`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Shell tool with proper command.\n\n## Working Directory\n\nThe current working directory is `/path/to/work/dir`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters.\n\nThe directory listing of current working directory is:\n\n```\nTest ls content\n```\n\nUse this as your basic understanding of the project structure.\n\n# Project Information\n\nMarkdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root.\n\n> Why `AGENTS.md`?\n>\n> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors.\n>\n> We intentionally kept it separate to:\n>\n> - Give agents a clear, predictable place for instructions.\n> - Keep `README`s concise and focused on human contributors.\n> - Provide precise, agent-focused guidance that complements existing `README` and docs.\n\nThe project level `/path/to/work/dir/AGENTS.md`:\n\n`````````\nTest agents content\n`````````\n\nIf the above `AGENTS.md` is empty or insufficient, you may check `README`/`README.md` files or `AGENTS.md` files in subdirectories for more information about specific parts of the project.\n\nIf you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.\n\n# Skills\n\nSkills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material.\n\n## What are skills?\n\nSkills are modular extensions that provide:\n\n- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis)\n- Workflow patterns: Best practices for common tasks\n- Tool integrations: Pre-configured tool chains for specific operations\n- Reference material: Documentation, templates, and examples\n\n## Available skills\n\nNo skills found.\n\n## How to use skills\n\nIdentify the skills that are likely to be useful for the tasks you are currently working on, read the `SKILL.md` file for detailed instructions, guidelines, scripts and more.\n\nOnly read skill details when needed to conserve the context window.\n\n# Ultimate Reminders\n\nAt any time, you should be HELPFUL and POLITE, CONCISE and ACCURATE, PATIENT and THOROUGH.\n\n- Never diverge from the requirements and the goals of the task you work on. Stay on track.\n- Never give the user more than what they want.\n- Try your best to avoid any hallucination. Do fact checking before providing any factual information.\n- Think twice before you act.\n- Do not give up too early.\n- ALWAYS, keep it stupidly simple. Do not overcomplicate things.\\\n\"\"\"\n    )\n\n\n@pytest.mark.skipif(platform.system() == \"Windows\", reason=\"Skipping test on Windows\")\nasync def test_default_agent_background_bash_guardrails(runtime: Runtime):\n    agent = await load_agent(DEFAULT_AGENT_FILE, runtime, mcp_configs=[])\n\n    assert \"the only task-management slash command is `/task`\" in agent.system_prompt\n    assert \"Do not tell users to run `/task list`, `/task output`, `/task stop`, `/tasks`\" in (\n        agent.system_prompt\n    )\n    assert agent.toolset.tools == snapshot(\n        [\n            Tool(\n                name=\"Task\",\n                description=\"\"\"\\\nSpawn a subagent to perform a specific task. Subagent will be spawned with a fresh context without any history of yours.\n\n**Context Isolation**\n\nContext isolation is one of the key benefits of using subagents. By delegating tasks to subagents, you can keep your main context clean and focused on the main goal requested by the user.\n\nHere are some scenarios you may want this tool for context isolation:\n\n- You wrote some code and it did not work as expected. In this case you can spawn a subagent to fix the code, asking the subagent to return how it is fixed. This can potentially benefit because the detailed process of fixing the code may not be relevant to your main goal, and may clutter your context.\n- When you need some latest knowledge of a specific library, framework or technology to proceed with your task, you can spawn a subagent to search on the internet for the needed information and return to you the gathered relevant information, for example code examples, API references, etc. This can avoid ton of irrelevant search results in your own context.\n\nDO NOT directly forward the user prompt to Task tool. DO NOT simply spawn Task tool for each todo item. This will cause the user confused because the user cannot see what the subagent do. Only you can see the response from the subagent. So, only spawn subagents for very specific and narrow tasks like fixing a compilation error, or searching for a specific solution.\n\n**Parallel Multi-Tasking**\n\nParallel multi-tasking is another key benefit of this tool. When the user request involves multiple subtasks that are independent of each other, you can use Task tool multiple times in a single response to let subagents work in parallel for you.\n\nExamples:\n\n- User requests to code, refactor or fix multiple modules/files in a project, and they can be tested independently. In this case you can spawn multiple subagents each working on a different module/file.\n- When you need to analyze a huge codebase (> hundreds of thousands of lines), you can spawn multiple subagents each exploring on a different part of the codebase and gather the summarized results.\n- When you need to search the web for multiple queries, you can spawn multiple subagents for better efficiency.\n\n**Available Subagents:**\n\n- `mocker`: The mock agent for testing purposes.\n- `coder`: Good at general software engineering tasks.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"description\": {\n                            \"description\": \"A short (3-5 word) description of the task\",\n                            \"type\": \"string\",\n                        },\n                        \"subagent_name\": {\n                            \"description\": \"The name of the specialized subagent to use for this task\",\n                            \"type\": \"string\",\n                        },\n                        \"prompt\": {\n                            \"description\": \"The task for the subagent to perform. You must provide a detailed prompt with all necessary background information because the subagent cannot see anything in your context.\",\n                            \"type\": \"string\",\n                        },\n                    },\n                    \"required\": [\"description\", \"subagent_name\", \"prompt\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"AskUserQuestion\",\n                description=\"\"\"\\\nUse this tool when you need to ask the user questions with structured options during execution. This allows you to:\n1. Collect user preferences or requirements before proceeding\n2. Resolve ambiguous or underspecified instructions\n3. Let the user decide between implementation approaches as you work\n4. Present concrete options when multiple valid directions exist\n\n**When NOT to use:**\n- When you can infer the answer from context — be decisive and proceed\n- Trivial decisions that don't materially affect the outcome\n\nOverusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action.\n\n**Usage notes:**\n- Users always have an \"Other\" option for custom input — don't create one yourself\n- Use multi_select to allow multiple answers to be selected for a question\n- Keep option labels concise (1-5 words), use descriptions for trade-offs and details\n- Each question should have 2-4 meaningful, distinct options\n- You can ask 1-4 questions at a time; group related questions to minimize interruptions\n- If you recommend a specific option, list it first and append \"(Recommended)\" to its label\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"questions\": {\n                            \"description\": \"The questions to ask the user (1-4 questions).\",\n                            \"items\": {\n                                \"properties\": {\n                                    \"question\": {\n                                        \"description\": \"A specific, actionable question. End with '?'.\",\n                                        \"type\": \"string\",\n                                    },\n                                    \"header\": {\n                                        \"default\": \"\",\n                                        \"description\": \"Short category tag (max 12 chars, e.g. 'Auth', 'Style').\",\n                                        \"type\": \"string\",\n                                    },\n                                    \"options\": {\n                                        \"description\": \"2-4 meaningful, distinct options. Do NOT include an 'Other' option — the system adds one automatically.\",\n                                        \"items\": {\n                                            \"properties\": {\n                                                \"label\": {\n                                                    \"description\": \"Concise display text (1-5 words). If recommended, append '(Recommended)'.\",\n                                                    \"type\": \"string\",\n                                                },\n                                                \"description\": {\n                                                    \"default\": \"\",\n                                                    \"description\": \"Brief explanation of trade-offs or implications of choosing this option.\",\n                                                    \"type\": \"string\",\n                                                },\n                                            },\n                                            \"required\": [\"label\"],\n                                            \"type\": \"object\",\n                                        },\n                                        \"maxItems\": 4,\n                                        \"minItems\": 2,\n                                        \"type\": \"array\",\n                                    },\n                                    \"multi_select\": {\n                                        \"default\": False,\n                                        \"description\": \"Whether the user can select multiple options.\",\n                                        \"type\": \"boolean\",\n                                    },\n                                },\n                                \"required\": [\"question\", \"options\"],\n                                \"type\": \"object\",\n                            },\n                            \"maxItems\": 4,\n                            \"minItems\": 1,\n                            \"type\": \"array\",\n                        }\n                    },\n                    \"required\": [\"questions\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"SetTodoList\",\n                description=\"\"\"\\\nUpdate the whole todo list.\n\nTodo list is a simple yet powerful tool to help you get things done. You typically want to use this tool when the given task involves multiple subtasks/milestones, or, multiple tasks are given in a single request. This tool can help you to break down the task and track the progress.\n\nThis is the only todo list tool available to you. That said, each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly.\n\nOnce you finished a subtask/milestone, remember to update the todo list to reflect the progress. Also, you can give yourself a self-encouragement to keep you motivated.\n\nAbusing this tool to track too small steps will just waste your time and make your context messy. For example, here are some cases you should not use this tool:\n\n- When the user just simply ask you a question. E.g. \"What language and framework is used in the project?\", \"What is the best practice for x?\"\n- When it only takes a few steps/tool calls to complete the task. E.g. \"Fix the unit test function 'test_xxx'\", \"Refactor the function 'xxx' to make it more solid.\"\n- When the user prompt is very specific and the only thing you need to do is brainlessly following the instructions. E.g. \"Replace xxx to yyy in the file zzz\", \"Create a file xxx with content yyy.\"\n\nHowever, do not get stuck in a rut. Be flexible. Sometimes, you may try to use todo list at first, then realize the task is too simple and you can simply stop using it; or, sometimes, you may realize the task is complex after a few steps and then you can start using todo list to break it down.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"todos\": {\n                            \"description\": \"The updated todo list\",\n                            \"items\": {\n                                \"properties\": {\n                                    \"title\": {\n                                        \"description\": \"The title of the todo\",\n                                        \"minLength\": 1,\n                                        \"type\": \"string\",\n                                    },\n                                    \"status\": {\n                                        \"description\": \"The status of the todo\",\n                                        \"enum\": [\"pending\", \"in_progress\", \"done\"],\n                                        \"type\": \"string\",\n                                    },\n                                },\n                                \"required\": [\"title\", \"status\"],\n                                \"type\": \"object\",\n                            },\n                            \"type\": \"array\",\n                        }\n                    },\n                    \"required\": [\"todos\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"Shell\",\n                description=\"\"\"\\\nExecute a bash (`/bin/bash`) command. Use this tool to explore the filesystem, edit files, run scripts, get system information, etc.\n\n**Output:**\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the exit code will be provided in a system tag.\n\nIf `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. You will be automatically notified when the task completes. Use `TaskOutput` if you need progress or want to wait for completion, and use `TaskStop` only if the task must be cancelled. For human users in the interactive shell, background tasks are managed through `/task` only; do not suggest `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented shell subcommands.\n\n**Guidelines for safety and security:**\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls.\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, you shall set `timeout` argument to a reasonable value.\n- Avoid using `..` to access files or directories outside of the working directory.\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\n\n**Guidelines for efficiency:**\n- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la`\n- Use `;` to run commands sequentially regardless of success/failure\n- Use `||` for conditional execution (run second command only if first fails)\n- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands\n- Always quote file paths containing spaces with double quotes (e.g., cd \"/path with spaces/\")\n- Use `if`, `case`, `for`, `while` control flows to execute complex logic in a single call.\n- Verify directory structure before create/edit/delete files or directories to reduce the risk of failure.\n- Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes.\n- After starting a background task, do not guess its outcome. Rely on the automatic completion notification whenever possible. Use `TaskOutput` only when you need to inspect progress or block until completion.\n- If you need to tell a human shell user how to manage background tasks, only mention `/task`. Do not invent `/task list`, `/task output`, `/task stop`, or `/tasks`.\n\n**Commands available:**\n- Shell environment: cd, pwd, export, unset, env\n- File system operations: ls, find, mkdir, rm, cp, mv, touch, chmod, chown\n- File viewing/editing: cat, grep, head, tail, diff, patch\n- Text processing: awk, sed, sort, uniq, wc\n- System information/operations: ps, kill, top, df, free, uname, whoami, id, date\n- Network operations: curl, wget, ping, telnet, ssh\n- Archive operations: tar, zip, unzip\n- Other: Other commands available in the shell environment. Check the existence of a command by running `which <command>` before using it.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"command\": {\n                            \"description\": \"The bash command to execute.\",\n                            \"type\": \"string\",\n                        },\n                        \"timeout\": {\n                            \"default\": 60,\n                            \"description\": \"The timeout in seconds for the command to execute. If the command takes longer than this, it will be killed.\",\n                            \"maximum\": 86400,\n                            \"minimum\": 1,\n                            \"type\": \"integer\",\n                        },\n                        \"run_in_background\": {\n                            \"default\": False,\n                            \"description\": \"Whether to run the command as a background task.\",\n                            \"type\": \"boolean\",\n                        },\n                        \"description\": {\n                            \"default\": \"\",\n                            \"description\": \"A short description for the background task. Required when run_in_background=true.\",\n                            \"type\": \"string\",\n                        },\n                    },\n                    \"required\": [\"command\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"TaskList\",\n                description=\"\"\"\\\nList background tasks from the current session.\n\nUse this when you need to re-enumerate which background tasks still exist, especially after context compaction or when you are no longer confident which task IDs are still active.\n\nGuidelines:\n\n- Prefer the default `active_only=true` unless you specifically need completed or failed tasks.\n- Use `TaskOutput` to inspect one task in detail after you have identified the correct task ID.\n- Do not guess which tasks are still running when you can call this tool directly.\n- This tool is read-only and safe to use in plan mode.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"active_only\": {\n                            \"default\": True,\n                            \"description\": \"Whether to list only non-terminal background tasks.\",\n                            \"type\": \"boolean\",\n                        },\n                        \"limit\": {\n                            \"default\": 20,\n                            \"description\": \"Maximum number of tasks to return.\",\n                            \"maximum\": 100,\n                            \"minimum\": 1,\n                            \"type\": \"integer\",\n                        },\n                    },\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"TaskOutput\",\n                description=\"\"\"\\\nRetrieve output from a running or completed background task.\n\nUse this after `Shell(run_in_background=true)` when you need to inspect progress or explicitly wait for completion.\n\nGuidelines:\n- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives.\n- Use `block=true` to wait for completion or timeout.\n- Use `block=false` for a non-blocking status and output check.\n- This tool returns structured task metadata, a fixed-size output preview, and an `output_path` for the full log.\n- When the preview is truncated, use `ReadFile` with the returned `output_path` to inspect the full log in pages.\n- This tool works with the generic background task system and should remain the primary read path for future task types, not just bash.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"task_id\": {\n                            \"description\": \"The background task ID to inspect.\",\n                            \"type\": \"string\",\n                        },\n                        \"block\": {\n                            \"default\": True,\n                            \"description\": \"Whether to wait for the task to finish before returning.\",\n                            \"type\": \"boolean\",\n                        },\n                        \"timeout\": {\n                            \"default\": 30,\n                            \"description\": \"Maximum number of seconds to wait when block=true.\",\n                            \"maximum\": 3600,\n                            \"minimum\": 0,\n                            \"type\": \"integer\",\n                        },\n                    },\n                    \"required\": [\"task_id\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"TaskStop\",\n                description=\"\"\"\\\nStop a running background task.\n\nUse this only when a background task must be cancelled. For normal task completion, prefer waiting for the automatic notification or using `TaskOutput`.\n\nGuidelines:\n- This is a generic task stop capability, not a bash-specific kill tool.\n- Use it sparingly because stopping a task is destructive and may leave partial side effects.\n- If the task is already complete, this tool will simply return its current state.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"task_id\": {\n                            \"description\": \"The background task ID to stop.\",\n                            \"type\": \"string\",\n                        },\n                        \"reason\": {\n                            \"default\": \"Stopped by TaskStop\",\n                            \"description\": \"Short reason recorded when the task is stopped.\",\n                            \"type\": \"string\",\n                        },\n                    },\n                    \"required\": [\"task_id\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"ReadFile\",\n                description=\"\"\"\\\nRead text content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read text files. To read images or videos, use other appropriate tools. To list directories, use the Glob tool or `ls` command via the Shell tool. To read other file types, use appropriate commands via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- If you want to search for a certain content/pattern, prefer Grep tool over ReadFile.\n- Content will be returned with a line number before each line like `cat -n` format.\n- Use `line_offset` and `n_lines` parameters when you only need to read a part of the file.\n- The maximum number of lines that can be read at once is 1000.\n- Any lines longer than 2000 characters will be truncated, ending with \"...\".\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"path\": {\n                            \"description\": \"The path to the file to read. Absolute paths are required when reading files outside the working directory.\",\n                            \"type\": \"string\",\n                        },\n                        \"line_offset\": {\n                            \"default\": 1,\n                            \"description\": \"The line number to start reading from. By default read from the beginning of the file. Set this when the file is too large to read at once.\",\n                            \"minimum\": 1,\n                            \"type\": \"integer\",\n                        },\n                        \"n_lines\": {\n                            \"default\": 1000,\n                            \"description\": \"The number of lines to read. By default read up to 1000 lines, which is the max allowed value. Set this value when the file is too large to read at once.\",\n                            \"minimum\": 1,\n                            \"type\": \"integer\",\n                        },\n                    },\n                    \"required\": [\"path\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"ReadMediaFile\",\n                description=\"\"\"\\\nRead media content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read image or video files. To read other types of files, use the ReadFile tool. To list directories, use the Glob tool or `ls` command via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- The maximum size that can be read is 100MB. An error will be returned if the file is larger than this limit.\n- The media content will be returned in a form that you can directly view and understand.\n\n**Capabilities**\n- This tool supports image and video files for the current model.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"path\": {\n                            \"description\": \"The path to the file to read. Absolute paths are required when reading files outside the working directory.\",\n                            \"type\": \"string\",\n                        }\n                    },\n                    \"required\": [\"path\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"Glob\",\n                description=\"\"\"\\\nFind files and directories using glob patterns. This tool supports standard glob syntax like `*`, `?`, and `**` for recursive searches.\n\n**When to use:**\n- Find files matching specific patterns (e.g., all Python files: `*.py`)\n- Search for files recursively in subdirectories (e.g., `src/**/*.js`)\n- Locate configuration files (e.g., `*.config.*`, `*.json`)\n- Find test files (e.g., `test_*.py`, `*_test.go`)\n\n**Example patterns:**\n- `*.py` - All Python files in current directory\n- `src/**/*.js` - All JavaScript files in src directory recursively\n- `test_*.py` - Python test files starting with \"test_\"\n- `*.config.{js,ts}` - Config files with .js or .ts extension\n\n**Bad example patterns:**\n- `**`, `**/*.py` - Any pattern starting with '**' will be rejected. Because it would recursively search all directories and subdirectories, which is very likely to yield large result that exceeds your context size. Always use more specific patterns like `src/**/*.py` instead.\n- `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursively searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"pattern\": {\n                            \"description\": \"Glob pattern to match files/directories.\",\n                            \"type\": \"string\",\n                        },\n                        \"directory\": {\n                            \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n                            \"default\": None,\n                            \"description\": \"Absolute path to the directory to search in (defaults to working directory).\",\n                        },\n                        \"include_dirs\": {\n                            \"default\": True,\n                            \"description\": \"Whether to include directories in results.\",\n                            \"type\": \"boolean\",\n                        },\n                    },\n                    \"required\": [\"pattern\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"Grep\",\n                description=\"\"\"\\\nA powerful search tool based-on ripgrep.\n\n**Tips:**\n- ALWAYS use Grep tool instead of running `grep` or `rg` command with Shell tool.\n- Use the ripgrep pattern syntax, not grep syntax. E.g. you need to escape braces like `\\\\\\\\{` to search for `{`.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"pattern\": {\n                            \"description\": \"The regular expression pattern to search for in file contents\",\n                            \"type\": \"string\",\n                        },\n                        \"path\": {\n                            \"default\": \".\",\n                            \"description\": \"File or directory to search in. Defaults to current working directory. If specified, it must be an absolute path.\",\n                            \"type\": \"string\",\n                        },\n                        \"glob\": {\n                            \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n                            \"default\": None,\n                            \"description\": \"Glob pattern to filter files (e.g. `*.js`, `*.{ts,tsx}`). No filter by default.\",\n                        },\n                        \"output_mode\": {\n                            \"default\": \"files_with_matches\",\n                            \"description\": \"`content`: Show matching lines (supports `-B`, `-A`, `-C`, `-n`, `head_limit`); `files_with_matches`: Show file paths (supports `head_limit`); `count_matches`: Show total number of matches. Defaults to `files_with_matches`.\",\n                            \"type\": \"string\",\n                        },\n                        \"-B\": {\n                            \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                            \"default\": None,\n                            \"description\": \"Number of lines to show before each match (the `-B` option). Requires `output_mode` to be `content`.\",\n                        },\n                        \"-A\": {\n                            \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                            \"default\": None,\n                            \"description\": \"Number of lines to show after each match (the `-A` option). Requires `output_mode` to be `content`.\",\n                        },\n                        \"-C\": {\n                            \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                            \"default\": None,\n                            \"description\": \"Number of lines to show before and after each match (the `-C` option). Requires `output_mode` to be `content`.\",\n                        },\n                        \"-n\": {\n                            \"default\": False,\n                            \"description\": \"Show line numbers in output (the `-n` option). Requires `output_mode` to be `content`.\",\n                            \"type\": \"boolean\",\n                        },\n                        \"-i\": {\n                            \"default\": False,\n                            \"description\": \"Case insensitive search (the `-i` option).\",\n                            \"type\": \"boolean\",\n                        },\n                        \"type\": {\n                            \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n                            \"default\": None,\n                            \"description\": \"File type to search. Examples: py, rust, js, ts, go, java, etc. More efficient than `glob` for standard file types.\",\n                        },\n                        \"head_limit\": {\n                            \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                            \"default\": None,\n                            \"description\": \"Limit output to first N lines, equivalent to `| head -N`. Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count_matches (limits count entries). By default, no limit is applied.\",\n                        },\n                        \"multiline\": {\n                            \"default\": False,\n                            \"description\": \"Enable multiline mode where `.` matches newlines and patterns can span lines (the `-U` and `--multiline-dotall` options). By default, multiline mode is disabled.\",\n                            \"type\": \"boolean\",\n                        },\n                    },\n                    \"required\": [\"pattern\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"WriteFile\",\n                description=\"\"\"\\\nWrite content to a file.\n\n**Tips:**\n- When `mode` is not specified, it defaults to `overwrite`. Always write with caution.\n- When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use `overwrite` mode at the first time, then use `append` mode after the first write.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"path\": {\n                            \"description\": \"The path to the file to write. Absolute paths are required when writing files outside the working directory.\",\n                            \"type\": \"string\",\n                        },\n                        \"content\": {\n                            \"description\": \"The content to write to the file\",\n                            \"type\": \"string\",\n                        },\n                        \"mode\": {\n                            \"default\": \"overwrite\",\n                            \"description\": \"The mode to use to write to the file. Two modes are supported: `overwrite` for overwriting the whole file and `append` for appending to the end of an existing file.\",\n                            \"enum\": [\"overwrite\", \"append\"],\n                            \"type\": \"string\",\n                        },\n                    },\n                    \"required\": [\"path\", \"content\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"StrReplaceFile\",\n                description=\"\"\"\\\nReplace specific strings within a specified file.\n\n**Tips:**\n- Only use this tool on text files.\n- Multi-line strings are supported.\n- Can specify a single edit or a list of edits in one call.\n- You should prefer this tool over WriteFile tool and Shell `sed` command.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"path\": {\n                            \"description\": \"The path to the file to edit. Absolute paths are required when editing files outside the working directory.\",\n                            \"type\": \"string\",\n                        },\n                        \"edit\": {\n                            \"anyOf\": [\n                                {\n                                    \"properties\": {\n                                        \"old\": {\n                                            \"description\": \"The old string to replace. Can be multi-line.\",\n                                            \"type\": \"string\",\n                                        },\n                                        \"new\": {\n                                            \"description\": \"The new string to replace with. Can be multi-line.\",\n                                            \"type\": \"string\",\n                                        },\n                                        \"replace_all\": {\n                                            \"default\": False,\n                                            \"description\": \"Whether to replace all occurrences.\",\n                                            \"type\": \"boolean\",\n                                        },\n                                    },\n                                    \"required\": [\"old\", \"new\"],\n                                    \"type\": \"object\",\n                                },\n                                {\n                                    \"items\": {\n                                        \"properties\": {\n                                            \"old\": {\n                                                \"description\": \"The old string to replace. Can be multi-line.\",\n                                                \"type\": \"string\",\n                                            },\n                                            \"new\": {\n                                                \"description\": \"The new string to replace with. Can be multi-line.\",\n                                                \"type\": \"string\",\n                                            },\n                                            \"replace_all\": {\n                                                \"default\": False,\n                                                \"description\": \"Whether to replace all occurrences.\",\n                                                \"type\": \"boolean\",\n                                            },\n                                        },\n                                        \"required\": [\"old\", \"new\"],\n                                        \"type\": \"object\",\n                                    },\n                                    \"type\": \"array\",\n                                },\n                            ],\n                            \"description\": \"The edit(s) to apply to the file. You can provide a single edit or a list of edits here.\",\n                        },\n                    },\n                    \"required\": [\"path\", \"edit\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"SearchWeb\",\n                description=\"WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc.\\n\",\n                parameters={\n                    \"properties\": {\n                        \"query\": {\n                            \"description\": \"The query text to search for.\",\n                            \"type\": \"string\",\n                        },\n                        \"limit\": {\n                            \"default\": 5,\n                            \"description\": \"The number of results to return. Typically you do not need to set this value. When the results do not contain what you need, you probably want to give a more concrete query.\",\n                            \"maximum\": 20,\n                            \"minimum\": 1,\n                            \"type\": \"integer\",\n                        },\n                        \"include_content\": {\n                            \"default\": False,\n                            \"description\": \"Whether to include the content of the web pages in the results. It can consume a large amount of tokens when this is set to True. You should avoid enabling this when `limit` is set to a large value.\",\n                            \"type\": \"boolean\",\n                        },\n                    },\n                    \"required\": [\"query\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"FetchURL\",\n                description=\"Fetch a web page from a URL and extract main text content from it.\\n\",\n                parameters={\n                    \"properties\": {\n                        \"url\": {\n                            \"description\": \"The URL to fetch content from.\",\n                            \"type\": \"string\",\n                        }\n                    },\n                    \"required\": [\"url\"],\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"ExitPlanMode\",\n                description=\"\"\"\\\nUse this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval.\n\n## How This Tool Works\n- You should have already written your plan to the plan file specified in the plan mode reminder.\n- This tool does NOT take the plan content as a parameter — it reads the plan from the file you wrote.\n- The user will see the contents of your plan file when they review it.\n\n## When to Use\nOnly use this tool for tasks that require planning implementation steps. For research tasks (searching files, reading code, understanding the codebase), do NOT use this tool.\n\n## Multiple Approaches\nIf your plan contains multiple alternative approaches:\n- Pass them via the `options` parameter so the user can choose which approach to execute.\n- Each option should have a concise label and a brief description of trade-offs.\n- If you recommend one option, append \"(Recommended)\" to its label.\n- The user will see all options alongside Reject and Revise choices.\n- Provide 2-3 options at most (the system appends a \"Reject\" option automatically, so the total shown to the user is 3-4).\n- Do NOT use \"Reject\", \"Revise\", or \"Approve\" as option labels — these are reserved by the system.\n\n## Before Using\n- If you have unresolved questions, use AskUserQuestion first.\n- If you have multiple approaches and haven't narrowed down yet, consider using AskUserQuestion first to let the user choose, then write a plan for the chosen approach only.\n- Once your plan is finalized, use THIS tool to request approval.\n- Do NOT use AskUserQuestion to ask \"Is this plan OK?\" or \"Should I proceed?\" — that is exactly what ExitPlanMode does.\n- If rejected, revise based on feedback and call ExitPlanMode again.\n\"\"\",\n                parameters={\n                    \"properties\": {\n                        \"options\": {\n                            \"anyOf\": [\n                                {\n                                    \"items\": {\n                                        \"description\": \"A selectable approach/option within the plan.\",\n                                        \"properties\": {\n                                            \"label\": {\n                                                \"description\": \"Short name for this option (1-8 words). Append '(Recommended)' if you recommend this option.\",\n                                                \"type\": \"string\",\n                                            },\n                                            \"description\": {\n                                                \"default\": \"\",\n                                                \"description\": \"Brief summary of this approach and its trade-offs.\",\n                                                \"type\": \"string\",\n                                            },\n                                        },\n                                        \"required\": [\"label\"],\n                                        \"type\": \"object\",\n                                    },\n                                    \"maxItems\": 3,\n                                    \"type\": \"array\",\n                                },\n                                {\"type\": \"null\"},\n                            ],\n                            \"default\": None,\n                            \"description\": \"When the plan contains multiple alternative approaches, list them here so the user can choose which one to execute. 2-3 options. Each option represents a distinct approach from the plan. Do not use 'Reject', 'Revise', or 'Approve' as labels.\",\n                        }\n                    },\n                    \"type\": \"object\",\n                },\n            ),\n            Tool(\n                name=\"EnterPlanMode\",\n                description=\"\"\"\\\nUse this tool proactively when you're about to start a non-trivial implementation task.\nGetting user sign-off on your approach before writing code prevents wasted effort.\n\nUse it when ANY of these conditions apply:\n\n1. New Feature Implementation — e.g. \"Add a caching layer to the API\"\n2. Multiple Valid Approaches — e.g. \"Optimize database queries\" (indexing vs rewrite vs caching)\n3. Code Modifications — e.g. \"Refactor auth module to support OAuth\"\n4. Architectural Decisions — e.g. \"Add WebSocket support\"\n5. Multi-File Changes — involves more than 2-3 files\n6. Unclear Requirements — need exploration to understand scope\n7. User Preferences Matter — if you'd use AskUserQuestion to clarify approach, use EnterPlanMode instead\n\nYolo mode note:\n- Yolo mode users chose continuous execution.\n- In yolo mode, use EnterPlanMode only when the user explicitly asks for planning or when\n  there is exceptional architectural ambiguity that requires user input before proceeding.\n\nWhen NOT to use:\n- Single-line or few-line fixes (typos, obvious bugs, small tweaks)\n- User gave very specific, detailed instructions\n- Pure research/exploration tasks\n\n## What Happens in Plan Mode\nIn plan mode, you will:\n1. Explore the codebase using Glob, Grep, ReadFile (read-only)\n2. Design an implementation approach\n3. Write your plan to a plan file\n4. Present your plan to the user via ExitPlanMode for approval\n\"\"\",\n                parameters={\n                    \"properties\": {},\n                    \"type\": \"object\",\n                },\n            ),\n        ]\n    )\n\n    subagents = [\n        (\n            name,\n            runtime.labor_market.fixed_subagent_descs[name],\n            agent.system_prompt.replace(\n                f\"{runtime.builtin_args.KIMI_WORK_DIR}\", \"/path/to/work/dir\"\n            ),\n            [tool.name for tool in agent.toolset.tools],\n        )\n        for name, agent in runtime.labor_market.fixed_subagents.items()\n    ]\n    assert subagents == snapshot(\n        [\n            (\n                \"mocker\",\n                \"The mock agent for testing purposes.\",\n                \"You are a mock agent for testing.\",\n                [],\n            ),\n            (\n                \"coder\",\n                \"Good at general software engineering tasks.\",\n                \"\"\"\\\nYou are Kimi Code CLI, an interactive general AI agent running on a user's computer.\n\nYour primary goal is to answer questions and/or finish tasks safely and efficiently, adhering strictly to the following system instructions and the user's requirements, leveraging the available tools flexibly.\n\nYou are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You need to provide a comprehensive summary on what you have done and learned in your final message. If you wrote or modified any files, you must mention them in the summary.\n\n\n# Prompt and Tool Use\n\nThe user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly.\n\nWhen handling the user's request, you may call available tools to accomplish the task. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools.\n\nYou have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance.\n\nThe results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information.\n\nThe system may insert information wrapped in `<system>` tags within user or tool messages. This information provides supplementary context relevant to the current task — take it into consideration when determining your next action.\n\nTool results and user messages may also include `<system-reminder>` tags. Unlike `<system>` tags, these are **authoritative system directives** that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode).\n\nIf the `Shell`, `TaskList`, `TaskOutput`, and `TaskStop` tools are available and you are the root agent, you can use Background Bash for long-running shell commands. Launch it via `Shell` with `run_in_background=true` and a short `description`. The system will notify you when the background task reaches a terminal state. Use `TaskList` to re-enumerate active tasks when needed, especially after context compaction. Use `TaskOutput` to inspect progress or wait for completion, and use `TaskStop` only when you need to cancel the task. For human users in the interactive shell, the only task-management slash command is `/task`. Do not tell users to run `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented slash subcommands. If you are a subagent or these tools are not available, do not assume you can create or control background tasks.\n\nWhen responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise.\n\n# General Guidelines for Coding\n\nWhen building something from scratch, you should:\n\n- Understand the user's requirements.\n- Ask the user for clarification if there is anything unclear.\n- Design the architecture and make a plan for the implementation.\n- Write the code in a modular and maintainable way.\n\nWhen working on an existing codebase, you should:\n\n- Understand the codebase and the user's requirements. Identify the ultimate goal and the most important criteria to achieve the goal.\n- For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes.\n- For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests.\n- For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes.\n- Make MINIMAL changes to achieve the goal. This is very important to your performance.\n- Follow the coding style of existing code in the project.\n\nDO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations.\n\n# General Guidelines for Research and Data Processing\n\nThe user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must:\n\n- Understand the user's requirements thoroughly, ask for clarification before you start if needed.\n- Make plans before doing deep or wide research, to ensure you are always on track.\n- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy.\n- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment.\n- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected.\n- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation.\n\n# Working Environment\n\n## Operating System\n\nThe operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory.\n\n## Date and Time\n\nThe current date and time in ISO format is `1970-01-01T00:00:00+00:00`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Shell tool with proper command.\n\n## Working Directory\n\nThe current working directory is `/path/to/work/dir`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters.\n\nThe directory listing of current working directory is:\n\n```\nTest ls content\n```\n\nUse this as your basic understanding of the project structure.\n\n# Project Information\n\nMarkdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root.\n\n> Why `AGENTS.md`?\n>\n> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors.\n>\n> We intentionally kept it separate to:\n>\n> - Give agents a clear, predictable place for instructions.\n> - Keep `README`s concise and focused on human contributors.\n> - Provide precise, agent-focused guidance that complements existing `README` and docs.\n\nThe project level `/path/to/work/dir/AGENTS.md`:\n\n`````````\nTest agents content\n`````````\n\nIf the above `AGENTS.md` is empty or insufficient, you may check `README`/`README.md` files or `AGENTS.md` files in subdirectories for more information about specific parts of the project.\n\nIf you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.\n\n# Skills\n\nSkills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material.\n\n## What are skills?\n\nSkills are modular extensions that provide:\n\n- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis)\n- Workflow patterns: Best practices for common tasks\n- Tool integrations: Pre-configured tool chains for specific operations\n- Reference material: Documentation, templates, and examples\n\n## Available skills\n\nNo skills found.\n\n## How to use skills\n\nIdentify the skills that are likely to be useful for the tasks you are currently working on, read the `SKILL.md` file for detailed instructions, guidelines, scripts and more.\n\nOnly read skill details when needed to conserve the context window.\n\n# Ultimate Reminders\n\nAt any time, you should be HELPFUL and POLITE, CONCISE and ACCURATE, PATIENT and THOROUGH.\n\n- Never diverge from the requirements and the goals of the task you work on. Stay on track.\n- Never give the user more than what they want.\n- Try your best to avoid any hallucination. Do fact checking before providing any factual information.\n- Think twice before you act.\n- Do not give up too early.\n- ALWAYS, keep it stupidly simple. Do not overcomplicate things.\\\n\"\"\",\n                [\n                    \"AskUserQuestion\",\n                    \"Shell\",\n                    \"TaskList\",\n                    \"TaskOutput\",\n                    \"TaskStop\",\n                    \"ReadFile\",\n                    \"ReadMediaFile\",\n                    \"Glob\",\n                    \"Grep\",\n                    \"WriteFile\",\n                    \"StrReplaceFile\",\n                    \"SearchWeb\",\n                    \"FetchURL\",\n                ],\n            ),\n        ]\n    )\n"
  },
  {
    "path": "tests/core/test_exceptions.py",
    "content": "from inline_snapshot import snapshot\n\nfrom kimi_cli.llm import LLM\n\n\ndef test_soul_exceptions(llm: LLM):\n    from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached\n\n    try:\n        raise LLMNotSet()\n    except LLMNotSet as e:\n        assert str(e) == snapshot(\"LLM not set\")\n\n    try:\n        raise LLMNotSupported(llm, [\"image_in\"])\n    except LLMNotSupported as e:\n        assert str(e) == snapshot(\n            \"LLM model 'mock' does not support required capability: image_in.\"\n        )\n\n    try:\n        raise MaxStepsReached(10)\n    except MaxStepsReached as e:\n        assert str(e) == snapshot(\"Max number of steps reached: 10\")\n"
  },
  {
    "path": "tests/core/test_inspect_plan_edit_target.py",
    "content": "\"\"\"Unit tests for inspect_plan_edit_target.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import ToolError\n\nfrom kimi_cli.tools.file.plan_mode import PlanEditTarget, inspect_plan_edit_target\n\n\nclass TestInspectPlanEditTarget:\n    def test_plan_mode_inactive_when_checker_is_none(self, tmp_path: Path) -> None:\n        target = tmp_path / \"file.txt\"\n        result = inspect_plan_edit_target(\n            KaosPath(str(target)).canonical(),\n            plan_mode_checker=None,\n            plan_file_path_getter=None,\n        )\n        assert isinstance(result, PlanEditTarget)\n        assert not result.active\n        assert not result.is_plan_target\n\n    def test_plan_mode_inactive_when_checker_returns_false(self, tmp_path: Path) -> None:\n        target = tmp_path / \"file.txt\"\n        result = inspect_plan_edit_target(\n            KaosPath(str(target)).canonical(),\n            plan_mode_checker=lambda: False,\n            plan_file_path_getter=lambda: tmp_path / \"plan.md\",\n        )\n        assert isinstance(result, PlanEditTarget)\n        assert not result.active\n        assert not result.is_plan_target\n\n    def test_plan_path_unavailable(self, tmp_path: Path) -> None:\n        target = tmp_path / \"file.txt\"\n        result = inspect_plan_edit_target(\n            KaosPath(str(target)).canonical(),\n            plan_mode_checker=lambda: True,\n            plan_file_path_getter=lambda: None,\n        )\n        assert isinstance(result, ToolError)\n        assert \"unavailable\" in result.message\n\n    def test_path_matches_plan_file(self, tmp_path: Path) -> None:\n        plan_path = tmp_path / \"plan.md\"\n        result = inspect_plan_edit_target(\n            KaosPath(str(plan_path)).canonical(),\n            plan_mode_checker=lambda: True,\n            plan_file_path_getter=lambda: plan_path,\n        )\n        assert isinstance(result, PlanEditTarget)\n        assert result.active\n        assert result.is_plan_target\n        assert result.plan_path == plan_path\n\n    def test_path_does_not_match_plan_file(self, tmp_path: Path) -> None:\n        plan_path = tmp_path / \"plan.md\"\n        other_path = tmp_path / \"other.txt\"\n        result = inspect_plan_edit_target(\n            KaosPath(str(other_path)).canonical(),\n            plan_mode_checker=lambda: True,\n            plan_file_path_getter=lambda: plan_path,\n        )\n        assert isinstance(result, ToolError)\n        assert \"only edit the current plan file\" in result.message\n"
  },
  {
    "path": "tests/core/test_kimisoul_ralph_loop.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom collections.abc import AsyncIterator, Sequence\nfrom pathlib import Path\nfrom typing import Self, TypeVar\n\nimport pytest\nfrom inline_snapshot import Snapshot, snapshot\nfrom kosong.chat_provider import StreamedMessagePart, ThinkingEffort, TokenUsage\nfrom kosong.message import ContentPart, ImageURLPart, Message, TextPart, ToolCall\nfrom kosong.tooling import CallableTool2, Tool, ToolResult, ToolReturnValue, Toolset\nfrom kosong.tooling.simple import SimpleToolset\nfrom pydantic import BaseModel\n\nfrom kimi_cli.llm import LLM, ModelCapability\nfrom kimi_cli.soul import run_soul\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.tools.utils import ToolRejectedError\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.types import TurnBegin\n\nT = TypeVar(\"T\")\nRALPH_IMAGE_URL = \"https://example.com/test.png\"\nRALPH_IMAGE_USER_INPUT = [\n    TextPart(text=\"Check this image\"),\n    ImageURLPart(image_url=ImageURLPart.ImageURL(url=RALPH_IMAGE_URL)),\n]\n\n\ndef expect_snapshot(value: T, expected: Snapshot[T]) -> None:\n    if expected != value:\n        pytest.fail(f\"Snapshot mismatch: {value!r} != {expected!r}\")\n\n\nclass SequenceStreamedMessage:\n    def __init__(self, parts: Sequence[StreamedMessagePart]) -> None:\n        self._iter = self._to_stream(list(parts))\n\n    def __aiter__(self) -> Self:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    async def _to_stream(\n        self, parts: list[StreamedMessagePart]\n    ) -> AsyncIterator[StreamedMessagePart]:\n        for part in parts:\n            yield part\n\n    @property\n    def id(self) -> str | None:\n        return \"sequence\"\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        return None\n\n\nclass SequenceChatProvider:\n    name = \"sequence\"\n\n    def __init__(self, sequences: Sequence[Sequence[StreamedMessagePart]]) -> None:\n        self._sequences = [list(sequence) for sequence in sequences]\n        self._index = 0\n\n    @property\n    def model_name(self) -> str:\n        return \"sequence\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> SequenceStreamedMessage:\n        index = min(self._index, len(self._sequences) - 1)\n        self._index += 1\n        return SequenceStreamedMessage(self._sequences[index])\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        return self\n\n\ndef _make_llm(\n    sequences: Sequence[Sequence[StreamedMessagePart]],\n    capabilities: set[ModelCapability],\n) -> LLM:\n    return LLM(\n        chat_provider=SequenceChatProvider(sequences),\n        max_context_size=100_000,\n        capabilities=capabilities,\n    )\n\n\ndef _runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime:\n    return Runtime(\n        config=runtime.config,\n        llm=llm,\n        session=runtime.session,\n        builtin_args=runtime.builtin_args,\n        denwa_renji=runtime.denwa_renji,\n        approval=runtime.approval,\n        labor_market=runtime.labor_market,\n        environment=runtime.environment,\n        notifications=runtime.notifications,\n        background_tasks=runtime.background_tasks,\n        skills=runtime.skills,\n        oauth=runtime.oauth,\n        additional_dirs=runtime.additional_dirs,\n        role=runtime.role,\n    )\n\n\ndef _make_soul(\n    runtime: Runtime, llm: LLM, toolset: Toolset, tmp_path: Path\n) -> tuple[KimiSoul, Context]:\n    agent = Agent(\n        name=\"Test Agent\",\n        system_prompt=\"Test system prompt.\",\n        toolset=toolset,\n        runtime=_runtime_with_llm(runtime, llm),\n    )\n    context = Context(file_backend=tmp_path / \"history.jsonl\")\n    return KimiSoul(agent, context=context), context\n\n\nasync def _run_and_collect_turns(\n    soul: KimiSoul, user_input: str | list[ContentPart]\n) -> list[str | list[ContentPart]]:\n    turns: list[str | list[ContentPart]] = []\n\n    async def _ui_loop_fn(wire: Wire) -> None:\n        wire_ui = wire.ui_side(merge=True)\n        while True:\n            try:\n                msg = await wire_ui.receive()\n            except QueueShutDown:\n                return\n            if isinstance(msg, TurnBegin):\n                turns.append(msg.user_input)\n\n    await run_soul(soul, user_input, _ui_loop_fn, asyncio.Event())\n    return turns\n\n\nclass RejectParams(BaseModel):\n    pass\n\n\nclass RejectTool(CallableTool2[RejectParams]):\n    name = \"reject_tool\"\n    description = \"Always reject tool calls.\"\n    params = RejectParams\n\n    async def __call__(self, params: RejectParams) -> ToolReturnValue:\n        return ToolRejectedError()\n\n\nclass RejectToolset:\n    def __init__(self) -> None:\n        self._tool = RejectTool()\n\n    @property\n    def tools(self) -> list[Tool]:\n        return [self._tool.base]\n\n    def handle(self, tool_call: ToolCall) -> ToolResult:\n        return ToolResult(tool_call_id=tool_call.id, return_value=ToolRejectedError())\n\n\n@pytest.mark.asyncio\nasync def test_ralph_loop_replays_original_prompt(runtime: Runtime, tmp_path: Path) -> None:\n    runtime.config.loop_control.max_ralph_iterations = 2\n\n    user_input = RALPH_IMAGE_USER_INPUT\n    llm = _make_llm(\n        [\n            [TextPart(text=\"first\")],\n            [TextPart(text=\"second <choice>CONTINUE</choice>\")],\n            [TextPart(text=\"third <choice>STOP</choice>\")],\n        ],\n        {\"image_in\"},\n    )\n\n    toolset = SimpleToolset()\n    soul, context = _make_soul(runtime, llm, toolset, tmp_path)\n\n    await _run_and_collect_turns(soul, user_input)\n    expect_snapshot(\n        context.history,\n        snapshot(\n            [\n                Message(\n                    role=\"user\",\n                    content=[\n                        TextPart(text=\"Check this image\"),\n                        ImageURLPart(\n                            image_url=ImageURLPart.ImageURL(url=\"https://example.com/test.png\")\n                        ),\n                    ],\n                ),\n                Message(role=\"assistant\", content=[TextPart(text=\"first\")]),\n                Message(\n                    role=\"user\",\n                    content=[\n                        TextPart(\n                            text=\"\"\"\\\nCheck this image. (You are running in an automated loop where the same prompt is fed repeatedly. Only choose STOP when the task is fully complete. Including it will stop further iterations. If you are not 100% sure, choose CONTINUE.)\n\nAvailable branches:\n- CONTINUE\n- STOP\n\nReply with a choice using <choice>...</choice>.\\\n\"\"\"  # noqa: E501\n                        ),\n                    ],\n                ),\n                Message(\n                    role=\"assistant\", content=[TextPart(text=\"second <choice>CONTINUE</choice>\")]\n                ),\n                Message(\n                    role=\"user\",\n                    content=[\n                        TextPart(\n                            text=\"\"\"\\\nCheck this image. (You are running in an automated loop where the same prompt is fed repeatedly. Only choose STOP when the task is fully complete. Including it will stop further iterations. If you are not 100% sure, choose CONTINUE.)\n\nAvailable branches:\n- CONTINUE\n- STOP\n\nReply with a choice using <choice>...</choice>.\\\n\"\"\"  # noqa: E501\n                        ),\n                    ],\n                ),\n                Message(role=\"assistant\", content=[TextPart(text=\"third <choice>STOP</choice>\")]),\n            ]\n        ),\n    )\n\n\n@pytest.mark.asyncio\nasync def test_ralph_loop_stops_on_choice(runtime: Runtime, tmp_path: Path) -> None:\n    runtime.config.loop_control.max_ralph_iterations = -1\n\n    llm = _make_llm(\n        [\n            [TextPart(text=\"first\")],\n            [TextPart(text=\"done <choice>STOP</choice>\")],\n        ],\n        set(),\n    )\n\n    toolset = SimpleToolset()\n    soul, context = _make_soul(runtime, llm, toolset, tmp_path)\n\n    await _run_and_collect_turns(soul, \"do it\")\n    expect_snapshot(\n        context.history,\n        snapshot(\n            [\n                Message(\n                    role=\"user\",\n                    content=[\n                        TextPart(text=\"do it\"),\n                    ],\n                ),\n                Message(role=\"assistant\", content=[TextPart(text=\"first\")]),\n                Message(\n                    role=\"user\",\n                    content=[\n                        TextPart(\n                            text=\"\"\"\\\ndo it. (You are running in an automated loop where the same prompt is fed repeatedly. Only choose STOP when the task is fully complete. Including it will stop further iterations. If you are not 100% sure, choose CONTINUE.)\n\nAvailable branches:\n- CONTINUE\n- STOP\n\nReply with a choice using <choice>...</choice>.\\\n\"\"\"  # noqa: E501\n                        ),\n                    ],\n                ),\n                Message(role=\"assistant\", content=[TextPart(text=\"done <choice>STOP</choice>\")]),\n            ]\n        ),\n    )\n\n\n@pytest.mark.asyncio\nasync def test_ralph_loop_stops_on_tool_rejected(runtime: Runtime, tmp_path: Path) -> None:\n    runtime.config.loop_control.max_ralph_iterations = 3\n\n    llm = _make_llm(\n        [\n            [\n                ToolCall(\n                    id=\"call-1\",\n                    function=ToolCall.FunctionBody(name=\"reject_tool\", arguments=\"{}\"),\n                )\n            ],\n        ],\n        set(),\n    )\n\n    toolset = RejectToolset()\n    soul, context = _make_soul(runtime, llm, toolset, tmp_path)\n\n    await _run_and_collect_turns(soul, \"do it\")\n    expect_snapshot(\n        context.history,\n        snapshot(\n            [\n                Message(\n                    role=\"user\",\n                    content=[\n                        TextPart(text=\"do it\"),\n                    ],\n                ),\n                Message(\n                    role=\"assistant\",\n                    content=[],\n                    tool_calls=[\n                        ToolCall(\n                            id=\"call-1\",\n                            function=ToolCall.FunctionBody(name=\"reject_tool\", arguments=\"{}\"),\n                        )\n                    ],\n                ),\n                Message(\n                    role=\"tool\",\n                    content=[\n                        TextPart(\n                            text=(\n                                \"<system>ERROR: The tool call is rejected by the user. \"\n                                \"Please follow the new instructions from the user.</system>\"\n                            )\n                        )\n                    ],\n                    tool_call_id=\"call-1\",\n                ),\n            ]\n        ),\n    )\n\n\n@pytest.mark.asyncio\nasync def test_ralph_loop_disabled_skips_loop_prompt(runtime: Runtime, tmp_path: Path) -> None:\n    runtime.config.loop_control.max_ralph_iterations = 0\n\n    llm = _make_llm([[TextPart(text=\"done\")]], set())\n\n    toolset = SimpleToolset()\n    soul, context = _make_soul(runtime, llm, toolset, tmp_path)\n\n    await _run_and_collect_turns(soul, \"hello\")\n    expect_snapshot(\n        context.history,\n        snapshot(\n            [\n                Message(role=\"user\", content=[TextPart(text=\"hello\")]),\n                Message(role=\"assistant\", content=[TextPart(text=\"done\")]),\n            ]\n        ),\n    )\n"
  },
  {
    "path": "tests/core/test_kimisoul_retry_recovery.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom collections.abc import AsyncIterator, Sequence\nfrom pathlib import Path\nfrom typing import Self\n\nimport pytest\nfrom kosong.chat_provider import (\n    APIConnectionError,\n    APIStatusError,\n    StreamedMessagePart,\n    ThinkingEffort,\n    TokenUsage,\n)\nfrom kosong.message import Message, TextPart\nfrom kosong.tooling import Tool\nfrom kosong.tooling.simple import SimpleToolset\n\nfrom kimi_cli.llm import LLM\nfrom kimi_cli.soul import run_soul\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.wire import Wire\n\n\nclass StaticStreamedMessage:\n    def __init__(self, parts: Sequence[StreamedMessagePart]) -> None:\n        self._iter = self._to_stream(parts)\n\n    def __aiter__(self) -> Self:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        return await self._iter.__anext__()\n\n    async def _to_stream(\n        self, parts: Sequence[StreamedMessagePart]\n    ) -> AsyncIterator[StreamedMessagePart]:\n        for part in parts:\n            yield part\n\n    @property\n    def id(self) -> str | None:\n        return \"recovering\"\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        return None\n\n\nclass RecoveringSequenceProvider:\n    name = \"recovering-sequence\"\n\n    def __init__(self) -> None:\n        self.generate_attempts = 0\n        self.recovery_calls = 0\n\n    @property\n    def model_name(self) -> str:\n        return \"recovering-sequence\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> StaticStreamedMessage:\n        self.generate_attempts += 1\n        if self.generate_attempts == 1:\n            raise APIConnectionError(\"Connection error.\")\n        return StaticStreamedMessage([TextPart(text=\"recovered\")])\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        self.recovery_calls += 1\n        return True\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        return self\n\n\nclass AlwaysConnectionErrorProvider:\n    name = \"always-connection-error\"\n\n    def __init__(self) -> None:\n        self.generate_attempts = 0\n        self.recovery_calls = 0\n\n    @property\n    def model_name(self) -> str:\n        return \"always-connection-error\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> StaticStreamedMessage:\n        self.generate_attempts += 1\n        raise APIConnectionError(\"Connection error.\")\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        self.recovery_calls += 1\n        return True\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        return self\n\n\nclass StatusErrorThenSuccessProvider:\n    name = \"status-error-then-success\"\n\n    def __init__(self) -> None:\n        self.generate_attempts = 0\n        self.recovery_calls = 0\n\n    @property\n    def model_name(self) -> str:\n        return \"status-error-then-success\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> StaticStreamedMessage:\n        self.generate_attempts += 1\n        if self.generate_attempts < 3:\n            raise APIStatusError(503, \"Service unavailable.\")\n        return StaticStreamedMessage([TextPart(text=\"status recovered\")])\n\n    def on_retryable_error(self, error: BaseException) -> bool:\n        self.recovery_calls += 1\n        return True\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        return self\n\n\nclass NonRetryableConnectionProvider:\n    name = \"non-retryable-connection\"\n\n    def __init__(self) -> None:\n        self.generate_attempts = 0\n\n    @property\n    def model_name(self) -> str:\n        return \"non-retryable-connection\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[Tool],\n        history: Sequence[Message],\n    ) -> StaticStreamedMessage:\n        self.generate_attempts += 1\n        if self.generate_attempts == 1:\n            raise APIConnectionError(\"Connection error.\")\n        return StaticStreamedMessage([TextPart(text=\"non-retryable recovered\")])\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        return self\n\n\ndef _runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime:\n    return Runtime(\n        config=runtime.config,\n        llm=llm,\n        session=runtime.session,\n        builtin_args=runtime.builtin_args,\n        denwa_renji=runtime.denwa_renji,\n        approval=runtime.approval,\n        labor_market=runtime.labor_market,\n        environment=runtime.environment,\n        notifications=runtime.notifications,\n        background_tasks=runtime.background_tasks,\n        skills=runtime.skills,\n        oauth=runtime.oauth,\n        additional_dirs=runtime.additional_dirs,\n        role=runtime.role,\n    )\n\n\ndef _make_soul(runtime: Runtime, llm: LLM, tmp_path: Path) -> tuple[KimiSoul, Context]:\n    agent = Agent(\n        name=\"Retry Test Agent\",\n        system_prompt=\"Retry test prompt.\",\n        toolset=SimpleToolset(),\n        runtime=_runtime_with_llm(runtime, llm),\n    )\n    context = Context(file_backend=tmp_path / \"history.jsonl\")\n    return KimiSoul(agent, context=context), context\n\n\nasync def _drain_ui_messages(wire: Wire) -> None:\n    wire_ui = wire.ui_side(merge=True)\n    while True:\n        try:\n            await wire_ui.receive()\n        except QueueShutDown:\n            return\n\n\n@pytest.mark.asyncio\nasync def test_step_retry_recovers_retryable_provider(runtime: Runtime, tmp_path: Path) -> None:\n    runtime.config.loop_control.max_retries_per_step = 2\n    provider = RecoveringSequenceProvider()\n    llm = LLM(\n        chat_provider=provider,\n        max_context_size=100_000,\n        capabilities=set(),\n    )\n    soul, context = _make_soul(runtime, llm, tmp_path)\n\n    await run_soul(soul, \"trigger recovery\", _drain_ui_messages, asyncio.Event())\n\n    assert provider.generate_attempts == 2\n    assert provider.recovery_calls == 1\n    assert context.history[-1].extract_text(\" \").strip() == \"recovered\"\n\n\n@pytest.mark.asyncio\nasync def test_step_connection_error_recovery_only_retries_once(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    runtime.config.loop_control.max_retries_per_step = 5\n    provider = AlwaysConnectionErrorProvider()\n    llm = LLM(\n        chat_provider=provider,\n        max_context_size=100_000,\n        capabilities=set(),\n    )\n    soul, _ = _make_soul(runtime, llm, tmp_path)\n\n    with pytest.raises(APIConnectionError):\n        await run_soul(soul, \"trigger connection failure\", _drain_ui_messages, asyncio.Event())\n\n    assert provider.generate_attempts == 2\n    assert provider.recovery_calls == 1\n\n\n@pytest.mark.asyncio\nasync def test_step_status_error_still_uses_tenacity_retries(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    runtime.config.loop_control.max_retries_per_step = 3\n    provider = StatusErrorThenSuccessProvider()\n    llm = LLM(\n        chat_provider=provider,\n        max_context_size=100_000,\n        capabilities=set(),\n    )\n    soul, context = _make_soul(runtime, llm, tmp_path)\n\n    await run_soul(soul, \"trigger status retry\", _drain_ui_messages, asyncio.Event())\n\n    assert provider.generate_attempts == 3\n    assert provider.recovery_calls == 0\n    assert context.history[-1].extract_text(\" \").strip() == \"status recovered\"\n\n\n@pytest.mark.asyncio\nasync def test_step_non_retryable_provider_keeps_tenacity_connection_retries(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    runtime.config.loop_control.max_retries_per_step = 2\n    provider = NonRetryableConnectionProvider()\n    llm = LLM(\n        chat_provider=provider,\n        max_context_size=100_000,\n        capabilities=set(),\n    )\n    soul, context = _make_soul(runtime, llm, tmp_path)\n\n    await run_soul(\n        soul, \"trigger non-retryable connection retry\", _drain_ui_messages, asyncio.Event()\n    )\n\n    assert provider.generate_attempts == 2\n    assert context.history[-1].extract_text(\" \").strip() == \"non-retryable recovered\"\n"
  },
  {
    "path": "tests/core/test_kimisoul_slash_commands.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling.empty import EmptyToolset\n\nfrom kimi_cli.skill import Skill\nfrom kimi_cli.skill.flow import Flow, FlowEdge, FlowNode\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\n\n\ndef _make_flow() -> Flow:\n    nodes = {\n        \"BEGIN\": FlowNode(id=\"BEGIN\", label=\"Begin\", kind=\"begin\"),\n        \"END\": FlowNode(id=\"END\", label=\"End\", kind=\"end\"),\n    }\n    outgoing = {\n        \"BEGIN\": [FlowEdge(src=\"BEGIN\", dst=\"END\", label=None)],\n        \"END\": [],\n    }\n    return Flow(nodes=nodes, outgoing=outgoing, begin_id=\"BEGIN\", end_id=\"END\")\n\n\ndef test_flow_skill_registers_skill_and_flow_commands(runtime: Runtime, tmp_path: Path) -> None:\n    flow = _make_flow()\n    skill_dir = tmp_path / \"flow-skill\"\n    skill_dir.mkdir()\n    flow_skill = Skill(\n        name=\"flow-skill\",\n        description=\"Flow skill\",\n        type=\"flow\",\n        dir=KaosPath.unsafe_from_local_path(skill_dir),\n        flow=flow,\n    )\n    runtime.skills = {\"flow-skill\": flow_skill}\n\n    agent = Agent(\n        name=\"Test Agent\",\n        system_prompt=\"Test system prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    soul = KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n    command_names = {cmd.name for cmd in soul.available_slash_commands}\n    assert \"skill:flow-skill\" in command_names\n    assert \"flow:flow-skill\" in command_names\n"
  },
  {
    "path": "tests/core/test_kimisoul_steer.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom pathlib import Path\n\nimport pytest\nfrom kosong import StepResult\nfrom kosong.message import ContentPart, Message\nfrom kosong.tooling.empty import EmptyToolset\n\nimport kimi_cli.soul.kimisoul as kimisoul_module\nfrom kimi_cli.llm import LLM, ModelCapability\nfrom kimi_cli.soul import LLMNotSupported, run_soul\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.dynamic_injection import DynamicInjection\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.soul.message import is_system_reminder_message\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.types import ImageURLPart, SteerInput, StepBegin, TextPart, TurnBegin, TurnEnd\n\n\ndef _make_soul(runtime: Runtime, tmp_path: Path) -> KimiSoul:\n    agent = Agent(\n        name=\"Steer Test Agent\",\n        system_prompt=\"Test prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    return KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n\ndef _runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime:\n    return Runtime(\n        config=runtime.config,\n        llm=llm,\n        session=runtime.session,\n        builtin_args=runtime.builtin_args,\n        denwa_renji=runtime.denwa_renji,\n        approval=runtime.approval,\n        labor_market=runtime.labor_market,\n        environment=runtime.environment,\n        notifications=runtime.notifications,\n        background_tasks=runtime.background_tasks,\n        skills=runtime.skills,\n        oauth=runtime.oauth,\n        additional_dirs=runtime.additional_dirs,\n    )\n\n\ndef _llm_with_capabilities(runtime: Runtime, capabilities: set[ModelCapability]) -> LLM:\n    assert runtime.llm is not None\n    return LLM(\n        chat_provider=runtime.llm.chat_provider,\n        max_context_size=runtime.llm.max_context_size,\n        capabilities=capabilities,\n        model_config=runtime.llm.model_config,\n        provider_config=runtime.llm.provider_config,\n    )\n\n\n@pytest.mark.asyncio\nasync def test_inject_steer_appends_plain_user_message(runtime: Runtime, tmp_path: Path) -> None:\n    soul = _make_soul(runtime, tmp_path)\n\n    await soul._inject_steer(\"Stop after summarizing the diff.\")\n\n    assert len(soul.context.history) == 1\n    message = soul.context.history[0]\n    assert message.role == \"user\"\n    assert message.content == [TextPart(text=\"Stop after summarizing the diff.\")]\n\n\n@pytest.mark.asyncio\nasync def test_inject_steer_preserves_content_parts(runtime: Runtime, tmp_path: Path) -> None:\n    soul = _make_soul(runtime, tmp_path)\n\n    parts: list[ContentPart] = [\n        TextPart(text=\"Focus on tests.\"),\n        TextPart(text=\"Explain failures.\"),\n    ]\n    await soul._inject_steer(parts)\n\n    message = soul.context.history[0]\n    assert message.content == parts\n\n\n@pytest.mark.asyncio\nasync def test_inject_steer_rejects_unsupported_media_and_keeps_context_clean(\n    runtime: Runtime,\n    tmp_path: Path,\n) -> None:\n    soul = _make_soul(_runtime_with_llm(runtime, _llm_with_capabilities(runtime, set())), tmp_path)\n\n    with pytest.raises(LLMNotSupported):\n        await soul._inject_steer(\n            [ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/diagram.png\"))]\n        )\n\n    assert soul.context.history == []\n\n\n@pytest.mark.asyncio\nasync def test_consume_pending_steers_appends_history_before_emitting_wire_event(\n    runtime: Runtime,\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    sent: list[SteerInput] = []\n\n    def fake_wire_send(msg) -> None:\n        assert soul.context.history == [\n            Message(role=\"user\", content=[TextPart(text=\"Follow up now.\")])\n        ]\n        assert isinstance(msg, SteerInput)\n        sent.append(msg)\n\n    monkeypatch.setattr(kimisoul_module, \"wire_send\", fake_wire_send)\n\n    soul.steer(\"Follow up now.\")\n\n    assert await soul._consume_pending_steers() is True\n    assert sent == [SteerInput(user_input=\"Follow up now.\")]\n\n\n@pytest.mark.asyncio\nasync def test_consume_pending_steers_does_not_emit_wire_event_for_unsupported_media(\n    runtime: Runtime,\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    soul = _make_soul(_runtime_with_llm(runtime, _llm_with_capabilities(runtime, set())), tmp_path)\n    sent: list[SteerInput] = []\n    monkeypatch.setattr(kimisoul_module, \"wire_send\", lambda msg: sent.append(msg))\n\n    soul.steer(\n        [ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/diagram.png\"))]\n    )\n\n    with pytest.raises(LLMNotSupported):\n        await soul._consume_pending_steers()\n\n    assert sent == []\n    assert soul.context.history == []\n\n\n@pytest.mark.asyncio\nasync def test_consume_pending_steers_preserves_fifo_order_and_emits_matching_events(\n    runtime: Runtime,\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    sent: list[SteerInput] = []\n\n    monkeypatch.setattr(kimisoul_module, \"wire_send\", lambda msg: sent.append(msg))\n\n    soul.steer(\"first\")\n    soul.steer(\"second\")\n\n    assert await soul._consume_pending_steers() is True\n    assert soul.context.history == [\n        Message(role=\"user\", content=[TextPart(text=\"first\")]),\n        Message(role=\"user\", content=[TextPart(text=\"second\")]),\n    ]\n    assert sent == [SteerInput(user_input=\"first\"), SteerInput(user_input=\"second\")]\n\n\n@pytest.mark.asyncio\nasync def test_agent_loop_injects_steer_between_completed_steps(\n    runtime: Runtime,\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    sent: list[object] = []\n    step_calls = 0\n\n    async def fake_fetch_request() -> None:\n        await asyncio.Future()\n\n    async def fake_checkpoint() -> None:\n        return None\n\n    monkeypatch.setattr(soul._approval, \"fetch_request\", fake_fetch_request)\n    monkeypatch.setattr(soul, \"_checkpoint\", fake_checkpoint)\n    monkeypatch.setattr(soul._denwa_renji, \"set_n_checkpoints\", lambda _n: None)\n    monkeypatch.setattr(kimisoul_module, \"wire_send\", lambda msg: sent.append(msg))\n\n    async def fake_step():\n        nonlocal step_calls\n        step_calls += 1\n        if step_calls == 1:\n            await soul.context.append_message(\n                Message(role=\"assistant\", content=[TextPart(text=\"tool call finished\")])\n            )\n            await soul.context.append_message(\n                Message(\n                    role=\"tool\",\n                    content=[TextPart(text=\"tool output\")],\n                    tool_call_id=\"call-1\",\n                )\n            )\n            soul.steer(\"follow-up steer\")\n            return None\n\n        assert soul.context.history == [\n            Message(role=\"assistant\", content=[TextPart(text=\"tool call finished\")]),\n            Message(\n                role=\"tool\",\n                content=[TextPart(text=\"tool output\")],\n                tool_call_id=\"call-1\",\n            ),\n            Message(role=\"user\", content=[TextPart(text=\"follow-up steer\")]),\n        ]\n        return kimisoul_module.StepOutcome(\n            stop_reason=\"no_tool_calls\",\n            assistant_message=Message(role=\"assistant\", content=[TextPart(text=\"done\")]),\n        )\n\n    monkeypatch.setattr(soul, \"_step\", fake_step)\n\n    outcome = await soul._agent_loop()\n\n    assert outcome.stop_reason == \"no_tool_calls\"\n    assert [msg for msg in sent if isinstance(msg, StepBegin)] == [StepBegin(n=1), StepBegin(n=2)]\n    assert [msg for msg in sent if isinstance(msg, SteerInput)] == [\n        SteerInput(user_input=\"follow-up steer\")\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_agent_loop_continues_after_tool_rejected_when_steer_is_injected(\n    runtime: Runtime,\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    sent: list[object] = []\n    step_calls = 0\n\n    async def fake_fetch_request() -> None:\n        await asyncio.Future()\n\n    async def fake_checkpoint() -> None:\n        return None\n\n    monkeypatch.setattr(soul._approval, \"fetch_request\", fake_fetch_request)\n    monkeypatch.setattr(soul, \"_checkpoint\", fake_checkpoint)\n    monkeypatch.setattr(soul._denwa_renji, \"set_n_checkpoints\", lambda _n: None)\n    monkeypatch.setattr(kimisoul_module, \"wire_send\", lambda msg: sent.append(msg))\n\n    async def fake_step():\n        nonlocal step_calls\n        step_calls += 1\n        if step_calls == 1:\n            await soul.context.append_message(\n                Message(role=\"assistant\", content=[TextPart(text=\"plan blocked the tool call\")])\n            )\n            await soul.context.append_message(\n                Message(\n                    role=\"tool\",\n                    content=[TextPart(text=\"<system>ERROR: rejected</system>\")],\n                    tool_call_id=\"call-1\",\n                )\n            )\n            soul.steer(\"switch to a read-only approach\")\n            return kimisoul_module.StepOutcome(\n                stop_reason=\"tool_rejected\",\n                assistant_message=Message(\n                    role=\"assistant\",\n                    content=[TextPart(text=\"plan blocked the tool call\")],\n                ),\n            )\n\n        assert soul.context.history == [\n            Message(role=\"assistant\", content=[TextPart(text=\"plan blocked the tool call\")]),\n            Message(\n                role=\"tool\",\n                content=[TextPart(text=\"<system>ERROR: rejected</system>\")],\n                tool_call_id=\"call-1\",\n            ),\n            Message(role=\"user\", content=[TextPart(text=\"switch to a read-only approach\")]),\n        ]\n        return kimisoul_module.StepOutcome(\n            stop_reason=\"no_tool_calls\",\n            assistant_message=Message(role=\"assistant\", content=[TextPart(text=\"done\")]),\n        )\n\n    monkeypatch.setattr(soul, \"_step\", fake_step)\n\n    outcome = await soul._agent_loop()\n\n    assert outcome.stop_reason == \"no_tool_calls\"\n    assert [msg for msg in sent if isinstance(msg, StepBegin)] == [StepBegin(n=1), StepBegin(n=2)]\n    assert [msg for msg in sent if isinstance(msg, SteerInput)] == [\n        SteerInput(user_input=\"switch to a read-only approach\")\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_step_merges_plain_steer_with_dynamic_injection_in_model_history(\n    runtime: Runtime,\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    captured_history: list[Message] = []\n\n    await soul.context.append_message(\n        [\n            Message(role=\"user\", content=[TextPart(text=\"Original question\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Original answer\")]),\n        ]\n    )\n    await soul._inject_steer(\"Follow user note\")\n\n    async def fake_kosong_step(chat_provider, system_prompt, toolset, history, **kwargs):\n        captured_history[:] = list(history)\n        return StepResult(\n            id=\"step-1\",\n            message=Message(role=\"assistant\", content=[TextPart(text=\"done\")]),\n            usage=None,\n            tool_calls=[],\n            _tool_result_futures={},\n        )\n\n    async def fake_collect_injections() -> list[DynamicInjection]:\n        return [DynamicInjection(type=\"plan_mode\", content=\"Internal reminder\")]\n\n    monkeypatch.setattr(\n        soul,\n        \"_collect_injections\",\n        fake_collect_injections,\n    )\n    monkeypatch.setattr(kimisoul_module.kosong, \"step\", fake_kosong_step)\n    monkeypatch.setattr(kimisoul_module, \"wire_send\", lambda _msg: None)\n\n    outcome = await soul._step()\n\n    assert outcome is not None\n    assert soul.context.history[-3:] == [\n        Message(role=\"user\", content=[TextPart(text=\"Follow user note\")]),\n        Message(\n            role=\"user\",\n            content=[TextPart(text=\"<system-reminder>\\nInternal reminder\\n</system-reminder>\")],\n        ),\n        Message(role=\"assistant\", content=[TextPart(text=\"done\")]),\n    ]\n    assert captured_history[-1].role == \"user\"\n    assert captured_history[-1].content == [\n        TextPart(text=\"Follow user note\"),\n        TextPart(text=\"<system-reminder>\\nInternal reminder\\n</system-reminder>\"),\n    ]\n\n\nclass _SequenceStreamedMessage:\n    def __init__(self, parts: list[TextPart]) -> None:\n        self._parts = list(parts)\n\n    def __aiter__(self):\n        return self\n\n    async def __anext__(self) -> TextPart:\n        if not self._parts:\n            raise StopAsyncIteration\n        return self._parts.pop(0)\n\n    @property\n    def id(self) -> str | None:\n        return \"sequence\"\n\n    @property\n    def usage(self):\n        return None\n\n\nclass _SequenceChatProvider:\n    name = \"sequence\"\n\n    def __init__(self, sequences: list[list[TextPart]]) -> None:\n        self._sequences = [list(parts) for parts in sequences]\n        self._calls = 0\n\n    @property\n    def model_name(self) -> str:\n        return \"sequence\"\n\n    @property\n    def thinking_effort(self):\n        return None\n\n    async def generate(self, system_prompt, tools, history):\n        index = min(self._calls, len(self._sequences) - 1)\n        self._calls += 1\n        return _SequenceStreamedMessage(self._sequences[index])\n\n    def with_thinking(self, effort):\n        return self\n\n\n@pytest.mark.asyncio\nasync def test_run_soul_emits_steer_input_and_continues_same_turn(\n    runtime: Runtime,\n    tmp_path: Path,\n) -> None:\n    assert runtime.llm is not None\n    llm = LLM(\n        chat_provider=_SequenceChatProvider(\n            [\n                [TextPart(text=\"first answer\")],\n                [TextPart(text=\"second answer\")],\n            ]\n        ),\n        max_context_size=runtime.llm.max_context_size,\n        capabilities=runtime.llm.capabilities,\n    )\n    agent = Agent(\n        name=\"Steer Test Agent\",\n        system_prompt=\"Test prompt.\",\n        toolset=EmptyToolset(),\n        runtime=_runtime_with_llm(runtime, llm),\n    )\n    soul = KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n    seen: list[object] = []\n    injected = False\n\n    async def ui_loop(wire: Wire) -> None:\n        nonlocal injected\n        wire_ui = wire.ui_side(merge=True)\n        while True:\n            try:\n                msg = await wire_ui.receive()\n            except QueueShutDown:\n                return\n            seen.append(msg)\n            if not injected and msg == TextPart(text=\"first answer\"):\n                soul.steer(\"follow-up steer\")\n                injected = True\n\n    await run_soul(soul, \"original question\", ui_loop, asyncio.Event())\n\n    assert soul.context.history == [\n        Message(role=\"user\", content=[TextPart(text=\"original question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"first answer\")]),\n        Message(role=\"user\", content=[TextPart(text=\"follow-up steer\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"second answer\")]),\n    ]\n    assert [msg for msg in seen if isinstance(msg, TurnBegin)] == [\n        TurnBegin(user_input=\"original question\")\n    ]\n    assert [msg for msg in seen if isinstance(msg, SteerInput)] == [\n        SteerInput(user_input=\"follow-up steer\")\n    ]\n    assert [msg for msg in seen if isinstance(msg, StepBegin)] == [StepBegin(n=1), StepBegin(n=2)]\n    assert isinstance(seen[-1], TurnEnd)\n\n\ndef test_is_system_reminder_message_detects_internal_reminder_message() -> None:\n    assert is_system_reminder_message(\n        Message(\n            role=\"user\",\n            content=[TextPart(text=\"<system-reminder>\\nStay on task.\\n</system-reminder>\")],\n        )\n    )\n\n\ndef test_is_system_reminder_message_rejects_regular_user_message() -> None:\n    assert (\n        is_system_reminder_message(Message(role=\"user\", content=[TextPart(text=\"hello\")])) is False\n    )\n"
  },
  {
    "path": "tests/core/test_load_agent.py",
    "content": "\"\"\"Tests for agent loading functionality.\"\"\"\n\nfrom __future__ import annotations\n\nimport tempfile\nfrom collections.abc import Generator\nfrom pathlib import Path\nfrom typing import Any\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.config import Config\nfrom kimi_cli.exception import InvalidToolError, SystemPromptTemplateError\nfrom kimi_cli.session import Session\nfrom kimi_cli.session_state import DynamicSubagentSpec\nfrom kimi_cli.soul.agent import BuiltinSystemPromptArgs, Runtime, _load_system_prompt, load_agent\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.soul.denwarenji import DenwaRenji\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.utils.environment import Environment\n\n\ndef test_load_system_prompt(system_prompt_file: Path, builtin_args: BuiltinSystemPromptArgs):\n    \"\"\"Test loading system prompt with template substitution.\"\"\"\n    prompt = _load_system_prompt(system_prompt_file, {\"CUSTOM_ARG\": \"test_value\"}, builtin_args)\n\n    assert \"Test system prompt with \" in prompt\n    assert \"1970-01-01\" in prompt  # Should contain the actual timestamp\n    assert builtin_args.KIMI_NOW in prompt\n    assert \"test_value\" in prompt\n\n\ndef test_load_system_prompt_allows_literal_dollar(builtin_args: BuiltinSystemPromptArgs):\n    \"\"\"System prompt should allow literal $ without template errors.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"Price is $100, path $PATH, time ${KIMI_NOW}.\")\n        prompt = _load_system_prompt(system_md, {}, builtin_args)\n\n    assert \"$100\" in prompt\n    assert \"$PATH\" in prompt\n    assert builtin_args.KIMI_NOW in prompt\n\n\ndef test_load_system_prompt_missing_arg_raises(builtin_args: BuiltinSystemPromptArgs):\n    \"\"\"Missing template args should raise a dedicated error.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"Missing ${UNKNOWN_ARG}.\")\n        with pytest.raises(SystemPromptTemplateError):\n            _load_system_prompt(system_md, {}, builtin_args)\n\n\ndef test_load_tools_valid(runtime: Runtime):\n    \"\"\"Test loading valid tools.\"\"\"\n    tool_paths = [\"kimi_cli.tools.think:Think\", \"kimi_cli.tools.shell:Shell\"]\n    toolset = KimiToolset()\n    toolset.load_tools(\n        tool_paths,\n        {\n            Runtime: runtime,\n            Config: runtime.config,\n            BuiltinSystemPromptArgs: runtime.builtin_args,\n            Session: runtime.session,\n            DenwaRenji: runtime.denwa_renji,\n            Approval: runtime.approval,\n            Environment: runtime.environment,\n        },\n    )\n    assert len(toolset.tools) == snapshot(2)\n\n\ndef test_load_tools_invalid(runtime: Runtime):\n    \"\"\"Test loading with invalid tool paths.\"\"\"\n    tool_paths = [\"kimi_cli.tools.nonexistent:Tool\", \"kimi_cli.tools.think:Think\"]\n    toolset = KimiToolset()\n    try:\n        toolset.load_tools(\n            tool_paths,\n            {\n                Runtime: runtime,\n                Config: runtime.config,\n                BuiltinSystemPromptArgs: runtime.builtin_args,\n                Session: runtime.session,\n                DenwaRenji: runtime.denwa_renji,\n                Approval: runtime.approval,\n            },\n        )\n        raise AssertionError(\"should fail to load non-existing tool\")\n    except InvalidToolError as e:\n        assert \"kimi_cli.tools.nonexistent:Tool\" in str(e)\n\n\nasync def test_load_agent_invalid_tools(agent_file_invalid_tools: Path, runtime: Runtime):\n    \"\"\"Test loading agent with invalid tools raises ValueError.\"\"\"\n    with pytest.raises(ValueError, match=\"Invalid tools\"):\n        await load_agent(agent_file_invalid_tools, runtime, mcp_configs=[])\n\n\nasync def test_fixed_subagent_does_not_restore_dynamic_subagents(runtime: Runtime):\n    \"\"\"Fixed subagents should not have dynamic subagents injected into their LaborMarket.\"\"\"\n    # Inject a dynamic subagent spec into session state\n    runtime.session.state.dynamic_subagents = [\n        DynamicSubagentSpec(name=\"dynamic-helper\", system_prompt=\"I am dynamic\"),\n    ]\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create system prompts\n        (tmpdir / \"system.md\").write_text(\"Main agent prompt\")\n        (tmpdir / \"sub_system.md\").write_text(\"Sub agent prompt\")\n\n        # Create sub agent YAML (no subagents, minimal tools)\n        sub_yaml = tmpdir / \"sub.yaml\"\n        sub_yaml.write_text(\n            'version: 1\\nagent:\\n  name: \"Sub\"\\n'\n            \"  system_prompt_path: ./sub_system.md\\n\"\n            '  tools: [\"kimi_cli.tools.think:Think\"]\\n'\n        )\n\n        # Create main agent YAML with a fixed subagent\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\n            'version: 1\\nagent:\\n  name: \"Main\"\\n'\n            \"  system_prompt_path: ./system.md\\n\"\n            '  tools: [\"kimi_cli.tools.think:Think\"]\\n'\n            \"  subagents:\\n\"\n            \"    coder:\\n\"\n            \"      path: ./sub.yaml\\n\"\n            '      description: \"A sub agent\"\\n'\n        )\n\n        agent = await load_agent(agent_yaml, runtime, mcp_configs=[])\n\n    # Main agent should have the dynamic subagent restored\n    assert \"dynamic-helper\" in agent.runtime.labor_market.dynamic_subagents\n\n    # Fixed subagent should NOT have the dynamic subagent\n    fixed_sub = agent.runtime.labor_market.fixed_subagents[\"coder\"]\n    assert \"dynamic-helper\" not in fixed_sub.runtime.labor_market.dynamic_subagents\n    assert len(fixed_sub.runtime.labor_market.dynamic_subagents) == 0\n\n\nasync def test_load_agent_starts_mcp_in_background(runtime: Runtime, monkeypatch):\n    called: dict[str, bool] = {}\n\n    async def fake_load_mcp_tools(self, mcp_configs, runtime, in_background: bool = True):\n        called[\"in_background\"] = in_background\n\n    monkeypatch.setattr(KimiToolset, \"load_mcp_tools\", fake_load_mcp_tools)\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n        (tmpdir / \"system.md\").write_text(\"Main agent prompt\")\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\n            'version: 1\\nagent:\\n  name: \"Main\"\\n'\n            \"  system_prompt_path: ./system.md\\n\"\n            '  tools: [\"kimi_cli.tools.think:Think\"]\\n'\n        )\n\n        await load_agent(agent_yaml, runtime, mcp_configs=[{\"mcpServers\": {}}])\n\n    assert called == {\"in_background\": True}\n\n\nasync def test_load_agent_can_defer_mcp_loading(runtime: Runtime, monkeypatch):\n    called: dict[str, bool] = {}\n\n    async def fake_load_mcp_tools(self, mcp_configs, runtime, in_background: bool = True):\n        called[\"load_called\"] = True\n\n    def fake_defer_mcp_tool_loading(self, mcp_configs, runtime):\n        called[\"defer_called\"] = True\n\n    monkeypatch.setattr(KimiToolset, \"load_mcp_tools\", fake_load_mcp_tools)\n    monkeypatch.setattr(KimiToolset, \"defer_mcp_tool_loading\", fake_defer_mcp_tool_loading)\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n        (tmpdir / \"system.md\").write_text(\"Main agent prompt\")\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\n            'version: 1\\nagent:\\n  name: \"Main\"\\n'\n            \"  system_prompt_path: ./system.md\\n\"\n            '  tools: [\"kimi_cli.tools.think:Think\"]\\n'\n        )\n\n        await load_agent(\n            agent_yaml,\n            runtime,\n            mcp_configs=[{\"mcpServers\": {}}],\n            start_mcp_loading=False,\n        )\n\n    assert called == {\"defer_called\": True}\n\n\n@pytest.fixture\ndef agent_file_invalid_tools() -> Generator[Path, Any, Any]:\n    \"\"\"Create an agent configuration file with invalid tools.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        # Create system.md\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"You are a test agent\")\n\n        # Create agent.yaml with invalid tools\n        agent_yaml = tmpdir / \"agent.yaml\"\n        agent_yaml.write_text(\"\"\"\nversion: 1\nagent:\n  name: \"Test Agent\"\n  system_prompt_path: ./system.md\n  tools: [\"kimi_cli.tools.nonexistent:Tool\"]\n\"\"\")\n\n        yield agent_yaml\n\n\n@pytest.fixture\ndef system_prompt_file() -> Generator[Path, Any, Any]:\n    \"\"\"Create a system prompt file with template variables.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        system_md = tmpdir / \"system.md\"\n        system_md.write_text(\"Test system prompt with ${KIMI_NOW} and ${CUSTOM_ARG}\")\n\n        yield system_md\n"
  },
  {
    "path": "tests/core/test_load_agents_md.py",
    "content": "from __future__ import annotations\n\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.soul.agent import load_agents_md\n\n\nasync def test_load_agents_md_found(temp_work_dir: KaosPath):\n    \"\"\"Test loading AGENTS.md when it exists.\"\"\"\n    agents_md = temp_work_dir / \"AGENTS.md\"\n    await agents_md.write_text(\"Test agents content\")\n\n    content = await load_agents_md(temp_work_dir)\n\n    assert content == \"Test agents content\"\n\n\nasync def test_load_agents_md_not_found(temp_work_dir: KaosPath):\n    \"\"\"Test loading AGENTS.md when it doesn't exist.\"\"\"\n    content = await load_agents_md(temp_work_dir)\n\n    assert content is None\n\n\nasync def test_load_agents_md_lowercase(temp_work_dir: KaosPath):\n    \"\"\"Test loading agents.md (lowercase).\"\"\"\n    agents_md = temp_work_dir / \"agents.md\"\n    await agents_md.write_text(\"Lowercase agents content\")\n\n    content = await load_agents_md(temp_work_dir)\n\n    assert content == \"Lowercase agents content\"\n"
  },
  {
    "path": "tests/core/test_normalize_history.py",
    "content": "\"\"\"Tests for normalize_history in the dynamic_injection module.\"\"\"\n\nfrom __future__ import annotations\n\nfrom kosong.message import ContentPart, Message, TextPart\n\nfrom kimi_cli.soul.dynamic_injection import normalize_history\n\n\ndef _text(part: ContentPart) -> str:\n    assert isinstance(part, TextPart)\n    return part.text\n\n\ndef test_empty_history() -> None:\n    assert normalize_history([]) == []\n\n\ndef test_single_user_message() -> None:\n    msgs = [Message(role=\"user\", content=[TextPart(text=\"hello\")])]\n    result = normalize_history(msgs)\n    assert len(result) == 1\n    assert result[0].role == \"user\"\n    assert _text(result[0].content[0]) == \"hello\"\n\n\ndef test_single_assistant_message() -> None:\n    msgs = [Message(role=\"assistant\", content=[TextPart(text=\"hi\")])]\n    result = normalize_history(msgs)\n    assert len(result) == 1\n    assert result[0].role == \"assistant\"\n\n\ndef test_adjacent_user_messages_merged() -> None:\n    msgs = [\n        Message(role=\"user\", content=[TextPart(text=\"A\")]),\n        Message(role=\"user\", content=[TextPart(text=\"B\")]),\n    ]\n    result = normalize_history(msgs)\n    assert len(result) == 1\n    assert result[0].role == \"user\"\n    assert len(result[0].content) == 2\n    assert _text(result[0].content[0]) == \"A\"\n    assert _text(result[0].content[1]) == \"B\"\n\n\ndef test_three_adjacent_user_messages_merged() -> None:\n    msgs = [\n        Message(role=\"user\", content=[TextPart(text=\"A\")]),\n        Message(role=\"user\", content=[TextPart(text=\"B\")]),\n        Message(role=\"user\", content=[TextPart(text=\"C\")]),\n    ]\n    result = normalize_history(msgs)\n    assert len(result) == 1\n    assert len(result[0].content) == 3\n\n\ndef test_non_adjacent_users_not_merged() -> None:\n    msgs = [\n        Message(role=\"user\", content=[TextPart(text=\"A\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"X\")]),\n        Message(role=\"user\", content=[TextPart(text=\"B\")]),\n    ]\n    result = normalize_history(msgs)\n    assert len(result) == 3\n    assert result[0].role == \"user\"\n    assert result[1].role == \"assistant\"\n    assert result[2].role == \"user\"\n\n\ndef test_adjacent_assistant_not_merged() -> None:\n    msgs = [\n        Message(role=\"assistant\", content=[TextPart(text=\"X\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Y\")]),\n    ]\n    result = normalize_history(msgs)\n    assert len(result) == 2\n\n\ndef test_mixed_roles_complex() -> None:\n    msgs = [\n        Message(role=\"user\", content=[TextPart(text=\"A\")]),\n        Message(role=\"user\", content=[TextPart(text=\"B\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"X\")]),\n        Message(role=\"user\", content=[TextPart(text=\"C\")]),\n        Message(role=\"user\", content=[TextPart(text=\"D\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Y\")]),\n    ]\n    result = normalize_history(msgs)\n    assert len(result) == 4\n    assert result[0].role == \"user\"\n    assert len(result[0].content) == 2  # A + B merged\n    assert result[1].role == \"assistant\"\n    assert result[2].role == \"user\"\n    assert len(result[2].content) == 2  # C + D merged\n    assert result[3].role == \"assistant\"\n\n\ndef test_multipart_content_preserved() -> None:\n    msgs = [\n        Message(role=\"user\", content=[TextPart(text=\"A\"), TextPart(text=\"B\")]),\n        Message(role=\"user\", content=[TextPart(text=\"C\")]),\n    ]\n    result = normalize_history(msgs)\n    assert len(result) == 1\n    assert len(result[0].content) == 3\n    assert _text(result[0].content[0]) == \"A\"\n    assert _text(result[0].content[1]) == \"B\"\n    assert _text(result[0].content[2]) == \"C\"\n\n\ndef test_notification_messages_not_merged_with_user_messages() -> None:\n    msgs = [\n        Message(role=\"user\", content=[TextPart(text=\"user input\")]),\n        Message(\n            role=\"user\",\n            content=[\n                TextPart(\n                    text='<notification id=\"n1\" category=\"task\" type=\"task.completed\">x</notification>'\n                )\n            ],\n        ),\n    ]\n    result = normalize_history(msgs)\n    assert len(result) == 2\n"
  },
  {
    "path": "tests/core/test_notifications.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport time\nfrom collections.abc import Sequence\nfrom pathlib import Path\nfrom typing import Self\n\nimport pytest\nfrom kosong.chat_provider import StreamedMessagePart, ThinkingEffort, TokenUsage\nfrom kosong.message import Message, TextPart\nfrom kosong.tooling.empty import EmptyToolset\n\nfrom kimi_cli.background import TaskRuntime, TaskSpec\nfrom kimi_cli.llm import LLM\nfrom kimi_cli.notifications import NotificationEvent\nfrom kimi_cli.soul import _current_wire, run_soul\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.types import Notification\n\n\nclass _SequenceStream:\n    def __init__(self, parts: Sequence[StreamedMessagePart]) -> None:\n        self._parts = list(parts)\n\n    def __aiter__(self) -> Self:\n        return self\n\n    async def __anext__(self) -> StreamedMessagePart:\n        if not self._parts:\n            raise StopAsyncIteration\n        return self._parts.pop(0)\n\n    @property\n    def id(self) -> str | None:\n        return \"notification-sequence\"\n\n    @property\n    def usage(self) -> TokenUsage | None:\n        return None\n\n\nclass _SequenceProvider:\n    name = \"notification-sequence\"\n\n    def __init__(self, parts: Sequence[StreamedMessagePart]) -> None:\n        self._parts = list(parts)\n\n    @property\n    def model_name(self) -> str:\n        return \"notification-sequence\"\n\n    @property\n    def thinking_effort(self) -> ThinkingEffort | None:\n        return None\n\n    async def generate(\n        self,\n        system_prompt: str,\n        tools: Sequence[object],\n        history: Sequence[Message],\n    ) -> _SequenceStream:\n        return _SequenceStream(self._parts)\n\n    def with_thinking(self, effort: ThinkingEffort) -> Self:\n        return self\n\n\ndef _runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime:\n    return Runtime(\n        config=runtime.config,\n        llm=llm,\n        session=runtime.session,\n        builtin_args=runtime.builtin_args,\n        denwa_renji=runtime.denwa_renji,\n        approval=runtime.approval,\n        labor_market=runtime.labor_market,\n        environment=runtime.environment,\n        notifications=runtime.notifications,\n        background_tasks=runtime.background_tasks,\n        skills=runtime.skills,\n        oauth=runtime.oauth,\n        additional_dirs=runtime.additional_dirs,\n        role=runtime.role,\n    )\n\n\ndef _make_soul(runtime: Runtime, tmp_path: Path) -> tuple[KimiSoul, Context]:\n    llm = LLM(\n        chat_provider=_SequenceProvider([TextPart(text=\"done\")]),\n        max_context_size=100_000,\n        capabilities=set(),\n    )\n    agent = Agent(\n        name=\"Notification Agent\",\n        system_prompt=\"System prompt.\",\n        toolset=EmptyToolset(),\n        runtime=_runtime_with_llm(runtime, llm),\n    )\n    context = Context(file_backend=tmp_path / \"history.jsonl\")\n    return KimiSoul(agent, context=context), context\n\n\ndef _write_completed_task(runtime: Runtime, task_id: str) -> None:\n    spec = TaskSpec(\n        id=task_id,\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"background completion\",\n        tool_call_id=\"tool-8\",\n        command=\"echo done\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    runtime.background_tasks.store.create_task(spec)\n    runtime.background_tasks.store.output_path(spec.id).write_text(\n        \"line 1\\nline 2\\n\", encoding=\"utf-8\"\n    )\n    runtime.background_tasks.store.write_runtime(\n        spec.id,\n        TaskRuntime(\n            status=\"completed\",\n            exit_code=0,\n            finished_at=time.time(),\n            updated_at=time.time(),\n        ),\n    )\n\n\n@pytest.mark.asyncio\nasync def test_kimisoul_appends_notification_message(runtime: Runtime, tmp_path: Path) -> None:\n    _write_completed_task(runtime, \"b3333333\")\n    runtime.background_tasks.publish_terminal_notifications()\n\n    soul, context = _make_soul(runtime, tmp_path)\n\n    async def _drain_ui(wire: Wire) -> None:\n        wire_ui = wire.ui_side(merge=True)\n        while True:\n            try:\n                await wire_ui.receive()\n            except QueueShutDown:\n                return\n\n    await run_soul(soul, \"check status\", _drain_ui, asyncio.Event())\n\n    notification_texts = [\n        message.extract_text(\"\\n\")\n        for message in context.history\n        if \"<notification \" in message.extract_text(\"\\n\")\n    ]\n    assert len(notification_texts) == 1\n    assert \"Task ID: b3333333\" in notification_texts[0]\n    assert \"line 2\" in notification_texts[0]\n\n\n@pytest.mark.asyncio\nasync def test_run_soul_emits_wire_notifications(runtime: Runtime, tmp_path: Path) -> None:\n    runtime.notifications.publish(\n        NotificationEvent(\n            id=runtime.notifications.new_id(),\n            category=\"system\",\n            type=\"system.info\",\n            source_kind=\"test\",\n            source_id=\"source-1\",\n            title=\"Test notification\",\n            body=\"hello from notification\",\n            targets=[\"wire\"],\n        )\n    )\n    soul, _ = _make_soul(runtime, tmp_path)\n    seen: list[Notification] = []\n\n    async def _ui_loop(wire: Wire) -> None:\n        wire_ui = wire.ui_side(merge=False)\n        while True:\n            try:\n                msg = await wire_ui.receive()\n            except QueueShutDown:\n                return\n            if isinstance(msg, Notification):\n                seen.append(msg)\n\n    await run_soul(soul, \"ping\", _ui_loop, asyncio.Event(), runtime=runtime)\n\n    assert len(seen) == 1\n    assert seen[0].title == \"Test notification\"\n    assert seen[0].body == \"hello from notification\"\n\n\n@pytest.mark.asyncio\nasync def test_run_soul_flushes_wire_notifications_published_right_before_turn_end(\n    runtime: Runtime,\n) -> None:\n    class _LateNotificationSoul:\n        def __init__(self, runtime: Runtime) -> None:\n            self.runtime = runtime\n\n        async def run(self, _user_input: str) -> None:\n            await asyncio.sleep(0.05)\n            self.runtime.notifications.publish(\n                NotificationEvent(\n                    id=self.runtime.notifications.new_id(),\n                    category=\"system\",\n                    type=\"system.info\",\n                    source_kind=\"test\",\n                    source_id=\"source-2\",\n                    title=\"Late notification\",\n                    body=\"published right before turn end\",\n                    targets=[\"wire\"],\n                )\n            )\n\n    seen: list[Notification] = []\n\n    async def _ui_loop(wire: Wire) -> None:\n        wire_ui = wire.ui_side(merge=False)\n        while True:\n            try:\n                msg = await wire_ui.receive()\n            except QueueShutDown:\n                return\n            if isinstance(msg, Notification):\n                seen.append(msg)\n\n    await run_soul(\n        _LateNotificationSoul(runtime),  # type: ignore[arg-type]\n        \"ping\",\n        _ui_loop,\n        asyncio.Event(),\n        runtime=runtime,\n    )\n\n    assert [msg.title for msg in seen] == [\"Late notification\"]\n\n\n@pytest.mark.asyncio\nasync def test_compaction_appends_active_task_snapshot(runtime: Runtime, tmp_path: Path) -> None:\n    _write_completed_task(runtime, \"b3333344\")\n    running_spec = TaskSpec(\n        id=\"b3333345\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"still running\",\n        tool_call_id=\"tool-9\",\n        command=\"sleep 10\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    runtime.background_tasks.store.create_task(running_spec)\n    runtime.background_tasks.store.write_runtime(\n        running_spec.id,\n        TaskRuntime(status=\"running\", updated_at=time.time()),\n    )\n\n    soul, context = _make_soul(runtime, tmp_path)\n    await context.append_message(\n        [\n            Message(role=\"user\", content=[TextPart(text=\"message 1\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"message 2\")]),\n            Message(role=\"user\", content=[TextPart(text=\"message 3\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"message 4\")]),\n        ]\n    )\n\n    wire = Wire()\n    token = _current_wire.set(wire)\n    try:\n        await soul.compact_context()\n    finally:\n        _current_wire.reset(token)\n\n    texts = [message.extract_text(\"\\n\") for message in context.history]\n    assert any(\"<active-background-tasks>\" in text for text in texts)\n    assert any(\"task_id: b3333345\" in text for text in texts)\n"
  },
  {
    "path": "tests/core/test_plan_mode.py",
    "content": "\"\"\"Tests for Plan Mode tools and helpers.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock\n\nimport pytest\nfrom kosong.tooling import ToolError, ToolReturnValue\nfrom kosong.tooling.empty import EmptyToolset\nfrom pydantic import ValidationError\n\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.tools.file.replace import StrReplaceFile\nfrom kimi_cli.tools.file.write import WriteFile\nfrom kimi_cli.tools.plan import ExitPlanMode, Params, PlanOption\nfrom kimi_cli.tools.plan.enter import _DESCRIPTION, EnterPlanMode\nfrom kimi_cli.tools.plan.heroes import (\n    _slug_cache,\n    get_or_create_slug,\n    get_plan_file_path,\n    read_plan_file,\n)\nfrom kimi_cli.tools.utils import ToolRejectedError\nfrom kimi_cli.wire.types import QuestionNotSupported, QuestionRequest, ToolCall\n\n# ---------------------------------------------------------------------------\n# helpers\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture(autouse=True)\ndef _clear_slug_cache():\n    \"\"\"Clear the module-level slug cache before each test.\"\"\"\n    _slug_cache.clear()\n    yield\n    _slug_cache.clear()\n\n\ndef _make_soul(runtime: Runtime, tmp_path: Path) -> KimiSoul:\n    agent = Agent(\n        name=\"Test Agent\",\n        system_prompt=\"Test system prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    return KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n\ndef _tool_output_text(result: ToolReturnValue) -> str:\n    assert isinstance(result.output, str)\n    return result.output\n\n\n# ---------------------------------------------------------------------------\n# heroes.py — slug generation\n# ---------------------------------------------------------------------------\n\n\nclass TestGetOrCreateSlug:\n    def test_returns_hero_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        slug = get_or_create_slug(\"session-1\")\n        # Slug is composed of 3 hero names joined by \"-\"; each hero name may itself contain \"-\"\n        # Just verify it's a non-empty string and contains at least some hero name substrings\n        assert isinstance(slug, str) and len(slug) > 0\n\n    def test_cache_hit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        first = get_or_create_slug(\"session-1\")\n        second = get_or_create_slug(\"session-1\")\n        assert first == second\n\n    def test_different_sessions_get_different_slugs(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        a = get_or_create_slug(\"session-a\")\n        b = get_or_create_slug(\"session-b\")\n        # Extremely unlikely to be equal with 230+ names, but not impossible.\n        # This test is probabilistic; if it flakes, the pool is too small.\n        assert isinstance(a, str)\n        assert isinstance(b, str)\n\n    def test_collision_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        \"\"\"When all random choices collide, append session prefix for uniqueness.\"\"\"\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        # Use a tiny hero list so we can predict and pre-create all combos\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.HERO_NAMES\", [\"a\", \"b\"])\n        # Pre-create all possible 3-word combos from [\"a\", \"b\"]\n        import itertools\n\n        for combo in itertools.product([\"a\", \"b\"], repeat=3):\n            (tmp_path / f\"{'-'.join(combo)}.md\").touch()\n\n        session_id = \"abcdef1234567890\"\n        slug = get_or_create_slug(session_id)\n        # Should have the session prefix appended\n        assert slug.endswith(f\"-{session_id[:8]}\")\n\n\nclass TestGetPlanFilePath:\n    def test_returns_md_file_in_plans_dir(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        path = get_plan_file_path(\"session-1\")\n        assert path.parent == tmp_path\n        assert path.suffix == \".md\"\n\n\nclass TestReadPlanFile:\n    def test_reads_existing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        # First, get the path so the slug is generated\n        path = get_plan_file_path(\"session-1\")\n        path.write_text(\"# My Plan\", encoding=\"utf-8\")\n        content = read_plan_file(\"session-1\")\n        assert content == \"# My Plan\"\n\n    def test_returns_none_for_missing_file(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        content = read_plan_file(\"session-nonexistent\")\n        assert content is None\n\n\n# ---------------------------------------------------------------------------\n# ExitPlanMode — guard conditions\n# ---------------------------------------------------------------------------\n\n\nclass TestExitPlanModeGuards:\n    async def test_not_in_plan_mode(self) -> None:\n        from kimi_cli.tools.plan import ExitPlanMode\n\n        tool = ExitPlanMode()\n        tool.bind(\n            toggle_callback=AsyncMock(return_value=False),\n            plan_file_path_getter=lambda: Path(\"/tmp/plan.md\"),\n            plan_mode_checker=lambda: False,\n        )\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Not in plan mode\" in result.message\n\n    async def test_not_bound(self) -> None:\n        from kimi_cli.tools.plan import ExitPlanMode\n\n        tool = ExitPlanMode()\n        # Not calling bind() at all\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Not in plan mode\" in result.message\n\n    async def test_no_plan_file(self, tmp_path: Path) -> None:\n        from kimi_cli.tools.plan import ExitPlanMode\n\n        tool = ExitPlanMode()\n        plan_path = tmp_path / \"nonexistent.md\"\n        tool.bind(\n            toggle_callback=AsyncMock(return_value=False),\n            plan_file_path_getter=lambda: plan_path,\n            plan_mode_checker=lambda: True,\n        )\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"No plan file found\" in result.message\n\n\n# ---------------------------------------------------------------------------\n# EnterPlanMode — guard conditions\n# ---------------------------------------------------------------------------\n\n\nclass TestEnterPlanModeGuards:\n    async def test_already_in_plan_mode(self) -> None:\n        from kimi_cli.tools.plan.enter import EnterPlanMode\n\n        tool = EnterPlanMode()\n        tool.bind(\n            toggle_callback=AsyncMock(return_value=True),\n            plan_file_path_getter=lambda: Path(\"/tmp/plan.md\"),\n            plan_mode_checker=lambda: True,\n        )\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Already in plan mode\" in result.message\n\n    async def test_not_bound(self) -> None:\n        from kimi_cli.tools.plan.enter import EnterPlanMode\n\n        tool = EnterPlanMode()\n        # plan_mode_checker is None, so it won't trigger the \"already in plan mode\" guard\n        # but toggle_callback is None, so it will trigger \"not initialized\"\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"not properly initialized\" in result.message\n\n\n# ---------------------------------------------------------------------------\n# EnterPlanMode — static description\n# ---------------------------------------------------------------------------\n\n\nclass TestEnterPlanModeDescription:\n    def test_description_is_static(self) -> None:\n        tool = EnterPlanMode()\n        assert tool.base.description == _DESCRIPTION\n\n\nclass TestManualPlanModeInjections:\n    async def test_manual_toggle_defers_activation_to_injection(\n        self,\n        runtime: Runtime,\n        tmp_path: Path,\n        monkeypatch: pytest.MonkeyPatch,\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        assert await soul.toggle_plan_mode_from_manual() is True\n        assert soul.plan_mode is True\n        assert soul._pending_plan_activation_injection is True\n        assert soul.context.history == []\n\n        injections = await soul._collect_injections()\n\n        assert len(injections) == 1\n        assert injections[0].type == \"plan_mode\"\n        assert \"Plan mode is active.\" in injections[0].content\n        assert soul._pending_plan_activation_injection is False\n        assert soul.context.history == []\n\n    async def test_manual_exit_clears_pending_activation_injection(\n        self,\n        runtime: Runtime,\n        tmp_path: Path,\n        monkeypatch: pytest.MonkeyPatch,\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        assert await soul.toggle_plan_mode_from_manual() is True\n        assert soul._pending_plan_activation_injection is True\n\n        assert await soul.toggle_plan_mode_from_manual() is False\n        assert soul.plan_mode is False\n        assert soul._pending_plan_activation_injection is False\n\n        injections = await soul._collect_injections()\n        assert injections == []\n\n    async def test_tool_toggle_does_not_queue_manual_activation_injection(\n        self,\n        runtime: Runtime,\n        tmp_path: Path,\n        monkeypatch: pytest.MonkeyPatch,\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        assert await soul.toggle_plan_mode() is True\n        assert soul.plan_mode is True\n        assert soul._pending_plan_activation_injection is False\n\n\n# ---------------------------------------------------------------------------\n# ExitPlanMode — happy paths\n# ---------------------------------------------------------------------------\n\n\ndef _setup_exit_tool(\n    tmp_path: Path, plan_content: str = \"# My Plan\"\n) -> tuple[ExitPlanMode, AsyncMock, Path]:\n    \"\"\"Create and bind an ExitPlanMode tool with a real plan file.\"\"\"\n    tool = ExitPlanMode()\n    plan_path = tmp_path / \"plans\" / \"test-plan.md\"\n    plan_path.parent.mkdir(parents=True, exist_ok=True)\n    plan_path.write_text(plan_content, encoding=\"utf-8\")\n    toggle_cb = AsyncMock(return_value=False)\n    tool.bind(\n        toggle_callback=toggle_cb,\n        plan_file_path_getter=lambda: plan_path,\n        plan_mode_checker=lambda: True,\n    )\n    return tool, toggle_cb, plan_path\n\n\ndef _mock_wire_and_tool_call(monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Monkeypatch wire and tool call context for plan mode tool tests.\"\"\"\n    wire_mock = MagicMock()\n    # Patch both the local imports in tools AND the central wire_send function\n    monkeypatch.setattr(\"kimi_cli.tools.plan.get_wire_or_none\", lambda: wire_mock)\n    monkeypatch.setattr(\"kimi_cli.tools.plan.wire_send\", lambda msg: None)\n    monkeypatch.setattr(\"kimi_cli.tools.plan.enter.get_wire_or_none\", lambda: wire_mock)\n    monkeypatch.setattr(\"kimi_cli.tools.plan.enter.wire_send\", lambda msg: None)\n    tc = ToolCall(id=\"test-tc\", function=ToolCall.FunctionBody(name=\"ExitPlanMode\", arguments=None))\n    monkeypatch.setattr(\"kimi_cli.tools.plan.get_current_tool_call_or_none\", lambda: tc)\n    monkeypatch.setattr(\"kimi_cli.tools.plan.enter.get_current_tool_call_or_none\", lambda: tc)\n    return wire_mock\n\n\nclass TestExitPlanModeHappyPaths:\n    async def test_approve_toggles_off_and_returns_plan(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, toggle_cb, plan_path = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Approve\"}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        output = _tool_output_text(result)\n        assert \"Plan approved\" in output\n        assert \"# My Plan\" in output\n        toggle_cb.assert_awaited_once()\n\n    async def test_reject_returns_tool_rejected(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, toggle_cb, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Reject\"}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolRejectedError)\n        toggle_cb.assert_not_awaited()\n\n    async def test_revise_with_feedback(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, _, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(\n            QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Fix the database section\"})\n        )\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        output = _tool_output_text(result)\n        assert \"revision\" in output.lower() or \"revise\" in output.lower()\n        assert \"Fix the database section\" in output\n\n    async def test_revise_without_feedback(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, _, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"\"}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"User feedback:\" not in _tool_output_text(result)\n\n    async def test_dismissed_returns_continue(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, toggle_cb, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"dismissed\" in _tool_output_text(result).lower()\n        toggle_cb.assert_not_awaited()\n\n    async def test_question_not_supported(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, _, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(side_effect=QuestionNotSupported()))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Client unsupported\" in result.brief\n\n    async def test_question_exception(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, _, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(side_effect=RuntimeError(\"boom\")))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Failed\" in result.message or \"failed\" in result.message\n\n    async def test_wire_unavailable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        tool, _, _ = _setup_exit_tool(tmp_path)\n        monkeypatch.setattr(\"kimi_cli.tools.plan.get_wire_or_none\", lambda: None)\n        tc = ToolCall(id=\"t\", function=ToolCall.FunctionBody(name=\"ExitPlanMode\", arguments=None))\n        monkeypatch.setattr(\"kimi_cli.tools.plan.get_current_tool_call_or_none\", lambda: tc)\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Wire\" in result.message or \"unavailable\" in result.message.lower()\n\n\n# ---------------------------------------------------------------------------\n# EnterPlanMode — happy paths\n# ---------------------------------------------------------------------------\n\n\ndef _setup_enter_tool(tmp_path: Path) -> tuple[EnterPlanMode, AsyncMock, Path]:\n    \"\"\"Create and bind an EnterPlanMode tool.\"\"\"\n    tool = EnterPlanMode()\n    plan_path = tmp_path / \"plans\" / \"test-plan.md\"\n    toggle_cb = AsyncMock(return_value=True)\n    tool.bind(\n        toggle_callback=toggle_cb,\n        plan_file_path_getter=lambda: plan_path,\n        plan_mode_checker=lambda: False,\n    )\n    return tool, toggle_cb, plan_path\n\n\nclass TestEnterPlanModeHappyPaths:\n    async def test_user_accepts(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        tool, toggle_cb, plan_path = _setup_enter_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Yes\"}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        output = _tool_output_text(result)\n        assert \"Plan mode activated\" in output or \"plan mode\" in output.lower()\n        assert str(plan_path) in output\n        assert \"StrReplaceFile\" in output\n        assert \"clarify missing requirements\" in output\n        toggle_cb.assert_awaited_once()\n\n    async def test_user_declines(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        tool, toggle_cb, _ = _setup_enter_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"No\"}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"declined\" in _tool_output_text(result).lower()\n        toggle_cb.assert_not_awaited()\n\n    async def test_dismissed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        tool, toggle_cb, _ = _setup_enter_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"dismissed\" in _tool_output_text(result).lower()\n        toggle_cb.assert_not_awaited()\n\n    async def test_question_not_supported(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, _, _ = _setup_enter_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(side_effect=QuestionNotSupported()))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Client unsupported\" in result.brief\n\n    async def test_wire_unavailable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        tool, _, _ = _setup_enter_tool(tmp_path)\n        monkeypatch.setattr(\"kimi_cli.tools.plan.enter.get_wire_or_none\", lambda: None)\n        tc = ToolCall(id=\"t\", function=ToolCall.FunctionBody(name=\"EnterPlanMode\", arguments=None))\n        monkeypatch.setattr(\"kimi_cli.tools.plan.enter.get_current_tool_call_or_none\", lambda: tc)\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolError)\n        assert \"Wire\" in result.message or \"unavailable\" in result.message.lower()\n\n\n# ---------------------------------------------------------------------------\n# KimiSoul — plan mode state management\n# ---------------------------------------------------------------------------\n\n\nclass TestKimiSoulPlanState:\n    async def test_session_id_allocated_on_activation(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._set_plan_mode(True, source=\"tool\")\n        assert soul._plan_session_id is not None\n\n    async def test_session_id_idempotent(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._ensure_plan_session_id()\n        first = soul._plan_session_id\n        soul._ensure_plan_session_id()\n        assert soul._plan_session_id == first\n\n    async def test_session_id_persists_after_deactivation(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._set_plan_mode(True, source=\"tool\")\n        soul._set_plan_mode(False, source=\"tool\")\n        assert soul._plan_session_id is None\n\n    def test_plan_file_path_none_before_activation(self, runtime: Runtime, tmp_path: Path) -> None:\n        soul = _make_soul(runtime, tmp_path)\n        assert soul.get_plan_file_path() is None\n\n    async def test_plan_file_path_valid_after_activation(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._set_plan_mode(True, source=\"tool\")\n        path = soul.get_plan_file_path()\n        assert path is not None\n        assert path.suffix == \".md\"\n\n    async def test_read_current_plan_none_no_file(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._set_plan_mode(True, source=\"tool\")\n        assert soul.read_current_plan() is None\n\n    async def test_read_current_plan_returns_content(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._set_plan_mode(True, source=\"tool\")\n        path = soul.get_plan_file_path()\n        assert path is not None\n        path.parent.mkdir(parents=True, exist_ok=True)\n        path.write_text(\"# Test Plan\", encoding=\"utf-8\")\n        assert soul.read_current_plan() == \"# Test Plan\"\n\n    async def test_clear_current_plan_deletes_file(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._set_plan_mode(True, source=\"tool\")\n        path = soul.get_plan_file_path()\n        assert path is not None\n        path.parent.mkdir(parents=True, exist_ok=True)\n        path.write_text(\"# Plan\", encoding=\"utf-8\")\n        soul.clear_current_plan()\n        assert not path.exists()\n\n    async def test_clear_current_plan_noop_no_file(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        soul._set_plan_mode(True, source=\"tool\")\n        soul.clear_current_plan()  # should not raise\n\n    async def test_status_includes_plan_mode(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n\n        assert soul.status.plan_mode is False\n        soul._set_plan_mode(True, source=\"tool\")\n        assert soul.status.plan_mode is True\n        soul._set_plan_mode(False, source=\"tool\")\n        assert soul.status.plan_mode is False\n\n    def test_bind_plan_mode_tools_binds_callbacks(self, runtime: Runtime, tmp_path: Path) -> None:\n        toolset = KimiToolset()\n        write_tool = WriteFile(runtime, runtime.approval)\n        replace_tool = StrReplaceFile(runtime, runtime.approval)\n        toolset.add(write_tool)\n        toolset.add(replace_tool)\n\n        agent = Agent(\n            name=\"Test Agent\",\n            system_prompt=\"Test system prompt.\",\n            toolset=toolset,\n            runtime=runtime,\n        )\n        soul = KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n        assert write_tool._plan_mode_checker is not None\n        assert write_tool._plan_file_path_getter is not None\n        assert replace_tool._plan_mode_checker is not None\n        assert replace_tool._plan_file_path_getter is not None\n\n        soul._set_plan_mode(True, source=\"tool\")\n        plan_path = soul.get_plan_file_path()\n        assert plan_path is not None\n        assert write_tool._plan_mode_checker() is True\n        assert replace_tool._plan_mode_checker() is True\n        assert write_tool._plan_file_path_getter() == plan_path\n        assert replace_tool._plan_file_path_getter() == plan_path\n\n\n# ---------------------------------------------------------------------------\n# KimiSoul — plan_session_id cross-process persistence\n# ---------------------------------------------------------------------------\n\n\nclass TestKimiSoulPlanSessionPersistence:\n    \"\"\"Tests that plan_session_id survives simulated process restarts.\"\"\"\n\n    async def test_plan_session_id_survives_restart(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        \"\"\"Second KimiSoul created from same session state gets same plan file path.\"\"\"\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul1 = _make_soul(runtime, tmp_path)\n        soul1._set_plan_mode(True, source=\"tool\")\n        path1 = soul1.get_plan_file_path()\n        assert path1 is not None\n\n        # Simulate restart: clear in-process slug cache (as would happen in a new process)\n        _slug_cache.clear()\n\n        # New KimiSoul reads from same (already-saved) session state and re-seeds cache\n        soul2 = _make_soul(runtime, tmp_path)\n        path2 = soul2.get_plan_file_path()\n        assert path2 == path1\n\n    async def test_plan_session_id_cleared_after_deactivation(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        \"\"\"After plan mode is turned off and process restarts, new activation gets fresh path.\"\"\"\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul1 = _make_soul(runtime, tmp_path)\n        soul1._set_plan_mode(True, source=\"tool\")\n        path1 = soul1.get_plan_file_path()\n        soul1._set_plan_mode(False, source=\"tool\")\n        # state.plan_session_id is now None in persisted state\n\n        # Restart + re-activate\n        from kimi_cli.tools.plan.heroes import _slug_cache\n\n        _slug_cache.clear()  # simulate fresh process (in-memory cache gone)\n        soul2 = _make_soul(runtime, tmp_path)\n        soul2._set_plan_mode(True, source=\"tool\")\n        path2 = soul2.get_plan_file_path()\n        assert path2 != path1  # fresh slug, different path\n\n\n# ---------------------------------------------------------------------------\n# ToolRejectedError — enhanced constructor\n# ---------------------------------------------------------------------------\n\n\nclass TestToolRejectedError:\n    def test_default_message(self) -> None:\n        err = ToolRejectedError()\n        assert \"rejected\" in err.message.lower()\n        assert err.brief == \"Rejected by user\"\n\n    def test_custom_message_and_brief(self) -> None:\n        err = ToolRejectedError(message=\"Plan rejected\", brief=\"Rejected\")\n        assert err.message == \"Plan rejected\"\n        assert err.brief == \"Rejected\"\n\n    def test_custom_message_default_brief(self) -> None:\n        err = ToolRejectedError(message=\"Custom rejection\")\n        assert err.message == \"Custom rejection\"\n        assert err.brief == \"Rejected by user\"\n\n\n# ---------------------------------------------------------------------------\n# ExitPlanMode — multi-option selection\n# ---------------------------------------------------------------------------\n\n\nclass TestExitPlanModeMultiOption:\n    \"\"\"Tests for ExitPlanMode with the `options` parameter.\"\"\"\n\n    def _make_params_with_options(self) -> Params:\n        return Params(\n            options=[\n                PlanOption(label=\"Option A (Recommended)\", description=\"Add new method\"),\n                PlanOption(label=\"Option B\", description=\"Modify call site\"),\n            ]\n        )\n\n    async def test_select_option_approves_plan(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, toggle_cb, plan_path = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(\n            QuestionRequest,\n            \"wait\",\n            AsyncMock(return_value={\"q\": \"Option A (Recommended)\"}),\n        )\n\n        params = self._make_params_with_options()\n        result = await tool(params)\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        output = _tool_output_text(result)\n        assert \"Option A (Recommended)\" in output\n        assert \"Selected approach\" in output\n        assert \"# My Plan\" in output\n        toggle_cb.assert_awaited_once()\n\n    async def test_select_second_option(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, toggle_cb, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Option B\"}))\n\n        params = self._make_params_with_options()\n        result = await tool(params)\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        output = _tool_output_text(result)\n        assert \"Option B\" in output\n        assert \"Selected approach\" in output\n        toggle_cb.assert_awaited_once()\n\n    async def test_reject_with_options(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, toggle_cb, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Reject\"}))\n\n        params = self._make_params_with_options()\n        result = await tool(params)\n        assert isinstance(result, ToolRejectedError)\n        toggle_cb.assert_not_awaited()\n\n    async def test_revise_with_options(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, _, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(\n            QuestionRequest,\n            \"wait\",\n            AsyncMock(return_value={\"q\": \"I want a third approach using decorator\"}),\n        )\n\n        params = self._make_params_with_options()\n        result = await tool(params)\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        output = _tool_output_text(result)\n        assert \"revision\" in output.lower() or \"revise\" in output.lower()\n        assert \"decorator\" in output\n\n    async def test_dismissed_with_options(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        tool, toggle_cb, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={}))\n\n        params = self._make_params_with_options()\n        result = await tool(params)\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"dismissed\" in _tool_output_text(result).lower()\n        toggle_cb.assert_not_awaited()\n\n    async def test_no_options_falls_back_to_approve_reject(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        \"\"\"When options=None, behaves like the original Approve/Reject flow.\"\"\"\n        tool, toggle_cb, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Approve\"}))\n\n        result = await tool(tool.params())\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"Plan approved\" in _tool_output_text(result)\n        toggle_cb.assert_awaited_once()\n\n    async def test_single_option_falls_back_to_approve_reject(\n        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        \"\"\"When only 1 option is provided (<2), falls back to Approve/Reject.\"\"\"\n        tool, toggle_cb, _ = _setup_exit_tool(tmp_path)\n        _mock_wire_and_tool_call(monkeypatch)\n        monkeypatch.setattr(QuestionRequest, \"wait\", AsyncMock(return_value={\"q\": \"Approve\"}))\n\n        params = Params(options=[PlanOption(label=\"Only one\", description=\"...\")])\n        result = await tool(params)\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"Plan approved\" in _tool_output_text(result)\n        toggle_cb.assert_awaited_once()\n\n\n# ---------------------------------------------------------------------------\n# PlanOption validator — reserved labels and Params max_length\n# ---------------------------------------------------------------------------\n\n\nclass TestPlanOptionValidator:\n    \"\"\"Tests for PlanOption label validation and Params.options max_length.\"\"\"\n\n    def test_reserved_label_reject_raises(self) -> None:\n        with pytest.raises(ValidationError):\n            PlanOption(label=\"Reject\", description=\"x\")\n\n    def test_reserved_label_reject_lowercase_raises(self) -> None:\n        with pytest.raises(ValidationError):\n            PlanOption(label=\"reject\", description=\"x\")\n\n    def test_reserved_label_revise_raises(self) -> None:\n        with pytest.raises(ValidationError):\n            PlanOption(label=\"Revise\", description=\"x\")\n\n    def test_reserved_label_approve_raises(self) -> None:\n        with pytest.raises(ValidationError):\n            PlanOption(label=\"Approve\", description=\"x\")\n\n    def test_reserved_label_with_whitespace_raises(self) -> None:\n        with pytest.raises(ValidationError):\n            PlanOption(label=\"  Reject  \", description=\"x\")\n\n    def test_non_reserved_label_ok(self) -> None:\n        opt = PlanOption(label=\"Option A\", description=\"x\")\n        assert opt.label == \"Option A\"\n\n    def test_params_max_four_options_raises(self) -> None:\n        \"\"\"Params.options has max_length=3; passing 4 must raise ValidationError.\"\"\"\n        with pytest.raises(ValidationError):\n            Params(\n                options=[\n                    PlanOption(label=\"A\", description=\"\"),\n                    PlanOption(label=\"B\", description=\"\"),\n                    PlanOption(label=\"C\", description=\"\"),\n                    PlanOption(label=\"D\", description=\"\"),\n                ]\n            )\n\n    def test_params_duplicate_labels_raises(self) -> None:\n        \"\"\"Duplicate option labels must raise ValidationError.\"\"\"\n        with pytest.raises(ValidationError):\n            Params(\n                options=[\n                    PlanOption(label=\"Patch config\", description=\"\"),\n                    PlanOption(label=\"Patch config\", description=\"different desc\"),\n                ]\n            )\n\n    def test_params_three_options_ok(self) -> None:\n        params = Params(\n            options=[\n                PlanOption(label=\"A\", description=\"\"),\n                PlanOption(label=\"B\", description=\"\"),\n                PlanOption(label=\"C\", description=\"\"),\n            ]\n        )\n        assert params.options is not None\n        assert len(params.options) == 3\n"
  },
  {
    "path": "tests/core/test_plan_mode_injection_provider.py",
    "content": "\"\"\"Tests for PlanModeInjectionProvider.get_injections() flow.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, PropertyMock\n\nfrom kosong.message import Message, TextPart\n\nfrom kimi_cli.soul.dynamic_injections.plan_mode import PlanModeInjectionProvider, _full_reminder\n\n\ndef _make_soul_mock(\n    plan_mode: bool = True,\n    plan_path: Path | None = None,\n    consume_pending: bool = False,\n) -> MagicMock:\n    soul = MagicMock()\n    type(soul).plan_mode = PropertyMock(return_value=plan_mode)\n    soul.get_plan_file_path.return_value = plan_path\n    soul.consume_pending_plan_activation_injection.return_value = consume_pending\n    return soul\n\n\ndef _reminder_msg() -> Message:\n    \"\"\"Create a user message that looks like a plan mode reminder.\"\"\"\n    return Message(\n        role=\"user\",\n        content=[TextPart(text=_full_reminder(\"/tmp/plan.md\", False))],\n    )\n\n\ndef _assistant_msg() -> Message:\n    return Message(role=\"assistant\", content=[TextPart(text=\"step\")])\n\n\nclass TestPlanModeInjectionProvider:\n    async def test_returns_empty_when_inactive(self) -> None:\n        provider = PlanModeInjectionProvider()\n        provider._inject_count = 5\n        soul = _make_soul_mock(plan_mode=False)\n\n        result = await provider.get_injections([], soul)\n\n        assert result == []\n        assert provider._inject_count == 0\n\n    async def test_first_call_injects_full_reminder(self) -> None:\n        provider = PlanModeInjectionProvider()\n        soul = _make_soul_mock(plan_mode=True, plan_path=Path(\"/tmp/plan.md\"))\n\n        result = await provider.get_injections([], soul)\n\n        assert len(result) == 1\n        assert result[0].type == \"plan_mode\"\n        assert \"Plan mode is active\" in result[0].content\n        assert provider._inject_count == 1\n\n    async def test_throttled_before_interval(self) -> None:\n        provider = PlanModeInjectionProvider()\n        soul = _make_soul_mock(plan_mode=True, plan_path=Path(\"/tmp/plan.md\"))\n\n        # History: reminder + 3 assistant turns (< 5 threshold)\n        history = [_reminder_msg()] + [_assistant_msg() for _ in range(3)]\n\n        result = await provider.get_injections(history, soul)\n        assert result == []\n\n    async def test_injects_after_interval_reached(self) -> None:\n        provider = PlanModeInjectionProvider()\n        soul = _make_soul_mock(plan_mode=True, plan_path=Path(\"/tmp/plan.md\"))\n\n        # History: reminder + 5 assistant turns (= threshold)\n        history = [_reminder_msg()] + [_assistant_msg() for _ in range(5)]\n\n        result = await provider.get_injections(history, soul)\n        assert len(result) == 1\n\n    async def test_sparse_on_non_full_cycle(self) -> None:\n        provider = PlanModeInjectionProvider()\n        # _inject_count=1 → after increment becomes 2 → 2 % 5 != 1 → sparse\n        provider._inject_count = 1\n        soul = _make_soul_mock(plan_mode=True, plan_path=Path(\"/tmp/plan.md\"))\n\n        history = [_reminder_msg()] + [_assistant_msg() for _ in range(5)]\n\n        result = await provider.get_injections(history, soul)\n        assert len(result) == 1\n        assert \"still active\" in result[0].content\n\n    async def test_full_on_every_5th_cycle(self) -> None:\n        provider = PlanModeInjectionProvider()\n        # _inject_count=5 → after increment becomes 6 → 6 % 5 == 1 → full\n        provider._inject_count = 5\n        soul = _make_soul_mock(plan_mode=True, plan_path=Path(\"/tmp/plan.md\"))\n\n        history = [_reminder_msg()] + [_assistant_msg() for _ in range(5)]\n\n        result = await provider.get_injections(history, soul)\n        assert len(result) == 1\n        assert \"Plan mode is active\" in result[0].content\n\n    async def test_pending_activation_returns_full(self) -> None:\n        provider = PlanModeInjectionProvider()\n        soul = _make_soul_mock(plan_mode=True, plan_path=Path(\"/tmp/plan.md\"), consume_pending=True)\n\n        result = await provider.get_injections([], soul)\n        assert len(result) == 1\n        assert result[0].type == \"plan_mode\"\n        assert \"Plan mode is active\" in result[0].content\n        assert provider._inject_count == 1\n\n    async def test_pending_activation_with_plan_returns_reentry(self, tmp_path: Path) -> None:\n        provider = PlanModeInjectionProvider()\n        plan_path = tmp_path / \"existing-plan.md\"\n        plan_path.write_text(\"# Existing plan\", encoding=\"utf-8\")\n        soul = _make_soul_mock(plan_mode=True, plan_path=plan_path, consume_pending=True)\n\n        result = await provider.get_injections([], soul)\n        assert len(result) == 1\n        assert result[0].type == \"plan_mode_reentry\"\n        assert \"Re-entering Plan Mode\" in result[0].content\n\n    async def test_resets_count_when_deactivated(self) -> None:\n        provider = PlanModeInjectionProvider()\n        provider._inject_count = 10\n        soul = _make_soul_mock(plan_mode=False)\n\n        await provider.get_injections([], soul)\n        assert provider._inject_count == 0\n"
  },
  {
    "path": "tests/core/test_plan_mode_reminder.py",
    "content": "\"\"\"Tests for plan mode reminder detection in PlanModeInjectionProvider.\"\"\"\n\nfrom __future__ import annotations\n\nfrom kosong.message import Message, TextPart\n\nfrom kimi_cli.soul.dynamic_injections.plan_mode import (\n    _full_reminder,\n    _has_plan_reminder,\n    _reentry_reminder,\n    _sparse_reminder,\n)\n\n\ndef _user_msg(text: str) -> Message:\n    return Message(role=\"user\", content=[TextPart(text=text)])\n\n\ndef test_detects_sparse_reminder() -> None:\n    msg = _user_msg(f\"<system-reminder>\\n{_sparse_reminder()}\\n</system-reminder>\")\n    assert _has_plan_reminder(msg)\n\n\ndef test_detects_sparse_reminder_with_path() -> None:\n    msg = _user_msg(\n        f\"<system-reminder>\\n{_sparse_reminder('/home/user/.kimi/plans/iron-man.md')}\\n</system-reminder>\"\n    )\n    assert _has_plan_reminder(msg)\n\n\ndef test_detects_full_reminder_without_path() -> None:\n    msg = _user_msg(f\"<system-reminder>\\n{_full_reminder()}\\n</system-reminder>\")\n    assert _has_plan_reminder(msg)\n\n\ndef test_detects_full_reminder_with_path() -> None:\n    msg = _user_msg(\n        f\"<system-reminder>\\n{_full_reminder('/home/user/.kimi/plans/iron-man.md')}\\n</system-reminder>\"\n    )\n    assert _has_plan_reminder(msg)\n\n\ndef test_detects_full_reminder_with_existing_plan() -> None:\n    msg = _user_msg(\n        f\"<system-reminder>\\n{_full_reminder('/home/user/.kimi/plans/batman.md', plan_exists=True)}\\n</system-reminder>\"\n    )\n    assert _has_plan_reminder(msg)\n\n\ndef test_does_not_match_unrelated_text() -> None:\n    msg = _user_msg(\"Please review the plan and let me know.\")\n    assert not _has_plan_reminder(msg)\n\n\ndef test_does_not_match_assistant_message() -> None:\n    # _has_plan_reminder only checks content, but caller filters by role.\n    # Ensure the content check itself doesn't false-positive on similar text.\n    msg = _user_msg(\"I will plan mode the project carefully.\")\n    assert not _has_plan_reminder(msg)\n\n\ndef test_detection_stays_in_sync_with_reminder_text() -> None:\n    \"\"\"Ensure that the detection keys are derived from the actual reminder functions.\n\n    If someone changes the reminder wording, the detection must still work.\n    This test verifies the contract: any text produced by _full_reminder or\n    _sparse_reminder must be detectable by _has_plan_reminder.\n    \"\"\"\n    for path in [None, \"/tmp/plan.md\", \"/home/user/.kimi/plans/batman.md\"]:\n        for exists in [False, True]:\n            full = _full_reminder(path, plan_exists=exists)\n            assert _has_plan_reminder(_user_msg(full)), (\n                f\"Failed to detect _full_reminder(path={path!r}, plan_exists={exists})\"\n            )\n\n    for path in [None, \"/tmp/plan.md\"]:\n        sparse = _sparse_reminder(path)\n        assert _has_plan_reminder(_user_msg(sparse)), (\n            f\"Failed to detect _sparse_reminder(path={path!r})\"\n        )\n\n\n# --- Full Reminder content checks ---\n\n\ndef test_full_reminder_contains_only_exception() -> None:\n    \"\"\"Full reminder should clearly state plan file as the only exception.\"\"\"\n    text = _full_reminder(\"/tmp/plan.md\")\n    assert \"with the exception of the plan file\" in text\n    assert \"only file you are allowed to edit\" in text\n\n\ndef test_full_reminder_contains_turn_ending_constraint() -> None:\n    \"\"\"Full reminder should constrain how turns end.\"\"\"\n    text = _full_reminder(\"/tmp/plan.md\")\n    assert \"WriteFile\" in text\n    assert \"StrReplaceFile\" in text\n    assert \"clarifying missing requirements\" in text\n    assert \"AskUserQuestion\" in text\n    assert \"ExitPlanMode\" in text\n    assert \"Do NOT end your turn any other way\" in text\n\n\ndef test_full_reminder_contains_anti_pattern() -> None:\n    \"\"\"Full reminder should warn against asking about plan approval via AskUserQuestion.\"\"\"\n    text = _full_reminder()\n    assert \"user cannot see the plan until you call ExitPlanMode\" in text\n\n\n# --- Sparse Reminder content checks ---\n\n\ndef test_sparse_reminder_contains_turn_ending_constraint() -> None:\n    \"\"\"Sparse reminder should include turn ending instructions.\"\"\"\n    text = _sparse_reminder(\"/tmp/plan.md\")\n    assert \"WriteFile\" in text\n    assert \"StrReplaceFile\" in text\n    assert \"user preferences\" in text\n    assert \"AskUserQuestion\" in text\n    assert \"ExitPlanMode\" in text\n    assert \"Never ask about plan approval\" in text\n\n\ndef test_sparse_reminder_back_references_full() -> None:\n    \"\"\"Sparse reminder should reference the full instructions.\"\"\"\n    text = _sparse_reminder()\n    assert \"see full instructions earlier\" in text\n\n\n# --- Reentry Reminder ---\n\n\ndef test_reentry_reminder_contains_decision_tree() -> None:\n    \"\"\"Reentry reminder should include guidance on how to handle existing plan.\"\"\"\n    text = _reentry_reminder(\"/tmp/plan.md\")\n    assert \"Re-entering Plan Mode\" in text\n    assert \"different task\" in text.lower()\n    assert \"same task\" in text.lower()\n    assert \"Read the existing plan\" in text\n    assert \"WriteFile\" in text\n    assert \"StrReplaceFile\" in text\n    assert \"clarify missing requirements\" in text\n"
  },
  {
    "path": "tests/core/test_plan_slash.py",
    "content": "\"\"\"Tests for /plan slash command.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\nfrom kosong.tooling.empty import EmptyToolset\n\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.soul.slash import plan\nfrom kimi_cli.tools.plan.heroes import _slug_cache\nfrom kimi_cli.wire.types import TextPart\n\n\n@pytest.fixture(autouse=True)\ndef _clear_slug_cache():\n    _slug_cache.clear()\n    yield\n    _slug_cache.clear()\n\n\ndef _make_soul(runtime: Runtime, tmp_path: Path) -> KimiSoul:\n    agent = Agent(\n        name=\"Test Agent\",\n        system_prompt=\"Test system prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    return KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n\nasync def _run_plan(soul: KimiSoul, args: str) -> None:\n    result = plan(soul, args)\n    if result is not None:\n        await result\n\n\nclass TestPlanSlashCommand:\n    async def test_plan_on(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"on\")\n\n        assert soul.plan_mode is True\n        assert any(\"Plan mode ON\" in s.text for s in sent)\n\n    async def test_plan_on_idempotent(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"on\")\n        await _run_plan(soul, \"on\")\n\n        assert soul.plan_mode is True\n\n    async def test_plan_off(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"on\")\n        sent.clear()\n        await _run_plan(soul, \"off\")\n\n        assert soul.plan_mode is False\n        assert any(\"Plan mode OFF\" in s.text for s in sent)\n\n    async def test_plan_off_idempotent(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"off\")\n\n        assert soul.plan_mode is False\n        assert any(\"Plan mode OFF\" in s.text for s in sent)\n\n    async def test_plan_view_with_content(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"on\")\n        plan_path = soul.get_plan_file_path()\n        assert plan_path is not None\n        plan_path.parent.mkdir(parents=True, exist_ok=True)\n        plan_path.write_text(\"# My Plan\\nStep 1\", encoding=\"utf-8\")\n\n        sent.clear()\n        await _run_plan(soul, \"view\")\n\n        assert any(\"# My Plan\" in s.text for s in sent)\n\n    async def test_plan_view_no_content(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"on\")\n        sent.clear()\n        await _run_plan(soul, \"view\")\n\n        assert any(\"No plan file found\" in s.text for s in sent)\n\n    async def test_plan_clear(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"on\")\n        plan_path = soul.get_plan_file_path()\n        assert plan_path is not None\n        plan_path.parent.mkdir(parents=True, exist_ok=True)\n        plan_path.write_text(\"# Plan\", encoding=\"utf-8\")\n\n        sent.clear()\n        await _run_plan(soul, \"clear\")\n\n        assert not plan_path.exists()\n        assert any(\"Plan cleared\" in s.text for s in sent)\n\n    async def test_plan_toggle_on(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"\")\n\n        assert soul.plan_mode is True\n        assert any(\"Plan mode ON\" in s.text for s in sent)\n\n    async def test_plan_toggle_off(\n        self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        monkeypatch.setattr(\"kimi_cli.tools.plan.heroes.PLANS_DIR\", tmp_path)\n        soul = _make_soul(runtime, tmp_path)\n        sent: list[TextPart] = []\n        monkeypatch.setattr(\"kimi_cli.soul.slash.wire_send\", lambda msg: sent.append(msg))\n\n        await _run_plan(soul, \"on\")\n        sent.clear()\n        await _run_plan(soul, \"\")\n\n        assert soul.plan_mode is False\n        assert any(\"Plan mode OFF\" in s.text for s in sent)\n"
  },
  {
    "path": "tests/core/test_plugin.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nimport typer\n\nfrom kimi_cli.cli.plugin import _parse_git_url, _resolve_source\nfrom kimi_cli.plugin import (\n    PluginError,\n    PluginRuntime,\n    inject_config,\n    parse_plugin_json,\n    write_runtime,\n)\n\n\ndef _write_plugin(tmp_path: Path, plugin_data: dict) -> Path:\n    \"\"\"Write a plugin.json and return the plugin directory.\"\"\"\n    plugin_dir = tmp_path / plugin_data.get(\"name\", \"test-plugin\")\n    plugin_dir.mkdir(parents=True, exist_ok=True)\n    (plugin_dir / \"plugin.json\").write_text(json.dumps(plugin_data), encoding=\"utf-8\")\n    return plugin_dir\n\n\ndef test_parse_minimal_plugin_json(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"my-plugin\",\n            \"version\": \"1.0.0\",\n        },\n    )\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    assert spec.name == \"my-plugin\"\n    assert spec.version == \"1.0.0\"\n    assert spec.config_file is None\n    assert spec.inject == {}\n    assert spec.runtime is None\n\n\ndef test_parse_full_plugin_json(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"stock-assistant\",\n            \"version\": \"1.0.0\",\n            \"description\": \"Stock helper\",\n            \"config_file\": \"config/config.json\",\n            \"inject\": {\"kimicode.api_key\": \"api_key\"},\n        },\n    )\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    assert spec.name == \"stock-assistant\"\n    assert spec.config_file == \"config/config.json\"\n    assert spec.inject == {\"kimicode.api_key\": \"api_key\"}\n\n\ndef test_parse_plugin_json_missing_name(tmp_path: Path):\n    plugin_dir = tmp_path / \"bad\"\n    plugin_dir.mkdir()\n    (plugin_dir / \"plugin.json\").write_text('{\"version\": \"1.0.0\"}', encoding=\"utf-8\")\n    with pytest.raises(PluginError, match=\"name\"):\n        parse_plugin_json(plugin_dir / \"plugin.json\")\n\n\ndef test_parse_plugin_json_inject_requires_config_file(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"bad-plugin\",\n            \"version\": \"1.0.0\",\n            \"inject\": {\"some.key\": \"api_key\"},\n        },\n    )\n    with pytest.raises(PluginError, match=\"config_file\"):\n        parse_plugin_json(plugin_dir / \"plugin.json\")\n\n\ndef test_parse_plugin_json_with_runtime(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"installed-plugin\",\n            \"version\": \"1.0.0\",\n            \"runtime\": {\"host\": \"kimi-code\", \"host_version\": \"1.22.0\"},\n        },\n    )\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    assert spec.runtime is not None\n    assert spec.runtime.host == \"kimi-code\"\n    assert spec.runtime.host_version == \"1.22.0\"\n\n\ndef test_parse_plugin_json_missing_version(tmp_path: Path):\n    plugin_dir = tmp_path / \"bad\"\n    plugin_dir.mkdir()\n    (plugin_dir / \"plugin.json\").write_text('{\"name\": \"x\"}', encoding=\"utf-8\")\n    with pytest.raises(PluginError, match=\"version\"):\n        parse_plugin_json(plugin_dir / \"plugin.json\")\n\n\ndef test_parse_plugin_json_malformed(tmp_path: Path):\n    plugin_dir = tmp_path / \"bad\"\n    plugin_dir.mkdir()\n    (plugin_dir / \"plugin.json\").write_text(\"{not json}\", encoding=\"utf-8\")\n    with pytest.raises(PluginError, match=\"Failed to read\"):\n        parse_plugin_json(plugin_dir / \"plugin.json\")\n\n\ndef test_inject_config_writes_value(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n            \"config_file\": \"config/config.json\",\n            \"inject\": {\"kimicode.api_key\": \"api_key\"},\n        },\n    )\n    config_dir = plugin_dir / \"config\"\n    config_dir.mkdir()\n    (config_dir / \"config.json\").write_text(\n        json.dumps({\"kimicode\": {\"api_key\": \"PLACEHOLDER\", \"timeout\": 30}}),\n        encoding=\"utf-8\",\n    )\n\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    inject_config(plugin_dir, spec, {\"api_key\": \"sk-real-key\"})\n\n    result = json.loads((config_dir / \"config.json\").read_text())\n    assert result[\"kimicode\"][\"api_key\"] == \"sk-real-key\"\n    assert result[\"kimicode\"][\"timeout\"] == 30  # untouched\n\n\ndef test_inject_config_creates_nested_path(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n            \"config_file\": \"c.json\",\n            \"inject\": {\"a.b.c\": \"api_key\"},\n        },\n    )\n    (plugin_dir / \"c.json\").write_text(\"{}\", encoding=\"utf-8\")\n\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    inject_config(plugin_dir, spec, {\"api_key\": \"val\"})\n\n    result = json.loads((plugin_dir / \"c.json\").read_text())\n    assert result[\"a\"][\"b\"][\"c\"] == \"val\"\n\n\ndef test_inject_config_missing_key_raises(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n            \"config_file\": \"c.json\",\n            \"inject\": {\"x\": \"api_key\"},\n        },\n    )\n    (plugin_dir / \"c.json\").write_text(\"{}\", encoding=\"utf-8\")\n\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    with pytest.raises(PluginError, match=\"api_key\"):\n        inject_config(plugin_dir, spec, {})\n\n\ndef test_inject_config_missing_file_raises(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n            \"config_file\": \"missing.json\",\n            \"inject\": {\"x\": \"api_key\"},\n        },\n    )\n\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    with pytest.raises(PluginError, match=\"not found\"):\n        inject_config(plugin_dir, spec, {\"api_key\": \"v\"})\n\n\ndef test_write_runtime(tmp_path: Path):\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n        },\n    )\n\n    runtime = PluginRuntime(host=\"kimi-code\", host_version=\"1.22.0\")\n    write_runtime(plugin_dir, runtime)\n\n    data = json.loads((plugin_dir / \"plugin.json\").read_text())\n    assert data[\"runtime\"][\"host\"] == \"kimi-code\"\n    assert data[\"runtime\"][\"host_version\"] == \"1.22.0\"\n    assert data[\"name\"] == \"p\"  # original fields preserved\n\n\ndef test_inject_config_noop_when_no_inject(tmp_path: Path):\n    \"\"\"inject_config should be a no-op when spec has no inject mappings.\"\"\"\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n        },\n    )\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    # Should not raise, even with empty values\n    inject_config(plugin_dir, spec, {})\n\n\ndef test_inject_config_rejects_path_traversal(tmp_path: Path):\n    \"\"\"config_file with '..' should be rejected.\"\"\"\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n            \"config_file\": \"../../etc/passwd\",\n            \"inject\": {\"x\": \"api_key\"},\n        },\n    )\n    # Create the file so it exists (the guard should trigger before reading)\n    target = (plugin_dir / \"../../etc/passwd\").resolve()\n    target.parent.mkdir(parents=True, exist_ok=True)\n    target.write_text(\"{}\", encoding=\"utf-8\")\n\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    with pytest.raises(PluginError, match=\"escapes plugin directory\"):\n        inject_config(plugin_dir, spec, {\"api_key\": \"v\"})\n\n\ndef test_parse_plugin_json_with_tools(tmp_path: Path):\n    \"\"\"Tools should be parsed from plugin.json.\"\"\"\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"t\",\n            \"version\": \"1.0.0\",\n            \"tools\": [\n                {\n                    \"name\": \"my_tool\",\n                    \"description\": \"does stuff\",\n                    \"command\": [\"python3\", \"run.py\"],\n                    \"parameters\": {\"type\": \"object\", \"properties\": {}},\n                }\n            ],\n        },\n    )\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    assert len(spec.tools) == 1\n    assert spec.tools[0].name == \"my_tool\"\n    assert spec.tools[0].command == [\"python3\", \"run.py\"]\n\n\ndef test_parse_plugin_json_ignores_unknown_fields(tmp_path: Path):\n    \"\"\"Unknown fields should be silently ignored (forward compat).\"\"\"\n    plugin_dir = _write_plugin(\n        tmp_path,\n        {\n            \"name\": \"p\",\n            \"version\": \"1.0.0\",\n            \"future_field\": \"whatever\",\n        },\n    )\n    spec = parse_plugin_json(plugin_dir / \"plugin.json\")\n    assert spec.name == \"p\"\n\n\n# --- _parse_git_url tests ---\n\n\n@pytest.mark.parametrize(\n    \"url, expected_clone, expected_subpath, expected_branch\",\n    [\n        # .git URLs — no subpath\n        (\"https://host.com/org/repo.git\", \"https://host.com/org/repo.git\", None, None),\n        (\"http://host.com/org/repo.git\", \"http://host.com/org/repo.git\", None, None),\n        # .git URLs — with subpath\n        (\n            \"https://host.com/org/repo.git/my-plugin\",\n            \"https://host.com/org/repo.git\",\n            \"my-plugin\",\n            None,\n        ),\n        (\n            \"https://host.com/org/repo.git/packages/my-plugin\",\n            \"https://host.com/org/repo.git\",\n            \"packages/my-plugin\",\n            None,\n        ),\n        # .git URLs — trailing slash (no subpath)\n        (\"https://host.com/org/repo.git/\", \"https://host.com/org/repo.git\", None, None),\n        # SSH URLs\n        (\"git@github.com:org/repo.git\", \"git@github.com:org/repo.git\", None, None),\n        (\n            \"git@github.com:org/repo.git/my-plugin\",\n            \"git@github.com:org/repo.git\",\n            \"my-plugin\",\n            None,\n        ),\n        # .github in hostname should not false-match\n        (\n            \"https://github.com/my.github.io/tools.git/plugin\",\n            \"https://github.com/my.github.io/tools.git\",\n            \"plugin\",\n            None,\n        ),\n        # GitHub short URLs — no subpath\n        (\"https://github.com/org/repo\", \"https://github.com/org/repo\", None, None),\n        # GitHub short URLs — with subpath\n        (\n            \"https://github.com/org/repo/my-plugin\",\n            \"https://github.com/org/repo\",\n            \"my-plugin\",\n            None,\n        ),\n        (\n            \"https://github.com/org/repo/packages/my-plugin\",\n            \"https://github.com/org/repo\",\n            \"packages/my-plugin\",\n            None,\n        ),\n        # GitHub short URLs — trailing slash\n        (\"https://github.com/org/repo/\", \"https://github.com/org/repo\", None, None),\n        # GitHub browser URL with tree/branch — extracts branch\n        (\n            \"https://github.com/org/repo/tree/main/my-plugin\",\n            \"https://github.com/org/repo\",\n            \"my-plugin\",\n            \"main\",\n        ),\n        (\n            \"https://github.com/org/repo/tree/develop/packages/my-plugin\",\n            \"https://github.com/org/repo\",\n            \"packages/my-plugin\",\n            \"develop\",\n        ),\n        # GitLab short URLs\n        (\n            \"https://gitlab.com/org/repo/my-plugin\",\n            \"https://gitlab.com/org/repo\",\n            \"my-plugin\",\n            None,\n        ),\n        (\n            \"https://gitlab.com/org/repo/tree/main/my-plugin\",\n            \"https://gitlab.com/org/repo\",\n            \"my-plugin\",\n            \"main\",\n        ),\n        # GitLab /-/tree/ format\n        (\n            \"https://gitlab.com/org/repo/-/tree/main/my-plugin\",\n            \"https://gitlab.com/org/repo\",\n            \"my-plugin\",\n            \"main\",\n        ),\n        # Edge case: fewer than 2 path segments — returned as-is\n        (\"https://github.com/org\", \"https://github.com/org\", None, None),\n    ],\n)\ndef test_parse_git_url(\n    url: str,\n    expected_clone: str,\n    expected_subpath: str | None,\n    expected_branch: str | None,\n):\n    clone_url, subpath, branch = _parse_git_url(url)\n    assert clone_url == expected_clone\n    assert subpath == expected_subpath\n    assert branch == expected_branch\n\n\n# --- _resolve_source git subpath tests ---\n\n\ndef _mock_git_clone(plugins: list[str] | None = None, root_plugin: bool = False):\n    \"\"\"Create a mock for subprocess.run that simulates git clone.\"\"\"\n\n    def side_effect(cmd, **kwargs):\n        dest = Path(cmd[-1])\n        dest.mkdir(parents=True)\n        if root_plugin:\n            (dest / \"plugin.json\").write_text(\n                json.dumps({\"name\": \"root-plugin\", \"version\": \"1.0.0\"}),\n                encoding=\"utf-8\",\n            )\n        for name in plugins or []:\n            sub = dest / name\n            sub.mkdir(parents=True, exist_ok=True)\n            (sub / \"plugin.json\").write_text(\n                json.dumps({\"name\": name, \"version\": \"1.0.0\"}),\n                encoding=\"utf-8\",\n            )\n        result = MagicMock()\n        result.returncode = 0\n        result.stderr = \"\"\n        return result\n\n    return side_effect\n\n\ndef test_resolve_source_git_with_subpath(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Git URL with subpath returns the sub-directory.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with patch(\n        \"subprocess.run\",\n        side_effect=_mock_git_clone(plugins=[\"my-plugin\"]),\n    ):\n        source, tmp_dir = _resolve_source(\"https://github.com/org/repo.git/my-plugin\")\n    assert source.name == \"my-plugin\"\n    assert (source / \"plugin.json\").exists()\n    assert tmp_dir is not None\n\n\ndef test_resolve_source_git_subpath_not_found(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Git URL with non-existent subpath raises Exit.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with (\n        patch(\"subprocess.run\", side_effect=_mock_git_clone(plugins=[])),\n        pytest.raises(typer.Exit),\n    ):\n        _resolve_source(\"https://github.com/org/repo.git/no-such-plugin\")\n\n\ndef test_resolve_source_git_no_subpath_suggests_plugins(\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n    capsys: pytest.CaptureFixture[str],\n):\n    \"\"\"No subpath + no root plugin.json -> list available plugins.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with (\n        patch(\n            \"subprocess.run\",\n            side_effect=_mock_git_clone(plugins=[\"alpha\", \"beta\"]),\n        ),\n        pytest.raises(typer.Exit),\n    ):\n        _resolve_source(\"https://github.com/org/repo.git\")\n    captured = capsys.readouterr()\n    assert \"alpha\" in captured.err\n    assert \"beta\" in captured.err\n\n\ndef test_resolve_source_git_no_subpath_root_plugin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"No subpath + root plugin.json -> returns root (existing behavior).\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with patch(\"subprocess.run\", side_effect=_mock_git_clone(root_plugin=True)):\n        source, _ = _resolve_source(\"https://github.com/org/repo.git\")\n    assert (source / \"plugin.json\").exists()\n\n\ndef test_resolve_source_git_subpath_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Subpath with '..' should be rejected.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with (\n        patch(\"subprocess.run\", side_effect=_mock_git_clone(plugins=[])),\n        pytest.raises(typer.Exit),\n    ):\n        _resolve_source(\"https://github.com/org/repo.git/../../etc\")\n\n\ndef test_resolve_source_git_no_subpath_no_plugins(\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n    capsys: pytest.CaptureFixture[str],\n):\n    \"\"\"No subpath + no root plugin.json + no sub-plugins -> plain error.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with (\n        patch(\"subprocess.run\", side_effect=_mock_git_clone(plugins=[])),\n        pytest.raises(typer.Exit),\n    ):\n        _resolve_source(\"https://github.com/org/repo.git\")\n    captured = capsys.readouterr()\n    assert \"No plugin.json found\" in captured.err\n\n\ndef test_resolve_source_git_short_url_with_subpath(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"GitHub short URL with subpath (no .git) returns the sub-directory.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with patch(\n        \"subprocess.run\",\n        side_effect=_mock_git_clone(plugins=[\"my-plugin\"]),\n    ):\n        source, _ = _resolve_source(\"https://github.com/org/repo/my-plugin\")\n    assert source.name == \"my-plugin\"\n    assert (source / \"plugin.json\").exists()\n\n\ndef test_resolve_source_git_tree_url_passes_branch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"tree/{branch}/ URL should pass --branch to git clone.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with patch(\n        \"subprocess.run\",\n        side_effect=_mock_git_clone(plugins=[\"my-plugin\"]),\n    ) as mock_run:\n        source, _ = _resolve_source(\"https://github.com/org/repo/tree/develop/my-plugin\")\n    # Verify --branch develop was passed to git clone\n    cmd = mock_run.call_args[0][0]\n    assert \"--branch\" in cmd\n    assert \"develop\" in cmd\n    assert source.name == \"my-plugin\"\n\n\ndef test_resolve_source_git_no_branch_omits_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Non-tree URL should not pass --branch to git clone.\"\"\"\n    monkeypatch.setattr(\"tempfile.mkdtemp\", lambda **kw: str(tmp_path / \"tmp\"))\n    (tmp_path / \"tmp\").mkdir()\n\n    with patch(\n        \"subprocess.run\",\n        side_effect=_mock_git_clone(plugins=[\"my-plugin\"]),\n    ) as mock_run:\n        _resolve_source(\"https://github.com/org/repo.git/my-plugin\")\n    cmd = mock_run.call_args[0][0]\n    assert \"--branch\" not in cmd\n"
  },
  {
    "path": "tests/core/test_plugin_manager.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\n\nimport pytest\nfrom pydantic import SecretStr\n\nfrom kimi_cli.plugin import PluginError\nfrom kimi_cli.plugin.manager import (\n    collect_host_values,\n    install_plugin,\n    list_plugins,\n    remove_plugin,\n)\n\n\ndef _make_source_plugin(tmp_path: Path, name: str = \"test-plugin\") -> Path:\n    \"\"\"Create a minimal valid plugin source directory.\"\"\"\n    src = tmp_path / \"source\" / name\n    src.mkdir(parents=True)\n    (src / \"plugin.json\").write_text(\n        json.dumps(\n            {\n                \"name\": name,\n                \"version\": \"1.0.0\",\n                \"config_file\": \"config/config.json\",\n                \"inject\": {\"app.api_key\": \"api_key\"},\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n    (src / \"SKILL.md\").write_text(\n        \"---\\nname: test-plugin\\ndescription: A test\\n---\\n# Test\",\n        encoding=\"utf-8\",\n    )\n    config_dir = src / \"config\"\n    config_dir.mkdir()\n    (config_dir / \"config.json\").write_text(\n        json.dumps({\"app\": {\"api_key\": \"PLACEHOLDER\"}}),\n        encoding=\"utf-8\",\n    )\n    return src\n\n\ndef test_install_plugin(tmp_path: Path):\n    plugins_dir = tmp_path / \"plugins\"\n    src = _make_source_plugin(tmp_path)\n\n    install_plugin(\n        source=src,\n        plugins_dir=plugins_dir,\n        host_values={\"api_key\": \"sk-real\"},\n        host_name=\"kimi-code\",\n        host_version=\"1.22.0\",\n    )\n\n    installed = plugins_dir / \"test-plugin\"\n    assert installed.is_dir()\n    assert (installed / \"SKILL.md\").exists()\n\n    # Check inject\n    config = json.loads((installed / \"config\" / \"config.json\").read_text())\n    assert config[\"app\"][\"api_key\"] == \"sk-real\"\n\n    # Check runtime in plugin.json\n    pj = json.loads((installed / \"plugin.json\").read_text())\n    assert pj[\"runtime\"][\"host\"] == \"kimi-code\"\n    assert pj[\"runtime\"][\"host_version\"] == \"1.22.0\"\n\n\ndef test_install_plugin_missing_plugin_json(tmp_path: Path):\n    src = tmp_path / \"source\" / \"bad\"\n    src.mkdir(parents=True)\n\n    with pytest.raises(PluginError, match=\"plugin.json\"):\n        install_plugin(\n            source=src,\n            plugins_dir=tmp_path / \"plugins\",\n            host_values={},\n            host_name=\"kimi-code\",\n            host_version=\"1.0.0\",\n        )\n\n\ndef test_install_plugin_rollback_on_failure(tmp_path: Path):\n    \"\"\"If inject fails (missing host key), installed dir should not remain.\"\"\"\n    plugins_dir = tmp_path / \"plugins\"\n    src = _make_source_plugin(tmp_path)\n\n    with pytest.raises(PluginError):\n        install_plugin(\n            source=src,\n            plugins_dir=plugins_dir,\n            host_values={},  # missing api_key\n            host_name=\"kimi-code\",\n            host_version=\"1.0.0\",\n        )\n\n    assert not (plugins_dir / \"test-plugin\").exists()\n\n\ndef test_reinstall_plugin(tmp_path: Path):\n    plugins_dir = tmp_path / \"plugins\"\n    src = _make_source_plugin(tmp_path)\n\n    install_plugin(\n        source=src,\n        plugins_dir=plugins_dir,\n        host_values={\"api_key\": \"sk-old\"},\n        host_name=\"kimi-code\",\n        host_version=\"1.20.0\",\n    )\n    install_plugin(\n        source=src,\n        plugins_dir=plugins_dir,\n        host_values={\"api_key\": \"sk-new\"},\n        host_name=\"kimi-code\",\n        host_version=\"1.22.0\",\n    )\n\n    config = json.loads((plugins_dir / \"test-plugin\" / \"config\" / \"config.json\").read_text())\n    assert config[\"app\"][\"api_key\"] == \"sk-new\"\n\n    pj = json.loads((plugins_dir / \"test-plugin\" / \"plugin.json\").read_text())\n    assert pj[\"runtime\"][\"host_version\"] == \"1.22.0\"\n\n\ndef test_list_plugins(tmp_path: Path):\n    plugins_dir = tmp_path / \"plugins\"\n    src = _make_source_plugin(tmp_path, \"alpha\")\n\n    install_plugin(\n        source=src,\n        plugins_dir=plugins_dir,\n        host_values={\"api_key\": \"k\"},\n        host_name=\"kimi-code\",\n        host_version=\"1.0.0\",\n    )\n\n    plugins = list_plugins(plugins_dir)\n    assert len(plugins) == 1\n    assert plugins[0].name == \"alpha\"\n\n\ndef test_list_plugins_empty(tmp_path: Path):\n    assert list_plugins(tmp_path / \"nonexistent\") == []\n\n\ndef test_remove_plugin(tmp_path: Path):\n    plugins_dir = tmp_path / \"plugins\"\n    src = _make_source_plugin(tmp_path)\n\n    install_plugin(\n        source=src,\n        plugins_dir=plugins_dir,\n        host_values={\"api_key\": \"k\"},\n        host_name=\"kimi-code\",\n        host_version=\"1.0.0\",\n    )\n    assert (plugins_dir / \"test-plugin\").exists()\n\n    remove_plugin(\"test-plugin\", plugins_dir)\n    assert not (plugins_dir / \"test-plugin\").exists()\n\n\ndef test_remove_nonexistent_plugin(tmp_path: Path):\n    with pytest.raises(PluginError, match=\"not found\"):\n        remove_plugin(\"ghost\", tmp_path / \"plugins\")\n\n\ndef test_install_rejects_path_traversal_name(tmp_path: Path):\n    \"\"\"Plugin name with '..' should be rejected.\"\"\"\n    src = tmp_path / \"source\" / \"evil\"\n    src.mkdir(parents=True)\n    (src / \"plugin.json\").write_text(\n        json.dumps({\"name\": \"../../escape\", \"version\": \"1.0.0\"}),\n        encoding=\"utf-8\",\n    )\n\n    with pytest.raises(PluginError, match=\"Invalid plugin name\"):\n        install_plugin(\n            source=src,\n            plugins_dir=tmp_path / \"plugins\",\n            host_values={},\n            host_name=\"kimi-code\",\n            host_version=\"1.0.0\",\n        )\n\n\n@pytest.mark.asyncio\nasync def test_skill_discovery_includes_plugins_dir(tmp_path: Path, monkeypatch):\n    \"\"\"Plugins dir should be included in skill discovery roots.\"\"\"\n    from kaos.path import KaosPath\n\n    from kimi_cli.skill import resolve_skills_roots\n\n    plugins_dir = tmp_path / \"plugins\"\n    plugins_dir.mkdir()\n\n    # Create a valid plugin with SKILL.md\n    plugin_dir = plugins_dir / \"my-plugin\"\n    plugin_dir.mkdir()\n    (plugin_dir / \"SKILL.md\").write_text(\n        \"---\\nname: my-plugin\\ndescription: test\\n---\\n# Test\",\n        encoding=\"utf-8\",\n    )\n    (plugin_dir / \"plugin.json\").write_text(\n        json.dumps({\"name\": \"my-plugin\", \"version\": \"1.0.0\"}),\n        encoding=\"utf-8\",\n    )\n\n    # Point KIMI_SHARE_DIR to tmp_path so get_plugins_dir() returns tmp_path/plugins\n    monkeypatch.setenv(\"KIMI_SHARE_DIR\", str(tmp_path))\n\n    roots = await resolve_skills_roots(KaosPath(str(tmp_path)))\n    root_strs = [str(r) for r in roots]\n    assert str(plugins_dir) in root_strs\n\n\n# --- collect_host_values tests ---\n\n\ndef _make_config(*, api_key: str = \"sk-test\", oauth: object = None):\n    \"\"\"Build a minimal mock Config with a default model and provider.\"\"\"\n    provider = MagicMock()\n    provider.api_key = SecretStr(api_key)\n    provider.oauth = oauth\n    provider.base_url = \"https://api.example.com/v1\"\n\n    model = MagicMock()\n    model.provider = \"test-provider\"\n\n    config = MagicMock()\n    config.default_model = \"test-model\"\n    config.models = {\"test-model\": model}\n    config.providers = {\"test-provider\": provider}\n    return config\n\n\ndef test_collect_host_values_static_key():\n    \"\"\"Static API key (no OAuth) is returned correctly.\"\"\"\n    config = _make_config(api_key=\"sk-static-key\")\n    oauth = MagicMock()\n    oauth.resolve_api_key.return_value = \"sk-static-key\"\n\n    values = collect_host_values(config, oauth)\n    assert values[\"api_key\"] == \"sk-static-key\"\n    assert values[\"base_url\"] == \"https://api.example.com/v1\"\n\n\ndef test_collect_host_values_oauth_token():\n    \"\"\"OAuth token is returned when provider has OAuth configured.\"\"\"\n    oauth_ref = MagicMock()\n    config = _make_config(api_key=\"\", oauth=oauth_ref)\n    oauth = MagicMock()\n    oauth.resolve_api_key.return_value = \"eyJ-oauth-token\"\n\n    values = collect_host_values(config, oauth)\n    assert values[\"api_key\"] == \"eyJ-oauth-token\"\n    oauth.resolve_api_key.assert_called_once()\n\n\ndef test_collect_host_values_no_default_model():\n    \"\"\"Returns empty dict when no default_model is configured.\"\"\"\n    config = MagicMock()\n    config.default_model = None\n    oauth = MagicMock()\n\n    values = collect_host_values(config, oauth)\n    assert values == {}\n\n\ndef test_collect_host_values_empty_key():\n    \"\"\"Empty API key is not included in values.\"\"\"\n    config = _make_config(api_key=\"\")\n    oauth = MagicMock()\n    oauth.resolve_api_key.return_value = \"\"\n\n    values = collect_host_values(config, oauth)\n    assert \"api_key\" not in values\n"
  },
  {
    "path": "tests/core/test_plugin_tool.py",
    "content": "from __future__ import annotations\n\nimport json\nimport sys\nfrom pathlib import Path\n\nimport pytest\n\nfrom kimi_cli.config import Config\nfrom kimi_cli.plugin import PluginToolSpec\nfrom kimi_cli.plugin.tool import PluginTool, load_plugin_tools\n\n\ndef _dummy_config() -> Config:\n    \"\"\"Create a minimal Config for testing.\"\"\"\n    return Config()\n\n\ndef _make_plugin_with_tool(tmp_path: Path, script_content: str) -> Path:\n    \"\"\"Create a plugin dir with a tool script.\"\"\"\n    plugin_dir = tmp_path / \"test-plugin\"\n    plugin_dir.mkdir()\n    scripts_dir = plugin_dir / \"scripts\"\n    scripts_dir.mkdir()\n    (scripts_dir / \"tool.py\").write_text(script_content, encoding=\"utf-8\")\n    (plugin_dir / \"plugin.json\").write_text(\n        json.dumps(\n            {\n                \"name\": \"test-plugin\",\n                \"version\": \"1.0.0\",\n                \"tools\": [\n                    {\n                        \"name\": \"test_tool\",\n                        \"description\": \"A test tool\",\n                        \"command\": [sys.executable, \"scripts/tool.py\"],\n                        \"parameters\": {\n                            \"type\": \"object\",\n                            \"properties\": {\"msg\": {\"type\": \"string\"}},\n                        },\n                    }\n                ],\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n    return plugin_dir\n\n\n@pytest.mark.asyncio\nasync def test_plugin_tool_executes_and_returns_stdout(tmp_path: Path):\n    plugin_dir = _make_plugin_with_tool(\n        tmp_path,\n        \"\"\"\nimport json, sys\nparams = json.loads(sys.stdin.read())\nprint(f\"hello {params.get('msg', 'world')}\")\n\"\"\",\n    )\n\n    tool_spec = PluginToolSpec(\n        name=\"test_tool\",\n        description=\"test\",\n        command=[sys.executable, \"scripts/tool.py\"],\n    )\n    tool = PluginTool(tool_spec, plugin_dir=plugin_dir, inject={}, config=_dummy_config())\n    result = await tool(msg=\"agent\")\n    assert \"hello agent\" in str(result)\n\n\n@pytest.mark.asyncio\nasync def test_plugin_tool_returns_error_on_nonzero_exit(tmp_path: Path):\n    plugin_dir = _make_plugin_with_tool(\n        tmp_path,\n        \"\"\"\nimport sys\nprint(\"something went wrong\", file=sys.stderr)\nsys.exit(1)\n\"\"\",\n    )\n\n    tool_spec = PluginToolSpec(\n        name=\"test_tool\",\n        description=\"test\",\n        command=[sys.executable, \"scripts/tool.py\"],\n    )\n    tool = PluginTool(tool_spec, plugin_dir=plugin_dir, inject={}, config=_dummy_config())\n    result = await tool()\n    assert \"failed\" in str(result).lower() or \"error\" in str(result).lower()\n\n\n@pytest.mark.asyncio\nasync def test_plugin_tool_empty_stdin(tmp_path: Path):\n    plugin_dir = _make_plugin_with_tool(\n        tmp_path,\n        \"\"\"\nimport json, sys\nparams = json.loads(sys.stdin.read()) if not sys.stdin.isatty() else {}\nprint(f\"mode={params.get('mode', 'default')}\")\n\"\"\",\n    )\n\n    tool_spec = PluginToolSpec(\n        name=\"test_tool\",\n        description=\"test\",\n        command=[sys.executable, \"scripts/tool.py\"],\n    )\n    tool = PluginTool(tool_spec, plugin_dir=plugin_dir, inject={}, config=_dummy_config())\n    result = await tool()\n    assert \"mode=default\" in str(result)\n\n\n@pytest.mark.asyncio\nasync def test_plugin_tool_injects_env_vars(tmp_path: Path):\n    \"\"\"Host credentials should be injected as env vars at runtime.\"\"\"\n    from pydantic import SecretStr\n\n    from kimi_cli.config import Config, LLMModel, LLMProvider\n\n    plugin_dir = _make_plugin_with_tool(\n        tmp_path,\n        \"\"\"\nimport os, json\nprint(json.dumps({\"key\": os.environ.get(\"myApiKey\", \"\"), \"url\": os.environ.get(\"myUrl\", \"\")}))\n\"\"\",\n    )\n\n    config = Config(\n        default_model=\"test\",\n        models={\"test\": LLMModel(provider=\"p\", model=\"m\", max_context_size=1000)},\n        providers={\n            \"p\": LLMProvider(\n                type=\"openai_responses\",\n                base_url=\"https://test.api/v1\",\n                api_key=SecretStr(\"sk-fresh-token\"),\n            )\n        },\n    )\n\n    tool_spec = PluginToolSpec(\n        name=\"test_tool\",\n        description=\"test\",\n        command=[sys.executable, \"scripts/tool.py\"],\n    )\n    tool = PluginTool(\n        tool_spec,\n        plugin_dir=plugin_dir,\n        inject={\"myApiKey\": \"api_key\", \"myUrl\": \"base_url\"},\n        config=config,\n    )\n    result = await tool()\n    data = json.loads(str(result.output))\n    assert data[\"key\"] == \"sk-fresh-token\"\n    assert data[\"url\"] == \"https://test.api/v1\"\n\n\ndef test_load_plugin_tools_discovers_tools(tmp_path: Path):\n    plugins_dir = tmp_path / \"plugins\"\n    plugin_dir = plugins_dir / \"my-plugin\"\n    plugin_dir.mkdir(parents=True)\n    (plugin_dir / \"plugin.json\").write_text(\n        json.dumps(\n            {\n                \"name\": \"my-plugin\",\n                \"version\": \"1.0.0\",\n                \"tools\": [\n                    {\n                        \"name\": \"my_tool\",\n                        \"description\": \"does things\",\n                        \"command\": [\"echo\", \"hi\"],\n                    }\n                ],\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n\n    tools = load_plugin_tools(plugins_dir, _dummy_config())\n    assert len(tools) == 1\n    assert tools[0].name == \"my_tool\"\n\n\ndef test_load_plugin_tools_empty_dir(tmp_path: Path):\n    assert load_plugin_tools(tmp_path / \"nonexistent\", _dummy_config()) == []\n\n\ndef test_load_plugin_tools_skips_plugins_without_tools(tmp_path: Path):\n    plugins_dir = tmp_path / \"plugins\"\n    plugin_dir = plugins_dir / \"no-tools\"\n    plugin_dir.mkdir(parents=True)\n    (plugin_dir / \"plugin.json\").write_text(\n        json.dumps({\"name\": \"no-tools\", \"version\": \"1.0.0\"}),\n        encoding=\"utf-8\",\n    )\n\n    tools = load_plugin_tools(plugins_dir, _dummy_config())\n    assert len(tools) == 0\n"
  },
  {
    "path": "tests/core/test_session.py",
    "content": "from __future__ import annotations\n\nimport json\nimport os\nimport time\nfrom pathlib import Path\n\nimport pytest\nfrom kaos.path import KaosPath\nfrom kosong.message import Message\n\nfrom kimi_cli.session import Session\nfrom kimi_cli.wire.file import WireFileMetadata, WireMessageRecord\nfrom kimi_cli.wire.protocol import WIRE_PROTOCOL_VERSION\nfrom kimi_cli.wire.types import TextPart, TurnBegin\n\n\n@pytest.fixture\ndef isolated_share_dir(monkeypatch, tmp_path: Path) -> Path:\n    \"\"\"Provide an isolated share directory for metadata operations.\"\"\"\n\n    share_dir = tmp_path / \"share\"\n    share_dir.mkdir()\n\n    def _get_share_dir() -> Path:\n        share_dir.mkdir(parents=True, exist_ok=True)\n        return share_dir\n\n    monkeypatch.setattr(\"kimi_cli.share.get_share_dir\", _get_share_dir)\n    monkeypatch.setattr(\"kimi_cli.metadata.get_share_dir\", _get_share_dir)\n    return share_dir\n\n\n@pytest.fixture\ndef work_dir(tmp_path: Path) -> KaosPath:\n    path = tmp_path / \"work\"\n    path.mkdir()\n    return KaosPath.unsafe_from_local_path(path)\n\n\ndef _write_wire_turn(session_dir: Path, text: str):\n    wire_file = session_dir / \"wire.jsonl\"\n    wire_file.parent.mkdir(parents=True, exist_ok=True)\n    metadata = WireFileMetadata(protocol_version=WIRE_PROTOCOL_VERSION)\n    record = WireMessageRecord.from_wire_message(\n        TurnBegin(user_input=[TextPart(text=text)]),\n        timestamp=time.time(),\n    )\n    with wire_file.open(\"w\", encoding=\"utf-8\") as f:\n        f.write(json.dumps(metadata.model_dump(mode=\"json\")) + \"\\n\")\n        f.write(json.dumps(record.model_dump(mode=\"json\")) + \"\\n\")\n\n\ndef _write_wire_metadata(session_dir: Path):\n    wire_file = session_dir / \"wire.jsonl\"\n    wire_file.parent.mkdir(parents=True, exist_ok=True)\n    metadata = WireFileMetadata(protocol_version=WIRE_PROTOCOL_VERSION)\n    wire_file.write_text(\n        json.dumps(metadata.model_dump(mode=\"json\")) + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n\ndef _write_context_message(context_file: Path, text: str):\n    context_file.parent.mkdir(parents=True, exist_ok=True)\n    message = Message(role=\"user\", content=[TextPart(text=text)])\n    context_file.write_text(message.model_dump_json(exclude_none=True) + \"\\n\", encoding=\"utf-8\")\n\n\ndef _write_context_records(context_file: Path, *records: dict[str, object]) -> None:\n    context_file.parent.mkdir(parents=True, exist_ok=True)\n    context_file.write_text(\n        \"\".join(json.dumps(record) + \"\\n\" for record in records),\n        encoding=\"utf-8\",\n    )\n\n\nasync def test_create_sets_fallback_title(isolated_share_dir: Path, work_dir: KaosPath):\n    session = await Session.create(work_dir)\n    assert session.title.startswith(\"Untitled (\")\n    assert session.context_file.exists()\n\n\nasync def test_find_uses_wire_title(isolated_share_dir: Path, work_dir: KaosPath):\n    session = await Session.create(work_dir)\n    _write_wire_turn(session.dir, \"hello world from wire file\")\n\n    found = await Session.find(work_dir, session.id)\n    assert found is not None\n    assert found.title.startswith(\"hello world from wire file\")\n\n\nasync def test_list_sorts_by_updated_and_titles(isolated_share_dir: Path, work_dir: KaosPath):\n    first = await Session.create(work_dir)\n    second = await Session.create(work_dir)\n\n    _write_context_message(first.context_file, \"old context message\")\n    _write_context_message(second.context_file, \"new context message\")\n    _write_wire_turn(first.dir, \"old session title\")\n    _write_wire_turn(second.dir, \"new session title that is slightly longer\")\n\n    # make sure ordering differs\n    now = time.time()\n    os.utime(first.context_file, (now - 10, now - 10))\n    os.utime(second.context_file, (now, now))\n    sessions = await Session.list(work_dir)\n\n    assert [s.id for s in sessions] == [second.id, first.id]\n    assert sessions[0].title.startswith(\"new session title\")\n    assert sessions[1].title.startswith(\"old session title\")\n\n\nasync def test_continue_without_last_returns_none(isolated_share_dir: Path, work_dir: KaosPath):\n    result = await Session.continue_(work_dir)\n    assert result is None\n\n\nasync def test_list_ignores_empty_sessions(isolated_share_dir: Path, work_dir: KaosPath):\n    empty = await Session.create(work_dir)\n    populated = await Session.create(work_dir)\n\n    _write_wire_metadata(empty.dir)\n    _write_context_message(populated.context_file, \"persisted user message\")\n    _write_wire_turn(populated.dir, \"populated session\")\n\n    sessions = await Session.list(work_dir)\n\n    assert [s.id for s in sessions] == [populated.id]\n    assert all(s.id != empty.id for s in sessions)\n\n\nasync def test_is_empty_ignores_context_metadata_only(\n    isolated_share_dir: Path, work_dir: KaosPath\n) -> None:\n    session = await Session.create(work_dir)\n\n    _write_context_records(\n        session.context_file,\n        {\"role\": \"_system_prompt\", \"content\": \"Persisted prompt\"},\n        {\"role\": \"_checkpoint\", \"id\": 0},\n        {\"role\": \"_usage\", \"token_count\": 42},\n    )\n\n    assert session.is_empty()\n\n\nasync def test_list_ignores_prompt_only_sessions(\n    isolated_share_dir: Path, work_dir: KaosPath\n) -> None:\n    prompt_only = await Session.create(work_dir)\n    populated = await Session.create(work_dir)\n\n    _write_context_records(\n        prompt_only.context_file,\n        {\"role\": \"_system_prompt\", \"content\": \"Persisted prompt\"},\n    )\n    _write_context_message(populated.context_file, \"persisted user message\")\n    _write_wire_turn(populated.dir, \"populated session\")\n\n    sessions = await Session.list(work_dir)\n\n    assert [s.id for s in sessions] == [populated.id]\n    assert all(s.id != prompt_only.id for s in sessions)\n\n\nasync def test_create_named_session(isolated_share_dir: Path, work_dir: KaosPath):\n    session_id = \"my-named-session\"\n    session = await Session.create(work_dir, session_id)\n    assert session.id == session_id\n    assert session.dir.name == session_id\n\n    # Verify we can find it\n    found = await Session.find(work_dir, session_id)\n    assert found is not None\n    assert found.id == session_id\n"
  },
  {
    "path": "tests/core/test_session_state.py",
    "content": "\"\"\"Tests for session state persistence.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nimport pytest\n\nfrom kimi_cli.session_state import (\n    ApprovalStateData,\n    DynamicSubagentSpec,\n    SessionState,\n    load_session_state,\n    save_session_state,\n)\n\n\n@pytest.fixture\ndef state_dir(tmp_path: Path) -> Path:\n    return tmp_path / \"session\"\n\n\nclass TestSessionState:\n    def test_default_state(self):\n        state = SessionState()\n        assert state.version == 1\n        assert state.approval.yolo is False\n        assert state.approval.auto_approve_actions == set()\n        assert state.dynamic_subagents == []\n\n    def test_save_and_load_roundtrip(self, state_dir: Path):\n        state_dir.mkdir(parents=True)\n        state = SessionState(\n            approval=ApprovalStateData(\n                yolo=True,\n                auto_approve_actions={\"Shell\", \"WriteFile\"},\n            ),\n            dynamic_subagents=[\n                DynamicSubagentSpec(name=\"researcher\", system_prompt=\"You are a researcher.\"),\n            ],\n        )\n        save_session_state(state, state_dir)\n\n        loaded = load_session_state(state_dir)\n        assert loaded.version == 1\n        assert loaded.approval.yolo is True\n        assert loaded.approval.auto_approve_actions == {\"Shell\", \"WriteFile\"}\n        assert len(loaded.dynamic_subagents) == 1\n        assert loaded.dynamic_subagents[0].name == \"researcher\"\n        assert loaded.dynamic_subagents[0].system_prompt == \"You are a researcher.\"\n\n    def test_load_missing_file_returns_default(self, state_dir: Path):\n        state_dir.mkdir(parents=True)\n        state = load_session_state(state_dir)\n        assert state == SessionState()\n\n    def test_load_missing_dir_returns_default(self, tmp_path: Path):\n        state = load_session_state(tmp_path / \"nonexistent\")\n        assert state == SessionState()\n\n    def test_save_creates_valid_json(self, state_dir: Path):\n        state_dir.mkdir(parents=True)\n        state = SessionState(\n            approval=ApprovalStateData(yolo=True, auto_approve_actions={\"Shell\"}),\n        )\n        save_session_state(state, state_dir)\n\n        state_file = state_dir / \"state.json\"\n        assert state_file.exists()\n        data = json.loads(state_file.read_text(encoding=\"utf-8\"))\n        assert data[\"version\"] == 1\n        assert data[\"approval\"][\"yolo\"] is True\n        assert set(data[\"approval\"][\"auto_approve_actions\"]) == {\"Shell\"}\n\n    def test_overwrite_existing_state(self, state_dir: Path):\n        state_dir.mkdir(parents=True)\n\n        state1 = SessionState(\n            approval=ApprovalStateData(yolo=False, auto_approve_actions={\"Shell\"}),\n        )\n        save_session_state(state1, state_dir)\n\n        state2 = SessionState(\n            approval=ApprovalStateData(yolo=True, auto_approve_actions={\"Shell\", \"WriteFile\"}),\n        )\n        save_session_state(state2, state_dir)\n\n        loaded = load_session_state(state_dir)\n        assert loaded.approval.yolo is True\n        assert loaded.approval.auto_approve_actions == {\"Shell\", \"WriteFile\"}\n\n    def test_multiple_dynamic_subagents(self, state_dir: Path):\n        state_dir.mkdir(parents=True)\n        state = SessionState(\n            dynamic_subagents=[\n                DynamicSubagentSpec(name=\"researcher\", system_prompt=\"Research things.\"),\n                DynamicSubagentSpec(name=\"coder\", system_prompt=\"Write code.\"),\n            ],\n        )\n        save_session_state(state, state_dir)\n\n        loaded = load_session_state(state_dir)\n        assert len(loaded.dynamic_subagents) == 2\n        assert loaded.dynamic_subagents[0].name == \"researcher\"\n        assert loaded.dynamic_subagents[1].name == \"coder\"\n\n    def test_load_truncated_json_returns_default(self, state_dir: Path):\n        \"\"\"Simulates a crash mid-write leaving a truncated JSON file.\"\"\"\n        state_dir.mkdir(parents=True)\n        state_file = state_dir / \"state.json\"\n        state_file.write_text('{\"version\": 1, \"approval\":', encoding=\"utf-8\")\n\n        state = load_session_state(state_dir)\n        assert state == SessionState()\n\n    def test_load_invalid_json_returns_default(self, state_dir: Path):\n        \"\"\"Completely invalid JSON content.\"\"\"\n        state_dir.mkdir(parents=True)\n        state_file = state_dir / \"state.json\"\n        state_file.write_text(\"not json at all\", encoding=\"utf-8\")\n\n        state = load_session_state(state_dir)\n        assert state == SessionState()\n\n    def test_load_invalid_schema_returns_default(self, state_dir: Path):\n        \"\"\"Valid JSON but invalid schema (e.g. wrong type for a field).\"\"\"\n        state_dir.mkdir(parents=True)\n        state_file = state_dir / \"state.json\"\n        state_file.write_text(\n            json.dumps({\"version\": \"not_an_int\", \"approval\": \"bad\"}),\n            encoding=\"utf-8\",\n        )\n\n        state = load_session_state(state_dir)\n        assert state == SessionState()\n\n    def test_load_empty_file_returns_default(self, state_dir: Path):\n        \"\"\"An empty state.json (e.g. process killed right after file creation).\"\"\"\n        state_dir.mkdir(parents=True)\n        state_file = state_dir / \"state.json\"\n        state_file.write_bytes(b\"\")\n\n        state = load_session_state(state_dir)\n        assert state == SessionState()\n\n    def test_load_binary_garbage_returns_default(self, state_dir: Path):\n        \"\"\"Binary corruption that isn't valid UTF-8.\"\"\"\n        state_dir.mkdir(parents=True)\n        state_file = state_dir / \"state.json\"\n        state_file.write_bytes(b\"\\x80\\xff\\xfe\\x00\\x01\")\n\n        state = load_session_state(state_dir)\n        assert state == SessionState()\n\n    def test_save_atomic_no_leftover_tmp(self, state_dir: Path):\n        \"\"\"After a successful save, no .tmp files should remain.\"\"\"\n        state_dir.mkdir(parents=True)\n        state = SessionState(approval=ApprovalStateData(yolo=True))\n        save_session_state(state, state_dir)\n\n        tmp_files = list(state_dir.glob(\"*.tmp\"))\n        assert tmp_files == []\n\n    def test_save_preserves_old_on_error(self, state_dir: Path, monkeypatch):\n        \"\"\"If writing fails, the original file should remain intact.\"\"\"\n        state_dir.mkdir(parents=True)\n        original = SessionState(approval=ApprovalStateData(yolo=True))\n        save_session_state(original, state_dir)\n\n        # Monkey-patch json.dump to raise mid-write\n        original_dump = json.dump\n\n        def bad_dump(*args, **kwargs):\n            original_dump(*args, **kwargs)\n            raise OSError(\"simulated disk error\")\n\n        monkeypatch.setattr(json, \"dump\", bad_dump)\n\n        with pytest.raises(OSError, match=\"simulated disk error\"):\n            save_session_state(SessionState(approval=ApprovalStateData(yolo=False)), state_dir)\n\n        # Restore and verify original data is intact\n        monkeypatch.undo()\n        loaded = load_session_state(state_dir)\n        assert loaded.approval.yolo is True\n\n        # No leftover tmp files\n        tmp_files = list(state_dir.glob(\"*.tmp\"))\n        assert tmp_files == []\n\n\nclass TestApprovalStateCallback:\n    def test_notify_change_called_on_set_yolo(self):\n        from kimi_cli.soul.approval import Approval, ApprovalState\n\n        changes: list[bool] = []\n\n        def on_change():\n            changes.append(True)\n\n        state = ApprovalState(on_change=on_change)\n        approval = Approval(state=state)\n\n        approval.set_yolo(True)\n        assert len(changes) == 1\n        assert state.yolo is True\n\n        approval.set_yolo(False)\n        assert len(changes) == 2\n        assert state.yolo is False\n\n    @pytest.mark.asyncio\n    async def test_notify_change_called_on_approve_for_session(self):\n        import asyncio\n\n        from kimi_cli.soul.approval import Approval, ApprovalState\n        from kimi_cli.soul.toolset import current_tool_call\n        from kimi_cli.wire.types import ToolCall\n\n        changes: list[bool] = []\n\n        def on_change():\n            changes.append(True)\n\n        state = ApprovalState(on_change=on_change)\n        approval = Approval(state=state)\n\n        # Set up tool call context\n        token = current_tool_call.set(\n            ToolCall(id=\"test\", function=ToolCall.FunctionBody(name=\"Shell\", arguments=None))\n        )\n        try:\n            # Start request in background\n            request_task = asyncio.create_task(\n                approval.request(sender=\"Shell\", action=\"shell_exec\", description=\"ls\")\n            )\n            # Wait for the request to be queued\n            request = await approval.fetch_request()\n            approval.resolve_request(request.id, \"approve_for_session\")\n            result = await request_task\n        finally:\n            current_tool_call.reset(token)\n\n        assert result is True\n        assert \"shell_exec\" in state.auto_approve_actions\n        assert len(changes) == 1\n\n    def test_no_callback_does_not_raise(self):\n        from kimi_cli.soul.approval import Approval, ApprovalState\n\n        state = ApprovalState()  # no on_change\n        approval = Approval(state=state)\n        approval.set_yolo(True)  # should not raise\n        assert state.yolo is True\n"
  },
  {
    "path": "tests/core/test_shell_mcp_status.py",
    "content": "from __future__ import annotations\n\nfrom rich.console import Console\n\nfrom kimi_cli.ui.shell.mcp_status import (\n    render_mcp_console,\n    render_mcp_prompt,\n)\nfrom kimi_cli.wire.types import MCPServerSnapshot, MCPStatusSnapshot\n\n\ndef test_render_mcp_servers_shows_live_loading_summary() -> None:\n    snapshot = MCPStatusSnapshot(\n        loading=True,\n        connected=0,\n        total=2,\n        tools=1,\n        servers=(\n            MCPServerSnapshot(\n                name=\"context7\",\n                status=\"connecting\",\n                tools=(\"resolve-library-id\",),\n            ),\n            MCPServerSnapshot(\n                name=\"chrome-devtools\",\n                status=\"pending\",\n                tools=(),\n            ),\n        ),\n    )\n\n    console = Console(record=True, force_terminal=False, width=120)\n    console.print(render_mcp_console(snapshot))\n    output = console.export_text()\n\n    assert \"MCP Servers: 0/2 connected, 1 tools\" in output\n    assert \"context7 (connecting)\" in output\n    assert \"chrome-devtools (pending)\" in output\n\n    prompt_text = \"\".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.0))\n    assert \"MCP Servers: 0/2 connected, 1 tools\" in prompt_text\n    assert \"context7 (connecting, 1 tool)\" in prompt_text\n    assert \"chrome-devtools (pending)\" in prompt_text\n    assert \"resolve-library-id\" not in prompt_text\n\n\ndef test_render_mcp_servers_shows_final_statuses() -> None:\n    snapshot = MCPStatusSnapshot(\n        loading=False,\n        connected=1,\n        total=2,\n        tools=2,\n        servers=(\n            MCPServerSnapshot(\n                name=\"context7\",\n                status=\"connected\",\n                tools=(\"resolve-library-id\", \"query-docs\"),\n            ),\n            MCPServerSnapshot(\n                name=\"chrome-devtools\",\n                status=\"failed\",\n                tools=(),\n            ),\n        ),\n    )\n\n    console = Console(record=True, force_terminal=False, width=120)\n    console.print(render_mcp_console(snapshot))\n    output = console.export_text()\n\n    assert \"MCP Servers: 1/2 connected, 2 tools\" in output\n    assert \"context7\" in output\n    assert \"resolve-library-id\" in output\n    assert \"query-docs\" in output\n    assert \"chrome-devtools (failed)\" in output\n\n    prompt_text = \"\".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.0))\n    assert prompt_text == \"\"\n"
  },
  {
    "path": "tests/core/test_simple_compaction.py",
    "content": "from __future__ import annotations\n\nfrom inline_snapshot import snapshot\nfrom kosong.chat_provider import TokenUsage\nfrom kosong.message import AudioURLPart, ImageURLPart, Message, VideoURLPart\n\nimport kimi_cli.prompts as prompts\nfrom kimi_cli.soul.compaction import CompactionResult, SimpleCompaction, should_auto_compact\nfrom kimi_cli.wire.types import TextPart, ThinkPart\n\n\ndef test_prepare_returns_original_when_not_enough_messages():\n    messages = [Message(role=\"user\", content=[TextPart(text=\"Only one message\")])]\n\n    result = SimpleCompaction(max_preserved_messages=2).prepare(messages)\n\n    assert result == snapshot(\n        SimpleCompaction.PrepareResult(\n            compact_message=None,\n            to_preserve=[Message(role=\"user\", content=[TextPart(text=\"Only one message\")])],\n        )\n    )\n\n\ndef test_prepare_skips_compaction_with_only_preserved_messages():\n    messages = [\n        Message(role=\"user\", content=[TextPart(text=\"Latest question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Latest reply\")]),\n    ]\n\n    result = SimpleCompaction(max_preserved_messages=2).prepare(messages)\n\n    assert result == snapshot(\n        SimpleCompaction.PrepareResult(\n            compact_message=None,\n            to_preserve=[\n                Message(role=\"user\", content=[TextPart(text=\"Latest question\")]),\n                Message(role=\"assistant\", content=[TextPart(text=\"Latest reply\")]),\n            ],\n        )\n    )\n\n\ndef test_prepare_builds_compact_message_and_preserves_tail():\n    messages = [\n        Message(role=\"system\", content=[TextPart(text=\"System note\")]),\n        Message(\n            role=\"user\",\n            content=[TextPart(text=\"Old question\"), ThinkPart(think=\"Hidden thoughts\")],\n        ),\n        Message(role=\"assistant\", content=[TextPart(text=\"Old answer\")]),\n        Message(role=\"user\", content=[TextPart(text=\"Latest question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Latest answer\")]),\n    ]\n\n    result = SimpleCompaction(max_preserved_messages=2).prepare(messages)\n\n    assert result.compact_message == snapshot(\n        Message(\n            role=\"user\",\n            content=[\n                TextPart(text=\"## Message 1\\nRole: system\\nContent:\\n\"),\n                TextPart(text=\"System note\"),\n                TextPart(text=\"## Message 2\\nRole: user\\nContent:\\n\"),\n                TextPart(text=\"Old question\"),\n                TextPart(text=\"## Message 3\\nRole: assistant\\nContent:\\n\"),\n                TextPart(text=\"Old answer\"),\n                TextPart(text=\"\\n\" + prompts.COMPACT),\n            ],\n        )\n    )\n    assert result.to_preserve == snapshot(\n        [\n            Message(role=\"user\", content=[TextPart(text=\"Latest question\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Latest answer\")]),\n        ]\n    )\n\n\n# --- CompactionResult.estimated_token_count tests ---\n\n\ndef test_estimated_token_count_with_usage_uses_output_tokens_for_summary():\n    \"\"\"When usage is available, the summary (first message) uses exact output tokens\n    and preserved messages (remaining) use character-based estimation.\"\"\"\n    summary_msg = Message(role=\"user\", content=[TextPart(text=\"compacted summary\")])\n    preserved_msg = Message(\n        role=\"user\",\n        content=[TextPart(text=\"a\" * 80)],  # 80 chars → 20 tokens\n    )\n    usage = TokenUsage(input_other=1000, output=150, input_cache_read=0)\n\n    result = CompactionResult(messages=[summary_msg, preserved_msg], usage=usage)\n\n    assert result.estimated_token_count == 150 + 20\n\n\ndef test_estimated_token_count_without_usage_estimates_all_from_text():\n    \"\"\"Without usage (no LLM call), all messages are estimated from text content.\"\"\"\n    messages = [\n        Message(role=\"user\", content=[TextPart(text=\"a\" * 100)]),\n        Message(role=\"assistant\", content=[TextPart(text=\"b\" * 200)]),\n    ]\n    result = CompactionResult(messages=messages, usage=None)\n\n    assert result.estimated_token_count == 300 // 4\n\n\ndef test_estimated_token_count_ignores_non_text_parts():\n    \"\"\"Non-text parts (think, etc.) should not inflate the estimate.\"\"\"\n    messages = [\n        Message(\n            role=\"user\",\n            content=[\n                TextPart(text=\"a\" * 40),\n                ThinkPart(think=\"internal reasoning \" * 100),\n            ],\n        ),\n    ]\n    result = CompactionResult(messages=messages, usage=None)\n\n    assert result.estimated_token_count == 40 // 4\n\n\ndef test_estimated_token_count_empty_messages():\n    \"\"\"Empty message list should return 0.\"\"\"\n    result = CompactionResult(messages=[], usage=None)\n    assert result.estimated_token_count == 0\n\n\ndef test_prepare_appends_custom_instruction():\n    messages = [\n        Message(role=\"user\", content=[TextPart(text=\"Old question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Old answer\")]),\n        Message(role=\"user\", content=[TextPart(text=\"Latest question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Latest answer\")]),\n    ]\n\n    result = SimpleCompaction(max_preserved_messages=2).prepare(\n        messages, custom_instruction=\"Preserve all discussions about the database\"\n    )\n\n    assert result.compact_message is not None\n    parts = result.compact_message.content\n    last_part = parts[-1]\n    assert isinstance(last_part, TextPart)\n    # Custom instruction should be merged into the same TextPart as the COMPACT prompt\n    assert last_part.text.startswith(\"\\n\" + prompts.COMPACT)\n    assert \"User's Custom Compaction Instruction\" in last_part.text\n    assert \"Preserve all discussions about the database\" in last_part.text\n\n\ndef test_prepare_without_custom_instruction_unchanged():\n    \"\"\"When no custom_instruction is given, the compact message should end with the COMPACT prompt.\"\"\"\n    messages = [\n        Message(role=\"user\", content=[TextPart(text=\"Old question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Old answer\")]),\n        Message(role=\"user\", content=[TextPart(text=\"Latest question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Latest answer\")]),\n    ]\n\n    result = SimpleCompaction(max_preserved_messages=2).prepare(messages)\n\n    assert result.compact_message is not None\n    parts = result.compact_message.content\n    last_part = parts[-1]\n    assert isinstance(last_part, TextPart)\n    assert last_part.text == \"\\n\" + prompts.COMPACT\n\n\n# --- should_auto_compact tests ---\n\n\nclass TestShouldAutoCompact:\n    \"\"\"Test the auto-compaction trigger logic across different model context sizes.\"\"\"\n\n    def test_200k_model_triggers_by_reserved(self):\n        \"\"\"200K model with default config: reserved (50K) fires first at 150K (75%).\"\"\"\n        # At 150K tokens: ratio check = 150K >= 170K (False), reserved check = 200K >= 200K (True)\n        assert should_auto_compact(\n            150_000, 200_000, trigger_ratio=0.85, reserved_context_size=50_000\n        )\n\n    def test_200k_model_below_threshold(self):\n        \"\"\"200K model: 140K tokens should NOT trigger (below both thresholds).\"\"\"\n        assert not should_auto_compact(\n            140_000, 200_000, trigger_ratio=0.85, reserved_context_size=50_000\n        )\n\n    def test_1m_model_triggers_by_ratio(self):\n        \"\"\"1M model with default config: ratio (85%) fires first at 850K.\"\"\"\n        # At 850K tokens: ratio check = 850K >= 850K (True)\n        assert should_auto_compact(\n            850_000, 1_000_000, trigger_ratio=0.85, reserved_context_size=50_000\n        )\n\n    def test_1m_model_below_ratio_threshold(self):\n        \"\"\"1M model: 840K tokens should NOT trigger (below 85% ratio, well above reserved).\"\"\"\n        assert not should_auto_compact(\n            840_000, 1_000_000, trigger_ratio=0.85, reserved_context_size=50_000\n        )\n\n    def test_custom_ratio_triggers_earlier(self):\n        \"\"\"Custom ratio=0.7 triggers at 70% of context.\"\"\"\n        # 200K * 0.7 = 140K\n        assert should_auto_compact(\n            140_000, 200_000, trigger_ratio=0.7, reserved_context_size=50_000\n        )\n        assert not should_auto_compact(\n            139_999, 200_000, trigger_ratio=0.7, reserved_context_size=50_000\n        )\n\n    def test_zero_tokens_never_triggers(self):\n        \"\"\"Empty context should never trigger compaction.\"\"\"\n        assert not should_auto_compact(0, 200_000, trigger_ratio=0.85, reserved_context_size=50_000)\n\n\ndef test_prepare_only_keeps_text_parts_in_compaction():\n    \"\"\"Compaction input should only contain TextPart (whitelist approach).\n\n    Non-text parts (media, think, etc.) are filtered out because the compaction\n    API endpoint only supports text content.\n\n    Fixes: https://github.com/MoonshotAI/kimi-cli/issues/1395\n    Fixes: https://github.com/MoonshotAI/kimi-cli/issues/1390\n    \"\"\"\n    messages = [\n        Message(\n            role=\"user\",\n            content=[\n                TextPart(text=\"Analyze these files:\"),\n                ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"data:image/png;base64,IMG\")),\n                AudioURLPart(audio_url=AudioURLPart.AudioURL(url=\"data:audio/mp3;base64,AUD\")),\n                VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"data:video/mp4;base64,VID\")),\n                ThinkPart(think=\"internal reasoning\"),\n            ],\n        ),\n        Message(role=\"assistant\", content=[TextPart(text=\"I can see all the media files.\")]),\n        Message(role=\"user\", content=[TextPart(text=\"What's your conclusion?\")]),\n    ]\n\n    result = SimpleCompaction(max_preserved_messages=1).prepare(messages)\n\n    assert result.compact_message is not None\n    # Verify only TextPart remains in the compaction request\n    for part in result.compact_message.content:\n        assert isinstance(part, TextPart), (\n            f\"Only TextPart should be in compaction input, got {type(part).__name__}\"\n        )\n\n    # Text content should be preserved\n    texts = [p.text for p in result.compact_message.content if isinstance(p, TextPart)]\n    assert any(\"Analyze these files:\" in t for t in texts)\n    assert any(\"I can see all the media files.\" in t for t in texts)\n\n\ndef test_prepare_preserves_media_parts_in_recent_messages():\n    \"\"\"Media parts in preserved (recent) messages should remain untouched.\"\"\"\n    messages = [\n        Message(role=\"user\", content=[TextPart(text=\"Old question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Old answer\")]),\n        Message(\n            role=\"user\",\n            content=[\n                TextPart(text=\"Look at this video:\"),\n                VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"data:video/mp4;base64,VID\")),\n            ],\n        ),\n        Message(role=\"assistant\", content=[TextPart(text=\"Nice video!\")]),\n    ]\n\n    result = SimpleCompaction(max_preserved_messages=2).prepare(messages)\n\n    # Preserved messages should keep their media parts intact\n    preserved_user_msg = result.to_preserve[0]\n    assert any(isinstance(p, VideoURLPart) for p in preserved_user_msg.content)\n"
  },
  {
    "path": "tests/core/test_skill.py",
    "content": "\"\"\"Tests for skill discovery and formatting behavior.\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\nfrom inline_snapshot import snapshot\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.skill import (\n    Skill,\n    discover_skills,\n    discover_skills_from_roots,\n    get_builtin_skills_dir,\n    resolve_skills_roots,\n)\n\n\ndef _write_skill(skill_dir: Path, content: str) -> None:\n    skill_dir.mkdir()\n    (skill_dir / \"SKILL.md\").write_text(content, encoding=\"utf-8\")\n\n\n@pytest.mark.asyncio\nasync def test_discover_skills_parses_frontmatter_and_defaults(tmp_path):\n    root = tmp_path / \"skills\"\n    root.mkdir()\n\n    _write_skill(\n        root / \"alpha\",\n        \"\"\"---\nname: alpha-skill\ndescription: Alpha description\n---\n\"\"\",\n    )\n    _write_skill(root / \"beta\", \"# No frontmatter\")\n\n    root_path = KaosPath.unsafe_from_local_path(root)\n    skills = await discover_skills(root_path)\n    base_dir = KaosPath.unsafe_from_local_path(Path(\"/path/to\"))\n    for skill in skills:\n        relative_dir = skill.dir.relative_to(root_path)\n        skill.dir = base_dir / relative_dir\n\n    assert skills == snapshot(\n        [\n            Skill(\n                name=\"alpha-skill\",\n                description=\"Alpha description\",\n                type=\"standard\",\n                dir=KaosPath.unsafe_from_local_path(Path(\"/path/to/alpha\")),\n                flow=None,\n            ),\n            Skill(\n                name=\"beta\",\n                description=\"No description provided.\",\n                type=\"standard\",\n                dir=KaosPath.unsafe_from_local_path(Path(\"/path/to/beta\")),\n                flow=None,\n            ),\n        ]\n    )\n\n\n@pytest.mark.asyncio\nasync def test_discover_skills_parses_flow_type(tmp_path):\n    root = tmp_path / \"skills\"\n    root.mkdir()\n\n    _write_skill(\n        root / \"flowy\",\n        \"\"\"---\nname: flowy\ndescription: Flow skill\ntype: flow\n---\n```mermaid\nflowchart TD\nBEGIN([BEGIN]) --> A[Hello]\nA --> END([END])\n```\n\"\"\",\n    )\n\n    skills = await discover_skills(KaosPath.unsafe_from_local_path(root))\n\n    assert len(skills) == 1\n    assert skills[0].type == \"flow\"\n    assert skills[0].flow is not None\n    assert skills[0].flow.begin_id == \"BEGIN\"\n\n\n@pytest.mark.asyncio\nasync def test_discover_skills_flow_parse_failure_falls_back(tmp_path):\n    root = tmp_path / \"skills\"\n    root.mkdir()\n\n    _write_skill(\n        root / \"broken-flow\",\n        \"\"\"---\nname: broken-flow\ndescription: Broken flow skill\ntype: flow\n---\n```mermaid\nflowchart TD\nA --> B\n```\n\"\"\",\n    )\n\n    skills = await discover_skills(KaosPath.unsafe_from_local_path(root))\n\n    assert len(skills) == 1\n    assert skills[0].type == \"standard\"\n    assert skills[0].flow is None\n\n\n@pytest.mark.asyncio\nasync def test_discover_skills_from_roots_prefers_later_dirs(tmp_path):\n    root = tmp_path / \"root\"\n    system_dir = root / \"system\"\n    user_dir = root / \"user\"\n    system_dir.mkdir(parents=True)\n    user_dir.mkdir(parents=True)\n\n    _write_skill(\n        system_dir / \"shared\",\n        \"\"\"---\nname: shared\ndescription: System version\n---\n\"\"\",\n    )\n    _write_skill(\n        user_dir / \"shared\",\n        \"\"\"---\nname: shared\ndescription: User version\n---\n\"\"\",\n    )\n\n    root_path = KaosPath.unsafe_from_local_path(root)\n    skills = await discover_skills_from_roots(\n        [\n            KaosPath.unsafe_from_local_path(system_dir),\n            KaosPath.unsafe_from_local_path(user_dir),\n        ]\n    )\n    base_dir = KaosPath.unsafe_from_local_path(Path(\"/path/to\"))\n    for skill in skills:\n        relative_dir = skill.dir.relative_to(root_path)\n        skill.dir = base_dir / relative_dir\n\n    assert skills == snapshot(\n        [\n            Skill(\n                name=\"shared\",\n                description=\"User version\",\n                type=\"standard\",\n                dir=KaosPath.unsafe_from_local_path(Path(\"/path/to/user/shared\")),\n                flow=None,\n            )\n        ]\n    )\n\n\n@pytest.mark.asyncio\nasync def test_resolve_skills_roots_uses_layers(monkeypatch, tmp_path):\n    home_dir = tmp_path / \"home\"\n    user_dir = home_dir / \".config\" / \"agents\" / \"skills\"\n    user_dir.mkdir(parents=True)\n    monkeypatch.setattr(Path, \"home\", lambda: home_dir)\n\n    # Redirect share dir so plugins dir doesn't interfere\n    monkeypatch.setenv(\"KIMI_SHARE_DIR\", str(tmp_path / \"share\"))\n\n    work_dir = tmp_path / \"project\"\n    project_dir = work_dir / \".agents\" / \"skills\"\n    project_dir.mkdir(parents=True)\n\n    roots = await resolve_skills_roots(KaosPath.unsafe_from_local_path(work_dir))\n\n    assert roots == [\n        KaosPath.unsafe_from_local_path(get_builtin_skills_dir()),\n        KaosPath.unsafe_from_local_path(user_dir),\n        KaosPath.unsafe_from_local_path(project_dir),\n    ]\n\n\n@pytest.mark.asyncio\nasync def test_resolve_skills_roots_respects_override(tmp_path, monkeypatch):\n    work_dir = tmp_path / \"project\"\n    override_dir = tmp_path / \"override\"\n    override_dir.mkdir()\n\n    # Redirect share dir to tmp so ~/.kimi/plugins/ doesn't interfere\n    monkeypatch.setenv(\"KIMI_SHARE_DIR\", str(tmp_path / \"share\"))\n\n    roots = await resolve_skills_roots(\n        KaosPath.unsafe_from_local_path(work_dir),\n        skills_dir_override=KaosPath.unsafe_from_local_path(override_dir),\n    )\n\n    assert roots == [\n        KaosPath.unsafe_from_local_path(get_builtin_skills_dir()),\n        KaosPath.unsafe_from_local_path(override_dir),\n    ]\n"
  },
  {
    "path": "tests/core/test_soul_import_command.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, Mock\n\nfrom kosong.message import Message\n\nfrom kimi_cli.soul import slash as soul_slash\nfrom kimi_cli.wire.types import TextPart\n\n\ndef _make_soul(work_dir: Path) -> Mock:\n    from kimi_cli.soul.kimisoul import KimiSoul\n\n    soul = Mock(spec=KimiSoul)\n    soul.runtime.session.work_dir = work_dir\n    soul.runtime.session.id = \"soul-session-id\"\n    soul.context.history = []\n    soul.context.token_count = 50\n    soul.context.append_message = AsyncMock()\n    soul.context.update_token_count = AsyncMock()\n    soul.wire_file.append_message = AsyncMock()\n    return soul\n\n\nasync def test_import_directory_path_reports_clear_error(tmp_path: Path, monkeypatch) -> None:\n    captured: list[TextPart] = []\n\n    def fake_wire_send(message: TextPart) -> None:\n        captured.append(message)\n\n    monkeypatch.setattr(soul_slash, \"wire_send\", fake_wire_send)\n\n    target_dir = tmp_path / \"import-dir\"\n    target_dir.mkdir()\n\n    soul = Mock()\n    await soul_slash.import_context(soul, str(target_dir))  # type: ignore[reportGeneralTypeIssues]\n\n    assert len(captured) == 1\n    assert \"directory\" in captured[0].text.lower()\n    assert \"provide a file\" in captured[0].text.lower()\n\n\nasync def test_export_writes_file_and_sends_wire(tmp_path: Path, monkeypatch) -> None:\n    captured: list[TextPart] = []\n\n    def fake_wire_send(message: TextPart) -> None:\n        captured.append(message)\n\n    monkeypatch.setattr(soul_slash, \"wire_send\", fake_wire_send)\n\n    soul = _make_soul(tmp_path)\n    soul.context.history = [\n        Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Hi!\")]),\n    ]\n\n    output = tmp_path / \"export.md\"\n    await soul_slash.export(soul, str(output))  # type: ignore[reportGeneralTypeIssues]\n\n    assert output.exists()\n    content = output.read_text(encoding=\"utf-8\")\n    assert \"# Kimi Session Export\" in content\n    assert \"Hello\" in content\n\n    # Should send export path + sensitive info warning\n    assert len(captured) == 2\n    assert \"Exported 2 messages\" in captured[0].text\n    assert \"sensitive information\" in captured[1].text.lower()\n\n\nasync def test_import_file_sends_wire_markers(tmp_path: Path, monkeypatch) -> None:\n    captured: list[TextPart] = []\n\n    def fake_wire_send(message: TextPart) -> None:\n        captured.append(message)\n\n    monkeypatch.setattr(soul_slash, \"wire_send\", fake_wire_send)\n\n    soul = _make_soul(tmp_path)\n    source = tmp_path / \"context.md\"\n    source.write_text(\"important context from before\", encoding=\"utf-8\")\n\n    await soul_slash.import_context(soul, str(source))  # type: ignore[reportGeneralTypeIssues]\n\n    # Context message appended\n    assert soul.context.append_message.await_count == 1\n    imported_msg = soul.context.append_message.await_args.args[0]\n    assert imported_msg.role == \"user\"\n\n    # No direct wire_file writes — KimiSoul.run() handles TurnBegin/TurnEnd\n    assert soul.wire_file.append_message.await_count == 0\n\n    # Success message sent\n    assert len(captured) == 1\n    assert \"Imported context\" in captured[0].text\n\n\nasync def test_import_env_file_sends_warning(tmp_path: Path, monkeypatch) -> None:\n    captured: list[TextPart] = []\n\n    def fake_wire_send(message: TextPart) -> None:\n        captured.append(message)\n\n    monkeypatch.setattr(soul_slash, \"wire_send\", fake_wire_send)\n\n    soul = _make_soul(tmp_path)\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\"API_KEY=secret123\", encoding=\"utf-8\")\n\n    await soul_slash.import_context(soul, str(env_file))  # type: ignore[reportGeneralTypeIssues]\n\n    assert len(captured) == 2\n    assert \"Imported context\" in captured[0].text\n    assert \"secrets\" in captured[1].text.lower()\n"
  },
  {
    "path": "tests/core/test_soul_message.py",
    "content": "from __future__ import annotations\n\nfrom inline_snapshot import snapshot\nfrom kosong.message import Message\nfrom kosong.tooling import ToolError, ToolOk\n\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.soul.message import check_message, system, tool_result_to_message\nfrom kimi_cli.wire.types import ImageURLPart, TextPart, ThinkPart, ToolResult, VideoURLPart\n\n\ndef test_system_message_creation():\n    \"\"\"Test that system messages are properly formatted.\"\"\"\n    message = \"Test message\"\n    assert system(message) == snapshot(TextPart(text=\"<system>Test message</system>\"))\n\n\ndef test_tool_ok_with_string_output():\n    \"\"\"Test ToolOk with string output.\"\"\"\n    tool_ok = ToolOk(output=\"Hello, world!\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n    message = tool_result_to_message(tool_result)\n    assert message == snapshot(\n        Message(role=\"tool\", content=[TextPart(text=\"Hello, world!\")], tool_call_id=\"call_123\")\n    )\n\n\ndef test_tool_ok_with_message():\n    \"\"\"Test ToolOk with explanatory message.\"\"\"\n    tool_ok = ToolOk(output=\"Result\", message=\"Operation completed\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n    message = tool_result_to_message(tool_result)\n    assert message == snapshot(\n        Message(\n            role=\"tool\",\n            content=[\n                TextPart(text=\"<system>Operation completed</system>\"),\n                TextPart(text=\"Result\"),\n            ],\n            tool_call_id=\"call_123\",\n        )\n    )\n\n\ndef test_tool_ok_with_content_part():\n    \"\"\"Test ToolOk with ContentPart output.\"\"\"\n    content_part = TextPart(text=\"Text content\")\n    tool_ok = ToolOk(output=content_part)\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n    message = tool_result_to_message(tool_result)\n    assert message == snapshot(\n        Message(role=\"tool\", content=[TextPart(text=\"Text content\")], tool_call_id=\"call_123\")\n    )\n\n\ndef test_tool_ok_with_sequence_of_parts():\n    \"\"\"Test ToolOk with sequence of ContentParts.\"\"\"\n    text_part = TextPart(text=\"Text content\")\n    text_part_2 = TextPart(text=\"Text content 2\")\n    tool_ok = ToolOk(output=[text_part, text_part_2])\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n    message = tool_result_to_message(tool_result)\n    assert message == snapshot(\n        Message(\n            role=\"tool\",\n            content=[TextPart(text=\"Text content\"), TextPart(text=\"Text content 2\")],\n            tool_call_id=\"call_123\",\n        )\n    )\n\n\ndef test_tool_ok_with_empty_output():\n    \"\"\"Test ToolOk with empty output.\"\"\"\n    tool_ok = ToolOk(output=\"\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n    message = tool_result_to_message(tool_result)\n    assert message == snapshot(\n        Message(\n            role=\"tool\",\n            content=[TextPart(text=\"<system>Tool output is empty.</system>\")],\n            tool_call_id=\"call_123\",\n        )\n    )\n\n\ndef test_tool_ok_with_message_but_empty_output():\n    \"\"\"Test ToolOk with message but empty output.\"\"\"\n    tool_ok = ToolOk(output=\"\", message=\"Just a message\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n    message = tool_result_to_message(tool_result)\n    assert message == snapshot(\n        Message(\n            role=\"tool\",\n            content=[TextPart(text=\"<system>Just a message</system>\")],\n            tool_call_id=\"call_123\",\n        )\n    )\n\n\ndef test_tool_error_result():\n    \"\"\"Test ToolResult with ToolError.\"\"\"\n    tool_error = ToolError(message=\"Error occurred\", brief=\"Brief error\", output=\"Error details\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_error)\n\n    message = tool_result_to_message(tool_result)\n\n    assert isinstance(message, Message)\n    assert message.role == \"tool\"\n    assert message.tool_call_id == \"call_123\"\n    assert len(message.content) == 2  # System message + error output\n    assert message.content[0] == system(\"ERROR: Error occurred\")\n    assert message.content[1] == TextPart(text=\"Error details\")\n\n\ndef test_tool_error_without_output():\n    \"\"\"Test ToolResult with ToolError without output.\"\"\"\n    tool_error = ToolError(message=\"Error occurred\", brief=\"Brief error\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_error)\n\n    message = tool_result_to_message(tool_result)\n\n    assert isinstance(message, Message)\n    assert message.role == \"tool\"\n    assert len(message.content) == 1  # Only system message\n    assert message.content[0] == system(\"ERROR: Error occurred\")\n\n\ndef test_tool_ok_with_text_only():\n    \"\"\"Test ToolResult with ToolOk containing only text parts.\"\"\"\n    tool_ok = ToolOk(output=\"Simple output\", message=\"Done\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n\n    message = tool_result_to_message(tool_result)\n\n    assert isinstance(message, Message)\n    assert message.role == \"tool\"\n    assert message.tool_call_id == \"call_123\"\n    # Should have system message from ToolOk + text output\n    assert len(message.content) == 2\n    assert message.content[0] == system(\"Done\")\n    assert message.content[1] == TextPart(text=\"Simple output\")\n\n\ndef test_tool_ok_with_non_text_parts():\n    \"\"\"Test ToolResult with ToolOk containing non-text parts.\"\"\"\n    text_part = TextPart(text=\"Text content\")\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n    tool_ok = ToolOk(output=[text_part, image_part], message=\"Mixed content\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n\n    # With current implementation, non-text parts are included in the same message\n    message = tool_result_to_message(tool_result)\n\n    assert isinstance(message, Message)\n    assert message.role == \"tool\"\n    assert message.tool_call_id == \"call_123\"\n\n    # Should have system message + text part + image part\n    assert len(message.content) == 3\n    assert message.content[0] == system(\"Mixed content\")\n    assert message.content[1] == text_part\n    assert message.content[2] == image_part\n\n\ndef test_tool_ok_with_only_non_text_parts():\n    \"\"\"Test ToolResult with ToolOk containing only non-text parts.\"\"\"\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n    tool_ok = ToolOk(output=image_part)\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n\n    # With current implementation, non-text parts are included in the same message\n    message = tool_result_to_message(tool_result)\n\n    assert isinstance(message, Message)\n    assert message.role == \"tool\"\n    assert message.tool_call_id == \"call_123\"\n    # Should have only the image part (no text parts)\n    assert len(message.content) == 1\n    assert message.content[0] == image_part\n\n\ndef test_tool_ok_with_only_text_parts():\n    \"\"\"Test ToolResult with ToolOk containing only text parts.\"\"\"\n    tool_ok = ToolOk(output=\"Just text\")\n    tool_result = ToolResult(tool_call_id=\"call_123\", return_value=tool_ok)\n\n    message = tool_result_to_message(tool_result)\n\n    assert isinstance(message, Message)\n    assert message.role == \"tool\"\n    assert len(message.content) == 1\n    assert message.content[0] == TextPart(text=\"Just text\")\n\n\ndef test_check_message_with_image_and_image_capability():\n    \"\"\"Test check_message with ImageURLPart when model has image_in capability.\"\"\"\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n    message = Message(role=\"user\", content=[image_part])\n    model_capabilities: set[ModelCapability] = {\"image_in\", \"thinking\"}\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == set()\n\n\ndef test_check_message_with_image_no_image_capability():\n    \"\"\"Test check_message with ImageURLPart when model lacks image_in capability.\"\"\"\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n    message = Message(role=\"user\", content=[image_part])\n    model_capabilities: set[ModelCapability] = {\"thinking\"}\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == {\"image_in\"}\n\n\ndef test_check_message_with_video_and_video_capability():\n    \"\"\"Test check_message with VideoURLPart when model has video_in capability.\"\"\"\n    video_part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video.mp4\"))\n    message = Message(role=\"user\", content=[video_part])\n    model_capabilities: set[ModelCapability] = {\"video_in\"}\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == set()\n\n\ndef test_check_message_with_video_no_video_capability():\n    \"\"\"Test check_message with VideoURLPart when model lacks video_in capability.\"\"\"\n    video_part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video.mp4\"))\n    message = Message(role=\"user\", content=[video_part])\n    model_capabilities: set[ModelCapability] = {\"image_in\"}\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == {\"video_in\"}\n\n\ndef test_check_message_with_think_and_think_capability():\n    \"\"\"Test check_message with ThinkPart when model has thinking capability.\"\"\"\n    think_part = ThinkPart(think=\"This is a thinking process\")\n    message = Message(role=\"assistant\", content=[think_part])\n    model_capabilities: set[ModelCapability] = {\"image_in\", \"thinking\"}\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == set()\n\n\ndef test_check_message_with_think_no_think_capability():\n    \"\"\"Test check_message with ThinkPart when model lacks thinking capability.\"\"\"\n    think_part = ThinkPart(think=\"This is a thinking process\")\n    message = Message(role=\"assistant\", content=[think_part])\n    model_capabilities: set[ModelCapability] = {\"image_in\"}\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == {\"thinking\"}\n\n\ndef test_check_message_with_mixed_parts_partial_capabilities():\n    \"\"\"Test check_message with both ImageURLPart and ThinkPart, model has only one capability.\"\"\"\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n    think_part = ThinkPart(think=\"Thinking...\")\n    message = Message(role=\"user\", content=[image_part, think_part])\n    model_capabilities: set[ModelCapability] = {\"image_in\"}\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == {\"thinking\"}\n\n\ndef test_check_message_with_text_only():\n    \"\"\"Test check_message with only TextPart (no special capabilities needed).\"\"\"\n    text_part = TextPart(text=\"Just a text message\")\n    message = Message(role=\"user\", content=[text_part])\n    model_capabilities: set[ModelCapability] = set()\n\n    missing_capabilities = check_message(message, model_capabilities)\n\n    assert missing_capabilities == set()\n"
  },
  {
    "path": "tests/core/test_startup_imports.py",
    "content": "from __future__ import annotations\n\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nROOT = Path(__file__).resolve().parents[2]\nSRC = ROOT / \"src\"\n\n\ndef _run_python(code: str) -> subprocess.CompletedProcess[str]:\n    env = os.environ.copy()\n    existing = env.get(\"PYTHONPATH\")\n    env[\"PYTHONPATH\"] = str(SRC) if not existing else f\"{SRC}{os.pathsep}{existing}\"\n    return subprocess.run(\n        [sys.executable, \"-c\", code],\n        cwd=ROOT,\n        env=env,\n        capture_output=True,\n        text=True,\n        check=True,\n    )\n\n\ndef test_import_kimi_cli_does_not_import_loguru() -> None:\n    proc = _run_python(\n        \"\"\"\nimport sys\nsys.modules.pop(\"loguru\", None)\nimport kimi_cli\nassert \"loguru\" not in sys.modules\nprint(\"ok\")\n\"\"\"\n    )\n    assert proc.stdout.strip() == \"ok\"\n\n\ndef test_logger_proxy_imports_loguru_on_first_use() -> None:\n    proc = _run_python(\n        \"\"\"\nimport sys\nsys.modules.pop(\"loguru\", None)\nfrom kimi_cli import logger\nassert \"loguru\" not in sys.modules\nlogger.disable(\"unit.test\")\nassert \"loguru\" in sys.modules\nprint(\"ok\")\n\"\"\"\n    )\n    assert proc.stdout.strip() == \"ok\"\n\n\ndef test_import_kimi_cli_constant_defers_package_metadata() -> None:\n    proc = _run_python(\n        \"\"\"\nimport sys\nsys.modules.pop(\"importlib.metadata\", None)\nimport kimi_cli.constant as constant\nassert \"importlib.metadata\" not in sys.modules\nassert constant.get_version()\nassert \"importlib.metadata\" in sys.modules\nprint(\"ok\")\n\"\"\"\n    )\n    assert proc.stdout.strip() == \"ok\"\n\n\ndef test_root_help_lists_lazy_subcommands_without_importing_them() -> None:\n    proc = _run_python(\n        \"\"\"\nimport sys\nfrom typer.testing import CliRunner\n\nlazy_modules = [\n    \"kimi_cli.cli.info\",\n    \"kimi_cli.cli.export\",\n    \"kimi_cli.cli.mcp\",\n    \"kimi_cli.cli.vis\",\n    \"kimi_cli.cli.web\",\n]\nfor name in lazy_modules:\n    sys.modules.pop(name, None)\n\nfrom kimi_cli.cli import cli\n\nassert all(name not in sys.modules for name in lazy_modules)\n\nresult = CliRunner().invoke(cli, [\"--help\"])\nassert result.exit_code == 0, result.output\nfor name in (\"info\", \"export\", \"mcp\", \"vis\", \"web\"):\n    assert name in result.output\nassert all(name not in sys.modules for name in lazy_modules)\nprint(\"ok\")\n\"\"\"\n    )\n    assert proc.stdout.strip() == \"ok\"\n\n\ndef test_info_subcommand_loads_on_demand() -> None:\n    proc = _run_python(\n        \"\"\"\nimport sys\nfrom typer.testing import CliRunner\n\nlazy_modules = [\n    \"kimi_cli.cli.info\",\n    \"kimi_cli.cli.export\",\n    \"kimi_cli.cli.mcp\",\n    \"kimi_cli.cli.vis\",\n    \"kimi_cli.cli.web\",\n]\nfor name in lazy_modules:\n    sys.modules.pop(name, None)\n\nfrom kimi_cli.cli import cli\n\nresult = CliRunner().invoke(cli, [\"info\", \"--json\"])\nassert result.exit_code == 0, result.output\nassert '\"kimi_cli_version\"' in result.output\nassert \"kimi_cli.cli.info\" in sys.modules\nassert \"kimi_cli.cli.export\" not in sys.modules\nassert \"kimi_cli.cli.mcp\" not in sys.modules\nassert \"kimi_cli.cli.vis\" not in sys.modules\nassert \"kimi_cli.cli.web\" not in sys.modules\nprint(\"ok\")\n\"\"\"\n    )\n    assert proc.stdout.strip() == \"ok\"\n\n\ndef test_package_entrypoint_fast_path_avoids_cli_import() -> None:\n    proc = _run_python(\n        \"\"\"\nimport io\nimport sys\nfrom contextlib import redirect_stdout\n\nsys.modules.pop(\"kimi_cli.cli\", None)\n\nfrom kimi_cli.__main__ import main\n\nstdout = io.StringIO()\nwith redirect_stdout(stdout):\n    exit_code = main([\"--version\"])\n\nassert exit_code == 0\nassert stdout.getvalue().startswith(\"kimi, version \")\nassert \"kimi_cli.cli\" not in sys.modules\nprint(\"ok\")\n\"\"\"\n    )\n    assert proc.stdout.strip() == \"ok\"\n\n\ndef test_package_entrypoint_falls_back_to_cli_for_commands() -> None:\n    proc = _run_python(\n        \"\"\"\nimport io\nimport json\nimport sys\nfrom contextlib import redirect_stdout\n\nsys.modules.pop(\"kimi_cli.cli\", None)\n\nfrom kimi_cli.__main__ import main\n\nstdout = io.StringIO()\nwith redirect_stdout(stdout):\n    exit_code = main([\"info\", \"--json\"])\n\nassert exit_code in (None, 0)\nassert \"kimi_cli.cli\" in sys.modules\npayload = json.loads(stdout.getvalue())\nassert payload[\"kimi_cli_version\"]\nprint(\"ok\")\n\"\"\"\n    )\n    assert proc.stdout.strip() == \"ok\"\n"
  },
  {
    "path": "tests/core/test_startup_progress.py",
    "content": "from __future__ import annotations\n\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nimport kimi_cli.app as app_module\nimport kimi_cli.ui.shell.startup as startup_module\nfrom kimi_cli.app import KimiCLI\nfrom kimi_cli.ui.shell.startup import ShellStartupProgress\n\n\ndef test_shell_startup_progress_starts_once_and_updates_messages(monkeypatch) -> None:\n    events: list[tuple[str, str]] = []\n\n    class FakeStatus:\n        def start(self) -> None:\n            events.append((\"start\", \"\"))\n\n        def update(self, message: str) -> None:\n            events.append((\"update\", message))\n\n        def stop(self) -> None:\n            events.append((\"stop\", \"\"))\n\n    def fake_status(message: str, *, spinner: str) -> FakeStatus:\n        events.append((\"create\", message))\n        assert spinner == \"dots\"\n        return FakeStatus()\n\n    monkeypatch.setattr(startup_module.console, \"status\", fake_status)\n\n    progress = ShellStartupProgress(enabled=True)\n    progress.update(\"Preparing session...\")\n    progress.update(\"Loading agent...\")\n    progress.stop()\n\n    assert events == [\n        (\"create\", \"[cyan]Preparing session...[/cyan]\"),\n        (\"start\", \"\"),\n        (\"update\", \"[cyan]Loading agent...[/cyan]\"),\n        (\"stop\", \"\"),\n    ]\n\n\ndef test_shell_startup_progress_is_noop_when_disabled(monkeypatch) -> None:\n    called = False\n\n    def fake_status(message: str, *, spinner: str):\n        nonlocal called\n        called = True\n        raise AssertionError(f\"status() should not be called, got {message!r} {spinner!r}\")\n\n    monkeypatch.setattr(startup_module.console, \"status\", fake_status)\n\n    progress = ShellStartupProgress(enabled=False)\n    progress.update(\"Preparing session...\")\n    progress.stop()\n\n    assert called is False\n\n\n@pytest.mark.asyncio\nasync def test_kimi_cli_create_reports_startup_phases(session, config, monkeypatch) -> None:\n    phases: list[str] = []\n    fake_runtime = SimpleNamespace(\n        session=session,\n        config=config,\n        llm=None,\n        notifications=SimpleNamespace(recover=lambda: None),\n        background_tasks=SimpleNamespace(reconcile=lambda: None),\n    )\n    fake_agent = SimpleNamespace(name=\"Test Agent\", system_prompt=\"Test system prompt\")\n    fake_context = SimpleNamespace(system_prompt=None)\n    write_system_prompt = AsyncMock()\n\n    async def fake_runtime_create(*args, **kwargs):\n        return fake_runtime\n\n    async def fake_load_agent(*args, **kwargs):\n        return fake_agent\n\n    async def fake_restore() -> None:\n        return None\n\n    fake_context.restore = fake_restore\n    fake_context.write_system_prompt = write_system_prompt\n\n    monkeypatch.setattr(app_module, \"load_config\", lambda conf: conf)\n    monkeypatch.setattr(app_module, \"augment_provider_with_env_vars\", lambda provider, model: {})\n    monkeypatch.setattr(app_module, \"create_llm\", lambda *args, **kwargs: None)\n    monkeypatch.setattr(app_module.Runtime, \"create\", fake_runtime_create)\n    monkeypatch.setattr(app_module, \"load_agent\", fake_load_agent)\n    monkeypatch.setattr(app_module, \"Context\", lambda _path: fake_context)\n    monkeypatch.setattr(app_module, \"KimiSoul\", lambda agent, context: (agent, context))\n\n    cli = await KimiCLI.create(session, config=config, startup_progress=phases.append)\n\n    assert isinstance(cli, KimiCLI)\n    assert phases == [\n        \"Loading configuration...\",\n        \"Scanning workspace...\",\n        \"Loading agent...\",\n        \"Restoring conversation...\",\n    ]\n    write_system_prompt.assert_awaited_once_with(\"Test system prompt\")\n"
  },
  {
    "path": "tests/core/test_status_formatting.py",
    "content": "from kimi_cli.soul import format_context_status, format_token_count\n\n\ndef test_format_token_count_drops_trailing_zero():\n    assert format_token_count(1_000) == \"1k\"\n    assert format_token_count(128_000) == \"128k\"\n    assert format_token_count(1_000_000) == \"1m\"\n\n\ndef test_format_token_count_keeps_decimal_when_needed():\n    assert format_token_count(1_550) == \"1.6k\"\n    assert format_token_count(1_240_000) == \"1.2m\"\n\n\ndef test_format_context_status_uses_compact_token_counts():\n    assert format_context_status(0.42, context_tokens=3_000, max_context_tokens=10_000) == (\n        \"context: 42.0% (3k/10k)\"\n    )\n"
  },
  {
    "path": "tests/core/test_str_replace_file_plan_mode.py",
    "content": "\"\"\"Tests for StrReplaceFile plan mode integration.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any, cast\nfrom unittest.mock import AsyncMock\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import ToolError, ToolReturnValue\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.tools.file.replace import Edit, Params, StrReplaceFile\nfrom tests.conftest import tool_call_context\n\n\nclass TestStrReplaceFilePlanMode:\n    async def test_plan_file_auto_approved(\n        self, runtime: Runtime, temp_work_dir: KaosPath, tmp_path: Path\n    ) -> None:\n        \"\"\"Editing the plan file should bypass approval even with yolo=False.\"\"\"\n        approval = Approval(yolo=False)\n        plan_path = tmp_path / \"plans\" / \"test-plan.md\"\n        plan_path.parent.mkdir(parents=True, exist_ok=True)\n        plan_path.write_text(\"# Plan\\n- old\", encoding=\"utf-8\")\n\n        with tool_call_context(\"StrReplaceFile\"):\n            tool = StrReplaceFile(runtime, approval)\n            tool.bind_plan_mode(\n                checker=lambda: True,\n                path_getter=lambda: plan_path,\n            )\n\n            request_mock = AsyncMock(return_value=False)\n            approval.request = cast(Any, request_mock)\n\n            result = await tool(\n                Params(\n                    path=str(plan_path),\n                    edit=Edit(old=\"- old\", new=\"- new\"),\n                )\n            )\n\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert plan_path.read_text() == \"# Plan\\n- new\"\n        request_mock.assert_not_awaited()\n\n    async def test_non_plan_file_is_blocked_in_plan_mode(\n        self, runtime: Runtime, temp_work_dir: KaosPath\n    ) -> None:\n        \"\"\"Plan mode should hard-block replacements on non-plan files.\"\"\"\n        approval = Approval(yolo=False)\n        target = temp_work_dir / \"other.txt\"\n        await target.write_text(\"old\")\n        plan_path = Path(str(temp_work_dir)) / \"plans\" / \"plan.md\"\n\n        with tool_call_context(\"StrReplaceFile\"):\n            tool = StrReplaceFile(runtime, approval)\n            tool.bind_plan_mode(\n                checker=lambda: True,\n                path_getter=lambda: plan_path,\n            )\n\n            request_mock = AsyncMock(return_value=False)\n            approval.request = cast(Any, request_mock)\n\n            result = await tool(\n                Params(\n                    path=str(target),\n                    edit=Edit(old=\"old\", new=\"new\"),\n                )\n            )\n\n        assert isinstance(result, ToolError)\n        assert \"only edit the current plan file\" in result.message\n        request_mock.assert_not_awaited()\n\n    async def test_no_plan_mode_normal_flow(\n        self, runtime: Runtime, temp_work_dir: KaosPath\n    ) -> None:\n        \"\"\"Without plan mode binding, yolo=True auto-approves normally.\"\"\"\n        approval = Approval(yolo=True)\n        target = temp_work_dir / \"normal.txt\"\n        await target.write_text(\"old content\")\n\n        with tool_call_context(\"StrReplaceFile\"):\n            tool = StrReplaceFile(runtime, approval)\n            result = await tool(\n                Params(\n                    path=str(target),\n                    edit=Edit(old=\"old content\", new=\"new content\"),\n                )\n            )\n\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n\n    async def test_missing_plan_file_guides_to_write_file(\n        self, runtime: Runtime, tmp_path: Path\n    ) -> None:\n        approval = Approval(yolo=False)\n        plan_path = tmp_path / \"plans\" / \"missing-plan.md\"\n\n        with tool_call_context(\"StrReplaceFile\"):\n            tool = StrReplaceFile(runtime, approval)\n            tool.bind_plan_mode(\n                checker=lambda: True,\n                path_getter=lambda: plan_path,\n            )\n\n            result = await tool(\n                Params(\n                    path=str(plan_path),\n                    edit=Edit(old=\"old\", new=\"new\"),\n                )\n            )\n\n        assert isinstance(result, ToolError)\n        assert \"Use WriteFile to create it\" in result.message\n"
  },
  {
    "path": "tests/core/test_toolset.py",
    "content": "\"\"\"Tests for KimiToolset hide/unhide functionality.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport json\n\nfrom kosong.tooling import CallableTool2, ToolOk, ToolReturnValue\nfrom kosong.tooling.error import ToolNotFoundError as KosongToolNotFoundError\nfrom pydantic import BaseModel\n\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.wire.types import ToolCall, ToolResult\n\n\nclass DummyParams(BaseModel):\n    value: str = \"\"\n\n\nclass DummyToolA(CallableTool2[DummyParams]):\n    name: str = \"ToolA\"\n    description: str = \"Tool A\"\n    params: type[DummyParams] = DummyParams\n\n    async def __call__(self, params: DummyParams) -> ToolReturnValue:\n        return ToolOk(output=\"a\")\n\n\nclass DummyToolB(CallableTool2[DummyParams]):\n    name: str = \"ToolB\"\n    description: str = \"Tool B\"\n    params: type[DummyParams] = DummyParams\n\n    async def __call__(self, params: DummyParams) -> ToolReturnValue:\n        return ToolOk(output=\"b\")\n\n\ndef _make_toolset() -> KimiToolset:\n    ts = KimiToolset()\n    ts.add(DummyToolA())\n    ts.add(DummyToolB())\n    return ts\n\n\ndef _tool_names(ts: KimiToolset) -> set[str]:\n    return {t.name for t in ts.tools}\n\n\n# --- hide() ---\n\n\ndef test_hide_removes_from_tools_property():\n    ts = _make_toolset()\n    assert _tool_names(ts) == {\"ToolA\", \"ToolB\"}\n\n    ts.hide(\"ToolA\")\n    assert _tool_names(ts) == {\"ToolB\"}\n\n\ndef test_hide_returns_true_for_existing_tool():\n    ts = _make_toolset()\n    assert ts.hide(\"ToolA\") is True\n\n\ndef test_hide_returns_false_for_nonexistent_tool():\n    ts = _make_toolset()\n    assert ts.hide(\"NoSuchTool\") is False\n\n\ndef test_hide_is_idempotent():\n    ts = _make_toolset()\n    ts.hide(\"ToolA\")\n    ts.hide(\"ToolA\")\n    assert \"ToolA\" not in _tool_names(ts)\n\n    # Single unhide restores after multiple hides\n    ts.unhide(\"ToolA\")\n    assert \"ToolA\" in _tool_names(ts)\n\n\ndef test_hide_multiple_tools():\n    ts = _make_toolset()\n    ts.hide(\"ToolA\")\n    ts.hide(\"ToolB\")\n    assert ts.tools == []\n\n\n# --- unhide() ---\n\n\ndef test_unhide_restores_tool():\n    ts = _make_toolset()\n    ts.hide(\"ToolA\")\n    assert \"ToolA\" not in _tool_names(ts)\n\n    ts.unhide(\"ToolA\")\n    assert \"ToolA\" in _tool_names(ts)\n\n\ndef test_unhide_nonexistent_is_noop():\n    ts = _make_toolset()\n    ts.unhide(\"NoSuchTool\")\n    assert _tool_names(ts) == {\"ToolA\", \"ToolB\"}\n\n\ndef test_unhide_without_prior_hide_is_noop():\n    ts = _make_toolset()\n    ts.unhide(\"ToolA\")\n    assert _tool_names(ts) == {\"ToolA\", \"ToolB\"}\n\n\n# --- find() is unaffected ---\n\n\ndef test_hidden_tool_still_findable_by_name():\n    ts = _make_toolset()\n    ts.hide(\"ToolA\")\n    assert ts.find(\"ToolA\") is not None\n\n\ndef test_hidden_tool_still_findable_by_type():\n    ts = _make_toolset()\n    ts.hide(\"ToolA\")\n    assert ts.find(DummyToolA) is not None\n\n\n# --- handle() is unaffected ---\n\n\nasync def test_hidden_tool_still_handled():\n    \"\"\"handle() should dispatch to hidden tools instead of returning ToolNotFoundError.\"\"\"\n    ts = _make_toolset()\n    ts.hide(\"ToolA\")\n\n    tool_call = ToolCall(\n        id=\"tc-1\",\n        function=ToolCall.FunctionBody(\n            name=\"ToolA\",\n            arguments=json.dumps({\"value\": \"test\"}),\n        ),\n    )\n    result = ts.handle(tool_call)\n    # For async tools, handle() returns an asyncio.Task.\n    # A ToolNotFoundError would be returned as a sync ToolResult directly.\n    if isinstance(result, ToolResult):\n        assert not isinstance(result.return_value, KosongToolNotFoundError)\n    else:\n        assert isinstance(result, asyncio.Task)\n        result.cancel()\n        with contextlib.suppress(asyncio.CancelledError):\n            await result\n\n\nasync def test_nonexistent_tool_returns_not_found():\n    \"\"\"handle() should return ToolNotFoundError for tools not in _tool_dict at all.\"\"\"\n    ts = _make_toolset()\n\n    tool_call = ToolCall(\n        id=\"tc-2\",\n        function=ToolCall.FunctionBody(\n            name=\"NoSuchTool\",\n            arguments=\"{}\",\n        ),\n    )\n    result = ts.handle(tool_call)\n    assert isinstance(result, ToolResult)\n    assert isinstance(result.return_value, KosongToolNotFoundError)\n\n\n# --- hide/unhide cycle ---\n\n\ndef test_hide_unhide_cycle():\n    \"\"\"Multiple hide/unhide cycles should work correctly.\"\"\"\n    ts = _make_toolset()\n\n    ts.hide(\"ToolA\")\n    assert \"ToolA\" not in _tool_names(ts)\n\n    ts.unhide(\"ToolA\")\n    assert \"ToolA\" in _tool_names(ts)\n\n    ts.hide(\"ToolA\")\n    assert \"ToolA\" not in _tool_names(ts)\n\n    ts.unhide(\"ToolA\")\n    assert \"ToolA\" in _tool_names(ts)\n"
  },
  {
    "path": "tests/core/test_wire_message.py",
    "content": "import inspect\nfrom pathlib import Path\n\nimport pytest\nfrom inline_snapshot import snapshot\nfrom pydantic import BaseModel\n\nfrom kimi_cli.wire.file import WireMessageRecord\nfrom kimi_cli.wire.serde import deserialize_wire_message, serialize_wire_message\nfrom kimi_cli.wire.types import (\n    ApprovalRequest,\n    ApprovalResponse,\n    BriefDisplayBlock,\n    CompactionBegin,\n    CompactionEnd,\n    ImageURLPart,\n    MCPLoadingBegin,\n    MCPLoadingEnd,\n    MCPServerSnapshot,\n    MCPStatusSnapshot,\n    Notification,\n    QuestionItem,\n    QuestionOption,\n    QuestionRequest,\n    QuestionResponse,\n    StatusUpdate,\n    SteerInput,\n    StepBegin,\n    StepInterrupted,\n    SubagentEvent,\n    TextPart,\n    ToolCall,\n    ToolCallPart,\n    ToolCallRequest,\n    ToolResult,\n    ToolReturnValue,\n    TurnBegin,\n    TurnEnd,\n    WireMessage,\n    WireMessageEnvelope,\n    is_event,\n    is_request,\n    is_wire_message,\n)\n\n\ndef _test_serde(msg: WireMessage):\n    serialized = serialize_wire_message(msg)\n    deserialized = deserialize_wire_message(serialized)\n    assert deserialized == msg\n\n\nasync def test_wire_message_serde():\n    \"\"\"Test serialization of all WireMessage types.\"\"\"\n\n    msg = TurnBegin(user_input=\"Hello, world!\")\n    assert serialize_wire_message(msg) == snapshot(\n        {\"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"Hello, world!\"}}\n    )\n    _test_serde(msg)\n\n    msg = TurnBegin(user_input=[TextPart(text=\"Hello\"), TextPart(text=\"world!\")])\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"TurnBegin\",\n            \"payload\": {\n                \"user_input\": [\n                    {\"type\": \"text\", \"text\": \"Hello\"},\n                    {\"type\": \"text\", \"text\": \"world!\"},\n                ]\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = TurnEnd()\n    assert serialize_wire_message(msg) == snapshot({\"type\": \"TurnEnd\", \"payload\": {}})\n    _test_serde(msg)\n\n    msg = SteerInput(user_input=\"Follow up\")\n    assert serialize_wire_message(msg) == snapshot(\n        {\"type\": \"SteerInput\", \"payload\": {\"user_input\": \"Follow up\"}}\n    )\n    _test_serde(msg)\n\n    msg = SteerInput(\n        user_input=[\n            TextPart(text=\"Look\"),\n            ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image\")),\n        ]\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"SteerInput\",\n            \"payload\": {\n                \"user_input\": [\n                    {\"type\": \"text\", \"text\": \"Look\"},\n                    {\n                        \"type\": \"image_url\",\n                        \"image_url\": {\"url\": \"https://example.com/image\", \"id\": None},\n                    },\n                ]\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = StepBegin(n=1)\n    assert serialize_wire_message(msg) == snapshot({\"type\": \"StepBegin\", \"payload\": {\"n\": 1}})\n    _test_serde(msg)\n\n    msg = StepInterrupted()\n    assert serialize_wire_message(msg) == snapshot({\"type\": \"StepInterrupted\", \"payload\": {}})\n    _test_serde(msg)\n\n    msg = CompactionBegin()\n    assert serialize_wire_message(msg) == snapshot({\"type\": \"CompactionBegin\", \"payload\": {}})\n    _test_serde(msg)\n\n    msg = CompactionEnd()\n    assert serialize_wire_message(msg) == snapshot({\"type\": \"CompactionEnd\", \"payload\": {}})\n    _test_serde(msg)\n\n    msg = MCPLoadingBegin()\n    assert serialize_wire_message(msg) == snapshot({\"type\": \"MCPLoadingBegin\", \"payload\": {}})\n    _test_serde(msg)\n\n    msg = MCPLoadingEnd()\n    assert serialize_wire_message(msg) == snapshot({\"type\": \"MCPLoadingEnd\", \"payload\": {}})\n    _test_serde(msg)\n\n    msg = StatusUpdate(\n        context_usage=0.5,\n        mcp_status=MCPStatusSnapshot(\n            loading=True,\n            connected=0,\n            total=1,\n            tools=0,\n            servers=(MCPServerSnapshot(name=\"context7\", status=\"connecting\"),),\n        ),\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"StatusUpdate\",\n            \"payload\": {\n                \"context_usage\": 0.5,\n                \"context_tokens\": None,\n                \"max_context_tokens\": None,\n                \"token_usage\": None,\n                \"message_id\": None,\n                \"plan_mode\": None,\n                \"mcp_status\": {\n                    \"loading\": True,\n                    \"connected\": 0,\n                    \"total\": 1,\n                    \"tools\": 0,\n                    \"servers\": [\n                        {\n                            \"name\": \"context7\",\n                            \"status\": \"connecting\",\n                            \"tools\": [],\n                        }\n                    ],\n                },\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = Notification(\n        id=\"n1234567\",\n        category=\"task\",\n        type=\"task.completed\",\n        source_kind=\"background_task\",\n        source_id=\"b1234567\",\n        title=\"Background task completed\",\n        body=\"Task ID: b1234567\",\n        severity=\"success\",\n        created_at=123.456,\n        payload={\"task_id\": \"b1234567\"},\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"Notification\",\n            \"payload\": {\n                \"id\": \"n1234567\",\n                \"category\": \"task\",\n                \"type\": \"task.completed\",\n                \"source_kind\": \"background_task\",\n                \"source_id\": \"b1234567\",\n                \"title\": \"Background task completed\",\n                \"body\": \"Task ID: b1234567\",\n                \"severity\": \"success\",\n                \"created_at\": 123.456,\n                \"payload\": {\"task_id\": \"b1234567\"},\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = TextPart(text=\"Hello world\")\n    assert serialize_wire_message(msg) == snapshot(\n        {\"type\": \"ContentPart\", \"payload\": {\"type\": \"text\", \"text\": \"Hello world\"}}\n    )\n    _test_serde(msg)\n\n    msg = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"http://example.com/image.png\"))\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"ContentPart\",\n            \"payload\": {\n                \"type\": \"image_url\",\n                \"image_url\": {\"url\": \"http://example.com/image.png\", \"id\": None},\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = ToolCall(\n        id=\"call_123\",\n        function=ToolCall.FunctionBody(name=\"bash\", arguments='{\"command\": \"ls -la\"}'),\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"ToolCall\",\n            \"payload\": {\n                \"type\": \"function\",\n                \"id\": \"call_123\",\n                \"function\": {\"name\": \"bash\", \"arguments\": '{\"command\": \"ls -la\"}'},\n                \"extras\": None,\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = ToolCallPart(arguments_part=\"}\")\n    assert serialize_wire_message(msg) == snapshot(\n        {\"type\": \"ToolCallPart\", \"payload\": {\"arguments_part\": \"}\"}}\n    )\n    _test_serde(msg)\n\n    msg = ToolResult(\n        tool_call_id=\"call_123\",\n        return_value=ToolReturnValue(\n            is_error=False,\n            output=\"\",\n            message=\"Command completed\",\n            display=[BriefDisplayBlock(text=\"Command completed\")],\n        ),\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"ToolResult\",\n            \"payload\": {\n                \"tool_call_id\": \"call_123\",\n                \"return_value\": {\n                    \"is_error\": False,\n                    \"output\": \"\",\n                    \"message\": \"Command completed\",\n                    \"display\": [{\"type\": \"brief\", \"text\": \"Command completed\"}],\n                    \"extras\": None,\n                },\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = ApprovalResponse(\n        request_id=\"request_123\",\n        response=\"approve\",\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"ApprovalResponse\",\n            \"payload\": {\"request_id\": \"request_123\", \"response\": \"approve\"},\n        }\n    )\n    _test_serde(msg)\n\n    msg = SubagentEvent(\n        task_tool_call_id=\"task_789\",\n        event=StepBegin(n=2),\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"SubagentEvent\",\n            \"payload\": {\n                \"task_tool_call_id\": \"task_789\",\n                \"event\": {\"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n            },\n        }\n    )\n    _test_serde(msg)\n\n    with pytest.raises(ValueError):\n        ApprovalResponse(request_id=\"request_123\", response=\"invalid_response\")  # type: ignore\n\n    msg = ApprovalRequest(\n        id=\"request_123\",\n        tool_call_id=\"call_999\",\n        sender=\"bash\",\n        action=\"Execute dangerous command\",\n        description=\"This command will delete files\",\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"ApprovalRequest\",\n            \"payload\": {\n                \"id\": \"request_123\",\n                \"tool_call_id\": \"call_999\",\n                \"sender\": \"bash\",\n                \"action\": \"Execute dangerous command\",\n                \"description\": \"This command will delete files\",\n                \"display\": [],\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = ToolCallRequest(\n        id=\"call_123\",\n        name=\"bash\",\n        arguments='{\"command\": \"ls -la\"}',\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"ToolCallRequest\",\n            \"payload\": {\n                \"id\": \"call_123\",\n                \"name\": \"bash\",\n                \"arguments\": '{\"command\": \"ls -la\"}',\n            },\n        }\n    )\n    _test_serde(msg)\n\n    msg = QuestionRequest(\n        id=\"question_001\",\n        tool_call_id=\"call_456\",\n        questions=[\n            QuestionItem(\n                question=\"Which library?\",\n                header=\"Library\",\n                options=[\n                    QuestionOption(label=\"React\", description=\"A JS library\"),\n                    QuestionOption(label=\"Vue\", description=\"A progressive framework\"),\n                ],\n                multi_select=False,\n            )\n        ],\n    )\n    assert serialize_wire_message(msg) == snapshot(\n        {\n            \"type\": \"QuestionRequest\",\n            \"payload\": {\n                \"id\": \"question_001\",\n                \"tool_call_id\": \"call_456\",\n                \"questions\": [\n                    {\n                        \"question\": \"Which library?\",\n                        \"header\": \"Library\",\n                        \"options\": [\n                            {\"label\": \"React\", \"description\": \"A JS library\"},\n                            {\"label\": \"Vue\", \"description\": \"A progressive framework\"},\n                        ],\n                        \"multi_select\": False,\n                        \"body\": \"\",\n                        \"other_label\": \"\",\n                        \"other_description\": \"\",\n                    }\n                ],\n            },\n        }\n    )\n    _test_serde(msg)\n\n\nasync def test_approval_request_deserialize_without_display():\n    msg = deserialize_wire_message(\n        {\n            \"type\": \"ApprovalRequest\",\n            \"payload\": {\n                \"id\": \"request_123\",\n                \"tool_call_id\": \"call_999\",\n                \"sender\": \"bash\",\n                \"action\": \"Execute dangerous command\",\n                \"description\": \"This command will delete files\",\n            },\n        }\n    )\n\n    assert isinstance(msg, ApprovalRequest)\n    assert msg.display == []\n\n\ndef test_wire_message_record_roundtrip():\n    envelope = WireMessageEnvelope.from_wire_message(TurnBegin(user_input=[TextPart(text=\"hi\")]))\n    record = WireMessageRecord(timestamp=123.456, message=envelope)\n\n    assert record.model_dump(mode=\"json\") == snapshot(\n        {\n            \"timestamp\": 123.456,\n            \"message\": {\n                \"type\": \"TurnBegin\",\n                \"payload\": {\"user_input\": [{\"type\": \"text\", \"text\": \"hi\"}]},\n            },\n        }\n    )\n\n    parsed = WireMessageRecord.model_validate_json(record.model_dump_json())\n    assert parsed.message == envelope\n    assert parsed.to_wire_message() == TurnBegin(user_input=[TextPart(text=\"hi\")])\n\n\ndef test_bad_wire_message_serde():\n    with pytest.raises(ValueError):\n        deserialize_wire_message(None)\n\n    with pytest.raises(ValueError):\n        deserialize_wire_message([])\n\n    with pytest.raises(ValueError):\n        deserialize_wire_message({})\n\n    with pytest.raises(ValueError):\n        deserialize_wire_message(\n            {\n                \"timestamp\": 123,\n                \"message\": {\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"Hello world\"},\n                },\n            }\n        )\n\n\ndef test_approval_request_resolved_compat():\n    msg = deserialize_wire_message(\n        {\n            \"type\": \"ApprovalRequestResolved\",\n            \"payload\": {\"request_id\": \"request_123\", \"response\": \"approve\"},\n        }\n    )\n\n    assert msg == ApprovalResponse(request_id=\"request_123\", response=\"approve\")\n\n\nasync def test_type_inspection():\n    msg = StepBegin(n=1)\n    assert is_wire_message(msg)\n    assert is_event(msg)\n    assert not is_request(msg)\n\n    msg = Notification(\n        id=\"n1234567\",\n        category=\"system\",\n        type=\"system.info\",\n        source_kind=\"test\",\n        source_id=\"source-1\",\n        title=\"Info\",\n        body=\"body\",\n        severity=\"info\",\n        created_at=1.0,\n    )\n    assert is_wire_message(msg)\n    assert is_event(msg)\n    assert not is_request(msg)\n\n    msg = TextPart(text=\"Hello world\")\n    assert is_wire_message(msg)\n    assert is_event(msg)\n    assert not is_request(msg)\n\n    msg = ApprovalResponse(\n        request_id=\"request_123\",\n        response=\"approve\",\n    )\n    assert is_wire_message(msg)\n    assert is_event(msg)\n    assert not is_request(msg)\n\n    msg = ApprovalRequest(\n        id=\"request_123\",\n        tool_call_id=\"call_999\",\n        sender=\"bash\",\n        action=\"Execute dangerous command\",\n        description=\"This command will delete files\",\n    )\n    assert is_wire_message(msg)\n    assert not is_event(msg)\n    assert is_request(msg)\n\n    msg = ToolCallRequest(\n        id=\"call_123\",\n        name=\"bash\",\n        arguments=\"{}\",\n    )\n    assert is_wire_message(msg)\n    assert not is_event(msg)\n    assert is_request(msg)\n\n    msg = QuestionRequest(\n        id=\"question_001\",\n        tool_call_id=\"call_456\",\n        questions=[\n            QuestionItem(\n                question=\"Pick one?\",\n                options=[\n                    QuestionOption(label=\"A\", description=\"\"),\n                    QuestionOption(label=\"B\", description=\"\"),\n                ],\n            )\n        ],\n    )\n    assert is_wire_message(msg)\n    assert not is_event(msg)\n    assert is_request(msg)\n\n\nasync def test_question_request_resolve():\n    \"\"\"Test basic resolve → wait flow for QuestionRequest.\"\"\"\n    request = QuestionRequest(\n        id=\"q1\",\n        tool_call_id=\"tc1\",\n        questions=[\n            QuestionItem(\n                question=\"Pick?\",\n                options=[\n                    QuestionOption(label=\"A\", description=\"\"),\n                    QuestionOption(label=\"B\", description=\"\"),\n                ],\n            )\n        ],\n    )\n    assert not request.resolved\n    request.resolve({\"Pick?\": \"A\"})\n    assert request.resolved\n    result = await request.wait()\n    assert result == {\"Pick?\": \"A\"}\n\n\nasync def test_question_request_resolve_empty():\n    \"\"\"Test resolve with empty answers dict.\"\"\"\n    request = QuestionRequest(\n        id=\"q2\",\n        tool_call_id=\"tc2\",\n        questions=[\n            QuestionItem(\n                question=\"Pick?\",\n                options=[\n                    QuestionOption(label=\"A\", description=\"\"),\n                    QuestionOption(label=\"B\", description=\"\"),\n                ],\n            )\n        ],\n    )\n    request.resolve({})\n    result = await request.wait()\n    assert result == {}\n    assert request.resolved\n\n\ndef test_wire_message_type_alias():\n    import kimi_cli.wire.types\n\n    module = kimi_cli.wire.types\n    # Helper types that are BaseModel subclasses but not WireMessage types\n    _NON_WIRE_TYPES = {\n        WireMessageEnvelope,\n        MCPServerSnapshot,\n        MCPStatusSnapshot,\n        QuestionOption,\n        QuestionItem,\n        QuestionResponse,\n    }\n\n    wire_message_types = {\n        obj\n        for _, obj in inspect.getmembers(module, inspect.isclass)\n        if obj.__module__ == module.__name__\n        and issubclass(obj, BaseModel)\n        and obj not in _NON_WIRE_TYPES\n    }\n\n    for type_ in wire_message_types:\n        assert type_ in module._WIRE_MESSAGE_TYPES\n\n\ndef test_read_wire_lines_request_id(tmp_path: Path):\n    \"\"\"Verify _read_wire_lines emits a top-level JSON-RPC ``id`` for request messages.\n\n    wire.jsonl stores messages as ``{\"type\": \"QuestionRequest\", \"payload\": {\"id\": ..., ...}}``.\n    The ``id`` lives inside ``payload``, NOT at the top of ``message``.  _read_wire_lines\n    must extract it to the top-level ``id`` field of the JSON-RPC envelope so that the\n    frontend client can correlate responses.\n\n    Regression test for a bug where ``message_raw.get(\"id\")`` was used instead of\n    ``message.id``, always producing an empty string.\n    \"\"\"\n    import json\n    import time\n\n    from kimi_cli.web.api.sessions import _read_wire_lines\n\n    # Build a realistic wire.jsonl with request and event messages\n    wire_file = tmp_path / \"wire.jsonl\"\n\n    question_req = QuestionRequest(\n        id=\"q-abc-123\",\n        tool_call_id=\"tc-1\",\n        questions=[\n            QuestionItem(\n                question=\"Pick one?\",\n                options=[\n                    QuestionOption(label=\"A\", description=\"Option A\"),\n                    QuestionOption(label=\"B\", description=\"Option B\"),\n                ],\n            )\n        ],\n    )\n    approval_req = ApprovalRequest(\n        id=\"a-def-456\",\n        action=\"write\",\n        description=\"Write to file.txt\",\n        sender=\"Agent\",\n        tool_call_id=\"tc-2\",\n    )\n    step_begin = StepBegin(n=1)\n\n    records = []\n    for msg in [step_begin, question_req, approval_req]:\n        envelope = WireMessageEnvelope.from_wire_message(msg)\n        record = {\"timestamp\": time.time(), \"message\": envelope.model_dump(mode=\"json\")}\n        records.append(json.dumps(record, ensure_ascii=False))\n\n    wire_file.write_text(\"\\n\".join(records) + \"\\n\")\n\n    # Parse\n    lines = _read_wire_lines(wire_file)\n    assert len(lines) == 3\n\n    parsed = [json.loads(line) for line in lines]\n\n    # StepBegin is an event — should have method=event and NO id\n    event_msg = parsed[0]\n    assert event_msg[\"method\"] == \"event\"\n    assert \"id\" not in event_msg\n\n    # QuestionRequest — must have method=request and correct top-level id\n    question_msg = parsed[1]\n    assert question_msg[\"method\"] == \"request\"\n    assert question_msg[\"id\"] == \"q-abc-123\", (\n        f\"Expected top-level id='q-abc-123', got '{question_msg.get('id')}'. \"\n        \"The id must come from the deserialized request object, not message_raw dict.\"\n    )\n\n    # ApprovalRequest — same check\n    approval_msg = parsed[2]\n    assert approval_msg[\"method\"] == \"request\"\n    assert approval_msg[\"id\"] == \"a-def-456\"\n"
  },
  {
    "path": "tests/core/test_wire_plan_mode.py",
    "content": "\"\"\"Tests for wire protocol plan mode support.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import MagicMock\n\nfrom kimi_cli.soul.toolset import KimiToolset\nfrom kimi_cli.tools.plan import ExitPlanMode\nfrom kimi_cli.tools.plan.enter import EnterPlanMode\nfrom kimi_cli.wire.jsonrpc import ClientCapabilities\n\n\nclass TestClientCapabilities:\n    def test_defaults_to_false(self) -> None:\n        caps = ClientCapabilities()\n        assert caps.supports_plan_mode is False\n\n    def test_parses_true(self) -> None:\n        caps = ClientCapabilities(supports_plan_mode=True)\n        assert caps.supports_plan_mode is True\n\n\nclass TestSyncPlanModeToolVisibility:\n    def _make_toolset_with_plan_tools(self) -> KimiToolset:\n        ts = KimiToolset()\n        ts.add(ExitPlanMode())\n        ts.add(EnterPlanMode())\n        return ts\n\n    def _make_server(self, supports_plan_mode: bool):\n        \"\"\"Create a minimal WireServer-like object with _sync_plan_mode_tool_visibility.\"\"\"\n        from kimi_cli.wire.server import WireServer\n\n        # We need to construct WireServer with minimal mocking\n        soul = MagicMock()\n        soul.agent = MagicMock()\n        soul.agent.runtime = MagicMock()\n        soul.agent.runtime.labor_market.fixed_subagents = {}\n\n        server = WireServer.__new__(WireServer)\n        server._soul = soul\n        server._client_supports_plan_mode = supports_plan_mode\n        return server\n\n    def test_hides_tools_when_unsupported(self) -> None:\n        ts = self._make_toolset_with_plan_tools()\n        server = self._make_server(supports_plan_mode=False)\n\n        server._sync_plan_mode_tool_visibility(ts)\n\n        # Tools should be hidden\n        tool_names = {t.name for t in ts.tools}\n        assert \"ExitPlanMode\" not in tool_names\n        assert \"EnterPlanMode\" not in tool_names\n\n    def test_tools_visible_when_supported(self) -> None:\n        ts = self._make_toolset_with_plan_tools()\n        server = self._make_server(supports_plan_mode=True)\n\n        server._sync_plan_mode_tool_visibility(ts)\n\n        tool_names = {t.name for t in ts.tools}\n        assert \"ExitPlanMode\" in tool_names\n        assert \"EnterPlanMode\" in tool_names\n\n    def test_unhide_after_hide(self) -> None:\n        ts = self._make_toolset_with_plan_tools()\n        server = self._make_server(supports_plan_mode=False)\n\n        # First hide\n        server._sync_plan_mode_tool_visibility(ts)\n        assert \"ExitPlanMode\" not in {t.name for t in ts.tools}\n\n        # Then unhide\n        server._client_supports_plan_mode = True\n        server._sync_plan_mode_tool_visibility(ts)\n        assert \"ExitPlanMode\" in {t.name for t in ts.tools}\n        assert \"EnterPlanMode\" in {t.name for t in ts.tools}\n"
  },
  {
    "path": "tests/core/test_wire_server_steer.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom pathlib import Path\n\nimport pytest\nfrom kosong.message import ContentPart\nfrom kosong.tooling.empty import EmptyToolset\n\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.wire.jsonrpc import (\n    ErrorCodes,\n    JSONRPCErrorResponse,\n    JSONRPCSteerMessage,\n    JSONRPCSuccessResponse,\n    Statuses,\n)\nfrom kimi_cli.wire.server import WireServer\nfrom kimi_cli.wire.types import TextPart\n\n\ndef _make_soul(runtime: Runtime, tmp_path: Path) -> KimiSoul:\n    agent = Agent(\n        name=\"Steer Test Agent\",\n        system_prompt=\"Test prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    return KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n\n@pytest.mark.asyncio\nasync def test_handle_steer_returns_invalid_state_when_not_streaming(\n    runtime: Runtime,\n    tmp_path: Path,\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    server = WireServer(soul)\n\n    response = await server._handle_steer(\n        JSONRPCSteerMessage(\n            id=\"1\",\n            params=JSONRPCSteerMessage.Params(user_input=[TextPart(text=\"follow-up\")]),\n        )\n    )\n\n    assert isinstance(response, JSONRPCErrorResponse)\n    assert response.error.code == ErrorCodes.INVALID_STATE\n\n\n@pytest.mark.asyncio\nasync def test_handle_steer_queues_input_when_streaming(\n    runtime: Runtime,\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    server = WireServer(soul)\n    queued: list[str | list[ContentPart]] = []\n\n    monkeypatch.setattr(soul, \"steer\", lambda user_input: queued.append(user_input))\n    server._cancel_event = asyncio.Event()\n\n    response = await server._handle_steer(\n        JSONRPCSteerMessage(\n            id=\"1\",\n            params=JSONRPCSteerMessage.Params(user_input=[TextPart(text=\"follow-up\")]),\n        )\n    )\n\n    assert isinstance(response, JSONRPCSuccessResponse)\n    assert response.result == {\"status\": Statuses.STEERED}\n    assert queued == [[TextPart(text=\"follow-up\")]]\n"
  },
  {
    "path": "tests/core/test_write_file_plan_mode.py",
    "content": "\"\"\"Tests for WriteFile plan mode integration.\n\nVerifies that plan mode allows writing to the plan file (auto-approved)\nwhile other write operations still go through normal approval.\nPlan mode constraints on Shell are enforced by the dynamic injection prompt,\nnot by hard-blocking the tool — Shell remains usable through user approval.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any, cast\nfrom unittest.mock import AsyncMock\n\nfrom kaos.path import KaosPath\nfrom kosong.tooling import ToolError, ToolReturnValue\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.tools.file.write import Params, WriteFile\nfrom kimi_cli.tools.shell import Params as ShellParams\nfrom kimi_cli.tools.shell import Shell\nfrom kimi_cli.utils.environment import Environment\nfrom tests.conftest import tool_call_context\n\n\nclass TestWriteFilePlanMode:\n    async def test_plan_file_auto_approved(\n        self, runtime: Runtime, temp_work_dir: KaosPath, tmp_path: Path\n    ) -> None:\n        \"\"\"Writing to the plan file should bypass approval even with yolo=False.\"\"\"\n        approval = Approval(yolo=False)\n        with tool_call_context(\"WriteFile\"):\n            tool = WriteFile(runtime, approval)\n            plan_path = tmp_path / \"plans\" / \"test-plan.md\"\n            tool.bind_plan_mode(\n                checker=lambda: True,\n                path_getter=lambda: plan_path,\n            )\n\n            # Mock approval.request to fail if called — plan file should skip it\n            request_mock = AsyncMock(return_value=False)\n            approval.request = cast(Any, request_mock)\n\n            result = await tool(\n                Params(\n                    path=str(plan_path),\n                    content=\"# My Plan\",\n                )\n            )\n\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert plan_path.exists()\n        assert plan_path.read_text() == \"# My Plan\"\n        # Approval should NOT have been called for plan file\n        request_mock.assert_not_awaited()\n\n    async def test_non_plan_file_is_blocked_in_plan_mode(\n        self, runtime: Runtime, temp_work_dir: KaosPath\n    ) -> None:\n        \"\"\"Plan mode should hard-block writes to non-plan files.\"\"\"\n        approval = Approval(yolo=False)\n        target = temp_work_dir / \"other.txt\"\n        plan_path = Path(str(temp_work_dir)) / \"plans\" / \"plan.md\"\n        with tool_call_context(\"WriteFile\"):\n            tool = WriteFile(runtime, approval)\n            tool.bind_plan_mode(\n                checker=lambda: True,\n                path_getter=lambda: plan_path,\n            )\n\n            # Approval should never be reached for non-plan files in plan mode.\n            request_mock = AsyncMock(return_value=False)\n            approval.request = cast(Any, request_mock)\n\n            result = await tool(\n                Params(\n                    path=str(target),\n                    content=\"hello\",\n                )\n            )\n\n        assert isinstance(result, ToolError)\n        assert \"only edit the current plan file\" in result.message\n        request_mock.assert_not_awaited()\n\n    async def test_no_plan_mode_normal_flow(\n        self, runtime: Runtime, temp_work_dir: KaosPath\n    ) -> None:\n        \"\"\"Without plan mode binding, yolo=True auto-approves normally.\"\"\"\n        approval = Approval(yolo=True)\n        target = temp_work_dir / \"normal.txt\"\n        with tool_call_context(\"WriteFile\"):\n            tool = WriteFile(runtime, approval)\n            result = await tool(\n                Params(\n                    path=str(target),\n                    content=\"hello\",\n                )\n            )\n\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n\n    async def test_plan_file_creates_parent_dir(\n        self, runtime: Runtime, temp_work_dir: KaosPath, tmp_path: Path\n    ) -> None:\n        \"\"\"Plan file writes should auto-create parent directories.\"\"\"\n        approval = Approval(yolo=False)\n        plan_path = tmp_path / \"deep\" / \"nested\" / \"plan.md\"\n        with tool_call_context(\"WriteFile\"):\n            tool = WriteFile(runtime, approval)\n            tool.bind_plan_mode(\n                checker=lambda: True,\n                path_getter=lambda: plan_path,\n            )\n\n            result = await tool(\n                Params(\n                    path=str(plan_path),\n                    content=\"# Deep Plan\",\n                )\n            )\n\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert plan_path.exists()\n        assert plan_path.read_text() == \"# Deep Plan\"\n\n    async def test_plan_file_append_works_in_plan_mode(\n        self, runtime: Runtime, temp_work_dir: KaosPath, tmp_path: Path\n    ) -> None:\n        \"\"\"Appending to the plan file should also be auto-approved in plan mode.\"\"\"\n        runtime.session.state.plan_mode = True\n        approval = Approval(yolo=False)\n        plan_path = tmp_path / \"plans\" / \"append-plan.md\"\n        plan_path.parent.mkdir(parents=True, exist_ok=True)\n        plan_path.write_text(\"# Plan v1\\n\")\n        with tool_call_context(\"WriteFile\"):\n            tool = WriteFile(runtime, approval)\n            tool.bind_plan_mode(\n                checker=lambda: runtime.session.state.plan_mode,\n                path_getter=lambda: plan_path,\n            )\n\n            request_mock = AsyncMock(return_value=False)\n            approval.request = cast(Any, request_mock)\n\n            result = await tool(\n                Params(path=str(plan_path), content=\"\\n## New Section\\n\", mode=\"append\")\n            )\n\n        assert isinstance(result, ToolReturnValue)\n        assert not result.is_error\n        assert \"# Plan v1\" in plan_path.read_text()\n        assert \"## New Section\" in plan_path.read_text()\n        request_mock.assert_not_awaited()\n\n\nclass TestPlanModeToolContract:\n    \"\"\"Verify the plan mode tool contract:\n\n    - Shell is NOT hard-blocked; plan mode constraints are enforced via dynamic\n      injection prompts and user approval, not at the tool layer.\n    - WriteFile auto-approves plan file writes, bypassing user approval.\n    - Both Shell and WriteFile (plan file) work in the same plan mode session.\n    \"\"\"\n\n    async def test_shell_still_works_in_plan_mode(\n        self,\n        runtime: Runtime,\n        environment: Environment,\n    ) -> None:\n        \"\"\"Shell must not be hard-blocked in plan mode.\n\n        Plan mode read-only guidance is delivered via PlanModeInjectionProvider.\n        The tool itself remains usable through normal approval flow.\n        \"\"\"\n        runtime.session.state.plan_mode = True\n        approval = Approval(yolo=True)\n\n        with tool_call_context(\"Shell\"):\n            shell = Shell(approval, environment, runtime)\n            result = await shell(ShellParams(command=\"echo plan_shell_ok\"))\n\n        assert not result.is_error\n        assert \"plan_shell_ok\" in result.output\n\n    async def test_shell_and_plan_file_write_both_work_in_plan_mode(\n        self,\n        runtime: Runtime,\n        environment: Environment,\n        temp_work_dir: KaosPath,\n        tmp_path: Path,\n    ) -> None:\n        \"\"\"In the same plan mode session, both Shell and WriteFile plan writes must succeed.\"\"\"\n        runtime.session.state.plan_mode = True\n        plan_path = tmp_path / \"plans\" / \"combined-plan.md\"\n\n        # Shell works (through yolo approval)\n        with tool_call_context(\"Shell\"):\n            shell = Shell(Approval(yolo=True), environment, runtime)\n            shell_result = await shell(ShellParams(command=\"echo shell_ok\"))\n\n        assert not shell_result.is_error\n        assert \"shell_ok\" in shell_result.output\n\n        # WriteFile to plan file works (auto-approved, no approval needed)\n        approval = Approval(yolo=False)\n        with tool_call_context(\"WriteFile\"):\n            write_tool = WriteFile(runtime, approval)\n            write_tool.bind_plan_mode(\n                checker=lambda: runtime.session.state.plan_mode,\n                path_getter=lambda: plan_path,\n            )\n            request_mock = AsyncMock(return_value=False)\n            approval.request = cast(Any, request_mock)\n\n            write_result = await write_tool(\n                Params(path=str(plan_path), content=\"# Plan\\n\\nBoth tools work in plan mode.\")\n            )\n\n        assert isinstance(write_result, ToolReturnValue)\n        assert not write_result.is_error\n        assert plan_path.exists()\n        request_mock.assert_not_awaited()\n"
  },
  {
    "path": "tests/e2e/__init__.py",
    "content": "\n"
  },
  {
    "path": "tests/e2e/shell_pty_helpers.py",
    "content": "from __future__ import annotations\n\nimport contextlib\nimport errno\nimport fcntl\nimport hashlib\nimport json\nimport os\nimport pty\nimport re\nimport select\nimport struct\nimport subprocess\nimport sys\nimport termios\nimport time\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any\n\nfrom tests_e2e.wire_helpers import TRACE_ENV, make_env, repo_root\nfrom tests_e2e.wire_helpers import make_home_dir as _make_home_dir\nfrom tests_e2e.wire_helpers import make_work_dir as _make_work_dir\nfrom tests_e2e.wire_helpers import write_scripted_config as write_scripted_config\n\nDEFAULT_TIMEOUT = 10.0\nPROMPT_SYMBOL = \"✨\"\nOSC_RE = re.compile(r\"\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)\")\nCSI_RE = re.compile(r\"\\x1b\\[[0-?]*[ -/]*[@-~]\")\nOTHER_ESCAPE_RE = re.compile(r\"\\x1b[@-_]\")\n\n\ndef _print_trace(label: str, text: str) -> None:\n    if os.getenv(TRACE_ENV) == \"1\":\n        print(\"-----\")\n        print(f\"{label}: {text}\")\n\n\ndef make_home_dir(tmp_path: Path) -> Path:\n    return _make_home_dir(tmp_path)\n\n\ndef make_work_dir(tmp_path: Path) -> Path:\n    return _make_work_dir(tmp_path)\n\n\ndef _normalize_terminal_text(text: str) -> str:\n    text = text.replace(\"\\r\\n\", \"\\n\")\n    text = text.replace(\"\\r\", \"\\n\")\n    text = OSC_RE.sub(\"\", text)\n    text = CSI_RE.sub(\"\", text)\n    text = OTHER_ESCAPE_RE.sub(\"\", text)\n    text = text.replace(\"\\x00\", \"\")\n    text = text.replace(\"\\x08\", \"\")\n    return text\n\n\ndef _set_window_size(fd: int, *, columns: int, lines: int) -> None:\n    packed = struct.pack(\"HHHH\", lines, columns, 0, 0)\n    fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)\n\n\ndef _preexec_for_tty(slave_fd: int):\n    def _run() -> None:\n        os.setsid()\n        fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)\n\n    return _run\n\n\n@dataclass\nclass ShellPTYProcess:\n    process: subprocess.Popen[bytes]\n    master_fd: int\n    _raw_chunks: list[bytes] = field(default_factory=list)\n\n    def normalized_text(self) -> str:\n        return _normalize_terminal_text(self.raw_text())\n\n    def raw_text(self) -> str:\n        return b\"\".join(self._raw_chunks).decode(\"utf-8\", errors=\"replace\")\n\n    def mark(self) -> int:\n        return len(self.normalized_text())\n\n    def _append_output(self, chunk: bytes) -> None:\n        if not chunk:\n            return\n        self._raw_chunks.append(chunk)\n        _print_trace(\"STDOUT\", chunk.decode(\"utf-8\", errors=\"replace\"))\n\n    def read_available(self, timeout: float = 0.1) -> bytes:\n        ready, _, _ = select.select([self.master_fd], [], [], timeout)\n        if not ready:\n            return b\"\"\n        try:\n            chunk = os.read(self.master_fd, 4096)\n        except OSError as exc:\n            if exc.errno == errno.EIO:\n                return b\"\"\n            raise\n        self._append_output(chunk)\n        return chunk\n\n    def read_until_contains(\n        self, text: str, *, timeout: float = DEFAULT_TIMEOUT, after: int = 0\n    ) -> str:\n        deadline = time.monotonic() + timeout\n        while True:\n            normalized = self.normalized_text()\n            if text in normalized[after:]:\n                return normalized\n            if self.process.poll() is not None:\n                # Drain any final PTY output before failing.\n                while self.read_available(timeout=0.01):\n                    normalized = self.normalized_text()\n                    if text in normalized[after:]:\n                        return normalized\n                raise AssertionError(\n                    f\"Missing {text!r} before process exit.\\n\"\n                    f\"Return code: {self.process.returncode}\\n\"\n                    f\"Normalized transcript:\\n{self.normalized_text()}\\n\"\n                    f\"Raw transcript:\\n{self.raw_text()}\"\n                )\n            remaining = deadline - time.monotonic()\n            if remaining <= 0:\n                raise AssertionError(\n                    f\"Timed out waiting for {text!r}.\\n\"\n                    f\"Normalized transcript:\\n{self.normalized_text()}\\n\"\n                    f\"Raw transcript:\\n{self.raw_text()}\"\n                )\n            self.read_available(timeout=min(0.2, remaining))\n\n    def send_text(self, text: str) -> None:\n        _print_trace(\"STDIN\", text)\n        os.write(self.master_fd, text.encode(\"utf-8\"))\n\n    def send_key(self, key: str) -> None:\n        key_map = {\n            \"enter\": b\"\\r\",\n            \"escape\": b\"\\x1b\",\n            \"tab\": b\"\\t\",\n            \"s_tab\": b\"\\x1b[Z\",\n            \"up\": b\"\\x1b[A\",\n            \"down\": b\"\\x1b[B\",\n            \"left\": b\"\\x1b[D\",\n            \"right\": b\"\\x1b[C\",\n            \"ctrl_c\": b\"\\x03\",\n            \"ctrl_d\": b\"\\x04\",\n            \"ctrl_x\": b\"\\x18\",\n        }\n        payload = key_map.get(key)\n        if payload is None:\n            if len(key) != 1:\n                raise ValueError(f\"Unsupported key: {key}\")\n            payload = key.encode(\"utf-8\")\n        _print_trace(\"STDIN\", repr(payload))\n        os.write(self.master_fd, payload)\n\n    def send_line(self, text: str) -> None:\n        if text:\n            self.send_text(text)\n        self.send_key(\"enter\")\n\n    def wait(self, timeout: float = DEFAULT_TIMEOUT) -> int:\n        deadline = time.monotonic() + timeout\n        while True:\n            result = self.process.poll()\n            if result is not None:\n                while self.read_available(timeout=0.01):\n                    pass\n                return result\n            remaining = deadline - time.monotonic()\n            if remaining <= 0:\n                raise AssertionError(\n                    \"Timed out waiting for shell process to exit.\\n\"\n                    f\"Normalized transcript:\\n{self.normalized_text()}\\n\"\n                    f\"Raw transcript:\\n{self.raw_text()}\"\n                )\n            self.read_available(timeout=min(0.2, remaining))\n\n    def wait_for_quiet(\n        self, *, timeout: float = 1.0, quiet_period: float = 0.2, after: int = 0\n    ) -> str:\n        deadline = time.monotonic() + timeout\n        while True:\n            if time.monotonic() >= deadline:\n                raise AssertionError(\n                    \"Timed out waiting for terminal output to settle.\\n\"\n                    f\"Normalized transcript:\\n{self.normalized_text()}\\n\"\n                    f\"Raw transcript:\\n{self.raw_text()}\"\n                )\n            chunk = self.read_available(timeout=quiet_period)\n            if not chunk:\n                return self.normalized_text()[after:]\n\n    def close(self) -> None:\n        with contextlib.suppress(Exception):\n            os.close(self.master_fd)\n        if self.process.poll() is None:\n            self.process.terminate()\n            try:\n                self.process.wait(timeout=2)\n            except subprocess.TimeoutExpired:\n                self.process.kill()\n                self.process.wait(timeout=2)\n\n\ndef start_shell_pty(\n    *,\n    config_path: Path,\n    work_dir: Path,\n    home_dir: Path,\n    yolo: bool,\n    extra_args: list[str] | None = None,\n    columns: int = 120,\n    lines: int = 40,\n) -> ShellPTYProcess:\n    master_fd, slave_fd = pty.openpty()\n    _set_window_size(master_fd, columns=columns, lines=lines)\n    _set_window_size(slave_fd, columns=columns, lines=lines)\n    os.set_blocking(master_fd, False)\n\n    env = make_env(home_dir)\n    env[\"KIMI_CLI_NO_AUTO_UPDATE\"] = \"1\"\n    env[\"COLUMNS\"] = str(columns)\n    env[\"LINES\"] = str(lines)\n    env[\"TERM\"] = \"xterm-256color\"\n    env[\"PYTHONUTF8\"] = \"1\"\n    env[\"PROMPT_TOOLKIT_NO_CPR\"] = \"1\"\n    env.pop(\"NO_COLOR\", None)\n\n    cmd = [sys.executable, \"-m\", \"kimi_cli.cli\"]\n    if yolo:\n        cmd.append(\"--yolo\")\n    cmd.extend([\"--config-file\", str(config_path), \"--work-dir\", str(work_dir)])\n    if extra_args:\n        cmd.extend(extra_args)\n\n    process = subprocess.Popen(\n        cmd,\n        cwd=repo_root(),\n        stdin=slave_fd,\n        stdout=slave_fd,\n        stderr=slave_fd,\n        env=env,\n        preexec_fn=_preexec_for_tty(slave_fd),\n        close_fds=True,\n    )\n    os.close(slave_fd)\n    return ShellPTYProcess(process=process, master_fd=master_fd)\n\n\ndef find_session_dir(home_dir: Path, work_dir: Path) -> Path:\n    path_md5 = hashlib.md5(str(work_dir.resolve()).encode(\"utf-8\")).hexdigest()\n    sessions_root = home_dir / \".kimi\" / \"sessions\" / path_md5\n    session_dirs = [path for path in sessions_root.iterdir() if path.is_dir()]\n    if len(session_dirs) != 1:\n        raise AssertionError(f\"Expected exactly one session dir, got {session_dirs!r}\")\n    return session_dirs[0]\n\n\ndef find_tool_result_output(home_dir: Path, work_dir: Path, tool_call_id: str) -> Any:\n    session_dir = find_session_dir(home_dir, work_dir)\n    wire_path = session_dir / \"wire.jsonl\"\n    with wire_path.open(encoding=\"utf-8\") as handle:\n        for raw_line in handle:\n            line = raw_line.strip()\n            if not line:\n                continue\n            record = json.loads(line)\n            if record.get(\"type\") == \"metadata\":\n                continue\n            message = record.get(\"message\")\n            if not isinstance(message, dict):\n                continue\n            if message.get(\"type\") != \"ToolResult\":\n                continue\n            payload = message.get(\"payload\", {})\n            if not isinstance(payload, dict):\n                continue\n            if payload.get(\"tool_call_id\") != tool_call_id:\n                continue\n            return_value = payload.get(\"return_value\", {})\n            if not isinstance(return_value, dict):\n                continue\n            return return_value.get(\"output\")\n    raise AssertionError(f\"Missing ToolResult output for tool call {tool_call_id!r}\")\n\n\ndef list_turn_begin_inputs(home_dir: Path, work_dir: Path) -> list[str]:\n    session_dir = find_session_dir(home_dir, work_dir)\n    wire_path = session_dir / \"wire.jsonl\"\n    inputs: list[str] = []\n    with wire_path.open(encoding=\"utf-8\") as handle:\n        for raw_line in handle:\n            line = raw_line.strip()\n            if not line:\n                continue\n            record = json.loads(line)\n            if record.get(\"type\") == \"metadata\":\n                continue\n            message = record.get(\"message\")\n            if not isinstance(message, dict) or message.get(\"type\") != \"TurnBegin\":\n                continue\n            payload = message.get(\"payload\", {})\n            if not isinstance(payload, dict):\n                continue\n            user_input = payload.get(\"user_input\")\n            if isinstance(user_input, str):\n                inputs.append(user_input)\n                continue\n            if isinstance(user_input, list):\n                text_parts = []\n                for part in user_input:\n                    if isinstance(part, dict) and part.get(\"type\") == \"text\":\n                        text = part.get(\"text\")\n                        if isinstance(text, str):\n                            text_parts.append(text)\n                inputs.append(\"\".join(text_parts))\n    return inputs\n\n\ndef count_wire_messages(home_dir: Path, work_dir: Path, message_type: str) -> int:\n    session_dir = find_session_dir(home_dir, work_dir)\n    wire_path = session_dir / \"wire.jsonl\"\n    count = 0\n    with wire_path.open(encoding=\"utf-8\") as handle:\n        for raw_line in handle:\n            line = raw_line.strip()\n            if not line:\n                continue\n            record = json.loads(line)\n            if record.get(\"type\") == \"metadata\":\n                continue\n            message = record.get(\"message\")\n            if isinstance(message, dict) and message.get(\"type\") == message_type:\n                count += 1\n    return count\n\n\ndef wait_for_wire_message_count(\n    home_dir: Path,\n    work_dir: Path,\n    *,\n    message_type: str,\n    expected_count: int,\n    timeout: float = DEFAULT_TIMEOUT,\n) -> None:\n    deadline = time.monotonic() + timeout\n    last_count = 0\n    while True:\n        with contextlib.suppress(FileNotFoundError):\n            last_count = count_wire_messages(home_dir, work_dir, message_type)\n            if last_count >= expected_count:\n                return\n        if time.monotonic() >= deadline:\n            raise AssertionError(\n                f\"Timed out waiting for {message_type} count >= {expected_count}. \"\n                f\"Observed count: {last_count}.\"\n            )\n        time.sleep(0.05)\n\n\ndef read_until_prompt_ready(\n    shell: ShellPTYProcess,\n    *,\n    after: int,\n    timeout: float = DEFAULT_TIMEOUT,\n    quiet_period: float = 0.2,\n) -> str:\n    shell.read_until_contains(PROMPT_SYMBOL, after=after, timeout=timeout)\n    shell.wait_for_quiet(timeout=timeout, quiet_period=quiet_period, after=after)\n    return shell.normalized_text()\n"
  },
  {
    "path": "tests/e2e/test_basic_e2e.py",
    "content": "import json\nimport os\nimport subprocess\nfrom pathlib import Path\nfrom typing import cast\n\nimport pytest\nfrom kaos.path import KaosPath\n\n\ndef _repo_root() -> Path:\n    return Path(__file__).resolve().parents[1]\n\n\ndef _print_trace(label: str, text: str) -> None:\n    if os.getenv(\"KIMI_TEST_TRACE\") == \"1\":\n        print(\"-----\")\n        print(f\"{label}: {text}\")\n\n\ndef _collect_stdout(process: subprocess.Popen[str]) -> list[str]:\n    assert process.stdout is not None\n    lines: list[str] = []\n    for line in process.stdout:\n        line = line.rstrip(\"\\n\")\n        _print_trace(\"STDOUT\", line)\n        lines.append(line)\n    return lines\n\n\ndef _run_print_mode(config_path: Path, work_dir: Path, user_prompt: str) -> tuple[int, list[str]]:\n    cmd = [\n        \"uv\",\n        \"run\",\n        \"kimi\",\n        \"--print\",\n        \"--yolo\",\n        \"--input-format\",\n        \"text\",\n        \"--output-format\",\n        \"stream-json\",\n        \"--config-file\",\n        str(config_path),\n        \"--work-dir\",\n        str(work_dir),\n    ]\n    process = subprocess.Popen(\n        cmd,\n        cwd=_repo_root(),\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        text=True,\n        env=os.environ.copy(),\n    )\n    assert process.stdin is not None\n    process.stdin.write(user_prompt)\n    process.stdin.close()\n    stdout_lines = _collect_stdout(process)\n    return process.wait(), stdout_lines\n\n\ndef _run_shell_mode(config_path: Path, work_dir: Path, user_prompt: str) -> tuple[int, list[str]]:\n    cmd = [\n        \"uv\",\n        \"run\",\n        \"kimi\",\n        \"--yolo\",\n        \"--prompt\",\n        user_prompt,\n        \"--config-file\",\n        str(config_path),\n        \"--work-dir\",\n        str(work_dir),\n    ]\n    process = subprocess.Popen(\n        cmd,\n        cwd=_repo_root(),\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        text=True,\n        env=os.environ.copy(),\n    )\n    if process.stdin is not None:\n        process.stdin.close()\n    stdout_lines = _collect_stdout(process)\n    return process.wait(), stdout_lines\n\n\ndef _send_json(process: subprocess.Popen[str], payload: dict[str, object]) -> None:\n    assert process.stdin is not None\n    line = json.dumps(payload)\n    _print_trace(\"STDIN\", line)\n    process.stdin.write(line + \"\\n\")\n    process.stdin.flush()\n\n\ndef _collect_until_response(\n    process: subprocess.Popen[str], response_id: str\n) -> tuple[dict[str, object], list[dict[str, object]]]:\n    assert process.stdout is not None\n    events: list[dict[str, object]] = []\n    while True:\n        line = process.stdout.readline()\n        if not line:\n            break\n        line = line.strip()\n        if not line:\n            continue\n        _print_trace(\"STDOUT\", line)\n        try:\n            msg = json.loads(line)\n        except json.JSONDecodeError:\n            continue\n        if not isinstance(msg, dict):\n            continue\n        msg = cast(dict[str, object], msg)\n        msg_id = msg.get(\"id\")\n        if msg_id == response_id:\n            return msg, events\n        if msg.get(\"method\") == \"event\":\n            params = msg.get(\"params\")\n            if isinstance(params, dict):\n                events.append(cast(dict[str, object], params))\n    raise AssertionError(f\"Missing response for id {response_id!r}\")\n\n\ndef _wire_has_text(events: list[dict[str, object]], text: str) -> bool:\n    for event in events:\n        if event.get(\"type\") != \"ContentPart\":\n            continue\n        payload_obj = event.get(\"payload\")\n        if not isinstance(payload_obj, dict):\n            continue\n        payload = cast(dict[str, object], payload_obj)\n        if payload.get(\"type\") == \"text\" and text in str(payload.get(\"text\", \"\")):\n            return True\n    return False\n\n\ndef _run_wire_mode(\n    config_path: Path, work_dir: Path, user_prompt: str\n) -> tuple[int, dict[str, object], list[dict[str, object]]]:\n    cmd = [\n        \"uv\",\n        \"run\",\n        \"kimi\",\n        \"--wire\",\n        \"--yolo\",\n        \"--config-file\",\n        str(config_path),\n        \"--work-dir\",\n        str(work_dir),\n    ]\n    process = subprocess.Popen(\n        cmd,\n        cwd=_repo_root(),\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        text=True,\n        env=os.environ.copy(),\n    )\n\n    _send_json(\n        process,\n        {\n            \"jsonrpc\": \"2.0\",\n            \"id\": \"init\",\n            \"method\": \"initialize\",\n            \"params\": {\"protocol_version\": \"1.1\"},\n        },\n    )\n    init_resp, _ = _collect_until_response(process, \"init\")\n    assert \"result\" in init_resp\n\n    _send_json(\n        process,\n        {\n            \"jsonrpc\": \"2.0\",\n            \"id\": \"prompt-1\",\n            \"method\": \"prompt\",\n            \"params\": {\"user_input\": user_prompt},\n        },\n    )\n    resp, events = _collect_until_response(process, \"prompt-1\")\n\n    if process.stdin is not None:\n        process.stdin.close()\n    _collect_stdout(process)\n    return process.wait(), resp, events\n\n\n@pytest.mark.parametrize(\"mode\", [\"print\", \"wire\", \"shell\"])\nasync def test_scripted_echo_kimi_cli_agent_e2e(\n    temp_work_dir: KaosPath, tmp_path: Path, mode: str\n) -> None:\n    sample_js = \"\\n\".join(\n        [\n            \"function add(a, b) {\",\n            \"  return a + b;\",\n            \"}\",\n            \"\",\n            \"function main() {\",\n            \"  const result = add(2, 3);\",\n            \"  console.log(`2 + 3 = ${result}`);\",\n            \"}\",\n            \"\",\n            \"main();\",\n            \"\",\n        ]\n    )\n    await (temp_work_dir / \"sample.js\").write_text(sample_js)\n\n    translated_py = \"\\n\".join(\n        [\n            \"def add(a, b):\",\n            \"    return a + b\",\n            \"\",\n            \"def main():\",\n            \"    result = add(2, 3)\",\n            '    print(f\"2 + 3 = {result}\")',\n            \"\",\n            'if __name__ == \"__main__\":',\n            \"    main()\",\n            \"\",\n        ]\n    )\n\n    read_args = json.dumps({\"path\": \"sample.js\"})\n    write_args = json.dumps(\n        {\n            \"path\": \"translated.py\",\n            \"content\": translated_py,\n            \"mode\": \"overwrite\",\n        }\n    )\n    read_call = {\"id\": \"ReadFile:0\", \"name\": \"ReadFile\", \"arguments\": read_args}\n    write_call = {\"id\": \"WriteFile:1\", \"name\": \"WriteFile\", \"arguments\": write_args}\n\n    scripts = [\n        \"\\n\".join(\n            [\n                \"id: scripted-1\",\n                'usage: {\"input_other\": 18, \"output\": 3}',\n                f\"tool_call: {json.dumps(read_call)}\",\n            ]\n        ),\n        \"\\n\".join(\n            [\n                \"id: scripted-2\",\n                'usage: {\"input_other\": 22, \"output\": 4}',\n                f\"tool_call: {json.dumps(write_call)}\",\n            ]\n        ),\n        \"\\n\".join(\n            [\n                \"id: scripted-3\",\n                'usage: {\"input_other\": 12, \"output\": 2}',\n                \"text: Translation completed successfully.\",\n            ]\n        ),\n    ]\n\n    scripts_path = tmp_path / \"scripts.json\"\n    scripts_path.write_text(json.dumps(scripts), encoding=\"utf-8\")\n\n    config_path = tmp_path / \"config.json\"\n    trace_env = os.getenv(\"KIMI_SCRIPTED_ECHO_TRACE\", \"0\")\n    config_data = {\n        \"default_model\": \"scripted\",\n        \"models\": {\n            \"scripted\": {\n                \"provider\": \"scripted_provider\",\n                \"model\": \"scripted_echo\",\n                \"max_context_size\": 100000,\n            }\n        },\n        \"providers\": {\n            \"scripted_provider\": {\n                \"type\": \"_scripted_echo\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\n                    \"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_path),\n                    \"KIMI_SCRIPTED_ECHO_TRACE\": trace_env,\n                },\n            }\n        },\n    }\n    config_path.write_text(json.dumps(config_data), encoding=\"utf-8\")\n\n    user_prompt = (\n        \"You are a code translation assistant.\\n\\n\"\n        \"Task:\\n\"\n        \"- Read the file `sample.js` in the current working directory.\\n\"\n        \"- Translate it into idiomatic Python 3.\\n\"\n        \"- Write the translated code to `translated.py` in the current working directory.\\n\\n\"\n        \"Rules:\\n\"\n        \"- You must read the file from disk; do not guess its contents.\\n\"\n        \"- Preserve behavior and output.\\n\"\n        \"- Write only Python code in translated.py (no Markdown).\\n\"\n        \"- Overwrite translated.py if it already exists.\\n\"\n        \"- After writing, reply with a single short ASCII confirmation sentence.\\n\"\n    )\n\n    work_dir = temp_work_dir.unsafe_to_local_path()\n    _print_trace(\"USER INPUT\", json.dumps(user_prompt))\n\n    if mode == \"print\":\n        return_code, stdout_lines = _run_print_mode(config_path, work_dir, user_prompt)\n        assert return_code == 0\n        assert any(\"Translation completed successfully.\" in line for line in stdout_lines)\n    elif mode == \"wire\":\n        return_code, resp, events = _run_wire_mode(config_path, work_dir, user_prompt)\n        assert return_code == 0\n        result_obj = resp.get(\"result\")\n        assert isinstance(result_obj, dict)\n        result = cast(dict[str, object], result_obj)\n        assert result.get(\"status\") == \"finished\"\n        assert _wire_has_text(events, \"Translation completed successfully.\")\n    elif mode == \"shell\":\n        return_code, stdout_lines = _run_shell_mode(config_path, work_dir, user_prompt)\n        assert return_code == 0\n        assert any(\"Translation completed successfully.\" in line for line in stdout_lines)\n    else:\n        raise AssertionError(f\"Unknown mode: {mode}\")\n\n    translated_path = work_dir / \"translated.py\"\n    assert translated_path.read_text(encoding=\"utf-8\") == translated_py\n"
  },
  {
    "path": "tests/e2e/test_cli_error_output.py",
    "content": "\"\"\"E2E tests for CLI startup/argument error output.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nfrom inline_snapshot import snapshot\n\n\ndef _repo_root() -> Path:\n    return Path(__file__).resolve().parents[2]\n\n\ndef _run_kimi(args: list[str], *, share_dir: Path) -> subprocess.CompletedProcess[str]:\n    env = os.environ.copy()\n    env[\"KIMI_SHARE_DIR\"] = str(share_dir)\n    # Stabilize rich/Click formatting across environments for snapshot tests.\n    env[\"COLUMNS\"] = \"120\"\n    env[\"LINES\"] = \"40\"\n    # Run via `python -m` to avoid `uv run kimi` build/progress output interfering with snapshots.\n    cmd = [sys.executable, \"-m\", \"kimi_cli.cli\", *args]\n    return subprocess.run(\n        cmd,\n        cwd=_repo_root(),\n        capture_output=True,\n        text=True,\n        env=env,\n        timeout=30,\n    )\n\n\ndef _normalize_cli_error_output(text: str) -> str:\n    \"\"\"Normalize Rich/Click error boxes across platforms for snapshot tests.\"\"\"\n    text = text.replace(\"\\r\\n\", \"\\n\")\n    lines: list[str] = []\n    in_box = False\n    for line in text.splitlines():\n        if line.startswith((\"╭\", \"┌\")) and \"Error\" in line:\n            in_box = True\n            lines.append(\"Error:\")\n            continue\n        if in_box and line.startswith((\"╰\", \"└\")):\n            in_box = False\n            continue\n        if in_box and line.startswith((\"│\", \"┃\")) and line.endswith((\"│\", \"┃\")):\n            inner = line[1:-1].strip()\n            if inner:\n                lines.append(inner)\n            continue\n        lines.append(line.rstrip())\n    normalized = \"\\n\".join(lines)\n    if text.endswith(\"\\n\"):\n        normalized += \"\\n\"\n    return normalized\n\n\ndef test_config_option_requires_argument_is_reported(tmp_path: Path) -> None:\n    share_dir = tmp_path / \"share\"\n    result = _run_kimi([\"--config\"], share_dir=share_dir)\n    assert result.returncode == snapshot(2)\n    assert result.stdout == snapshot(\"\")\n    assert _normalize_cli_error_output(result.stderr) == snapshot(\n        \"\"\"\\\nError:\nOption '--config' requires an argument.\n\"\"\"\n    )\n\n\ndef test_config_option_help_value_is_reported(tmp_path: Path) -> None:\n    share_dir = tmp_path / \"share\"\n    result = _run_kimi([\"--config\", \"--help\"], share_dir=share_dir)\n    assert result.returncode == snapshot(2)\n    assert result.stdout == snapshot(\"\")\n    normalized = _normalize_cli_error_output(result.stderr)\n    assert normalized.startswith(\n        \"\"\"\\\nUsage: python -m kimi_cli.cli [OPTIONS] COMMAND [ARGS]...\nTry 'python -m kimi_cli.cli -h' for help.\nError:\n\"\"\"\n    )\n    assert (\n        \"Invalid value for --config: Invalid configuration text: Expecting value: line 1 column 1\"\n        in normalized\n    )\n    assert \"character: '\\\\x00' at line 1 col 6\" in normalized.replace(\"\\n\", \"\")\n\n\ndef test_invalid_config_toml_is_reported(tmp_path: Path) -> None:\n    share_dir = tmp_path / \"share\"\n    config_path = tmp_path / \"bad-config.toml\"\n    config_path.write_text(\"this is not toml =\\n\", encoding=\"utf-8\")\n\n    result = _run_kimi(\n        [\"--print\", \"--yolo\", \"--prompt\", \"hello\", \"--config-file\", str(config_path)],\n        share_dir=share_dir,\n    )\n\n    log_path = share_dir / \"logs\" / \"kimi.log\"\n    assert result.returncode == snapshot(1)\n    assert result.stdout == snapshot(\"\")\n    assert _normalize_cli_error_output(result.stderr) == snapshot(\n        f\"\"\"\\\nInvalid TOML in configuration file {config_path}: Invalid key \"this is not toml\" at line 1 col 17\nSee logs: {log_path}\n\"\"\"\n    )\n\n\ndef test_continue_without_previous_session_is_reported(tmp_path: Path) -> None:\n    share_dir = tmp_path / \"share\"\n    work_dir = tmp_path / \"work\"\n    work_dir.mkdir(parents=True, exist_ok=True)\n    config_path = tmp_path / \"config.json\"\n    config_path.write_text(\n        '{\"default_model\":\"\",\"models\":{},\"providers\":{}}',\n        encoding=\"utf-8\",\n    )\n\n    result = _run_kimi(\n        [\n            \"--continue\",\n            \"--print\",\n            \"--yolo\",\n            \"--prompt\",\n            \"hello\",\n            \"--config-file\",\n            str(config_path),\n            \"--work-dir\",\n            str(work_dir),\n        ],\n        share_dir=share_dir,\n    )\n    assert result.returncode == snapshot(2)\n    assert result.stdout == snapshot(\"\")\n    assert _normalize_cli_error_output(result.stderr) == snapshot(\n        \"\"\"\\\nUsage: python -m kimi_cli.cli [OPTIONS] COMMAND [ARGS]...\nTry 'python -m kimi_cli.cli -h' for help.\nError:\nInvalid value for --continue: No previous session found for the working directory\n\"\"\"\n    )\n"
  },
  {
    "path": "tests/e2e/test_media_e2e.py",
    "content": "import json\nimport os\nimport subprocess\nfrom pathlib import Path\nfrom typing import cast\n\nimport pytest\nfrom kaos.path import KaosPath\n\n\ndef _repo_root() -> Path:\n    return Path(__file__).resolve().parents[1]\n\n\ndef _print_trace(label: str, text: str) -> None:\n    if os.getenv(\"KIMI_TEST_TRACE\") == \"1\":\n        print(\"-----\")\n        print(f\"{label}: {text}\")\n\n\ndef _send_json(process: subprocess.Popen[str], payload: dict[str, object]) -> None:\n    assert process.stdin is not None\n    line = json.dumps(payload)\n    _print_trace(\"STDIN\", line)\n    process.stdin.write(line + \"\\n\")\n    process.stdin.flush()\n\n\ndef _collect_until_response(\n    process: subprocess.Popen[str], response_id: str\n) -> tuple[dict[str, object], list[dict[str, object]]]:\n    assert process.stdout is not None\n    events: list[dict[str, object]] = []\n    while True:\n        line = process.stdout.readline()\n        if not line:\n            break\n        line = line.strip()\n        if not line:\n            continue\n        _print_trace(\"STDOUT\", line)\n        try:\n            msg = json.loads(line)\n        except json.JSONDecodeError:\n            continue\n        if not isinstance(msg, dict):\n            continue\n        msg = cast(dict[str, object], msg)\n        msg_id = msg.get(\"id\")\n        if msg_id == response_id:\n            return msg, events\n        if msg.get(\"method\") == \"event\":\n            params = msg.get(\"params\")\n            if isinstance(params, dict):\n                events.append(cast(dict[str, object], params))\n    raise AssertionError(f\"Missing response for id {response_id!r}\")\n\n\ndef _turn_has_part_type(events: list[dict[str, object]], part_type: str) -> bool:\n    for event in events:\n        if event.get(\"type\") != \"TurnBegin\":\n            continue\n        payload_obj = event.get(\"payload\")\n        if not isinstance(payload_obj, dict):\n            continue\n        payload = cast(dict[str, object], payload_obj)\n        user_input = payload.get(\"user_input\")\n        if not isinstance(user_input, list):\n            continue\n        for part in user_input:\n            if isinstance(part, dict) and cast(dict[str, object], part).get(\"type\") == part_type:\n                return True\n    return False\n\n\ndef _has_content_part(events: list[dict[str, object]], part_type: str) -> bool:\n    for event in events:\n        if event.get(\"type\") != \"ContentPart\":\n            continue\n        payload_obj = event.get(\"payload\")\n        if (\n            isinstance(payload_obj, dict)\n            and cast(dict[str, object], payload_obj).get(\"type\") == part_type\n        ):\n            return True\n    return False\n\n\ndef _has_text_content(events: list[dict[str, object]], text: str) -> bool:\n    for event in events:\n        if event.get(\"type\") != \"ContentPart\":\n            continue\n        payload_obj = event.get(\"payload\")\n        if not isinstance(payload_obj, dict):\n            continue\n        payload = cast(dict[str, object], payload_obj)\n        if payload.get(\"type\") == \"text\" and text in str(payload.get(\"text\", \"\")):\n            return True\n    return False\n\n\ndef _parse_message_content(line: str) -> list[dict[str, object]]:\n    try:\n        msg = json.loads(line)\n    except json.JSONDecodeError:\n        return []\n    if not isinstance(msg, dict):\n        return []\n    content = msg.get(\"content\")\n    if isinstance(content, list):\n        return [part for part in content if isinstance(part, dict)]\n    if isinstance(content, str):\n        return [{\"type\": \"text\", \"text\": content}]\n    return []\n\n\ndef _content_has_part(parts: list[dict[str, object]], part_type: str) -> bool:\n    return any(part.get(\"type\") == part_type for part in parts)\n\n\ndef _content_has_text(parts: list[dict[str, object]], text: str) -> bool:\n    return any(part.get(\"type\") == \"text\" and text in str(part.get(\"text\", \"\")) for part in parts)\n\n\ndef _run_print_mode(\n    config_path: Path, work_dir: Path, messages: list[dict[str, object]]\n) -> tuple[int, list[str]]:\n    cmd = [\n        \"uv\",\n        \"run\",\n        \"kimi\",\n        \"--print\",\n        \"--yolo\",\n        \"--input-format\",\n        \"stream-json\",\n        \"--output-format\",\n        \"stream-json\",\n        \"--config-file\",\n        str(config_path),\n        \"--work-dir\",\n        str(work_dir),\n    ]\n    process = subprocess.Popen(\n        cmd,\n        cwd=_repo_root(),\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        text=True,\n        env=os.environ.copy(),\n    )\n    assert process.stdin is not None\n    for msg in messages:\n        _send_json(process, msg)\n    process.stdin.close()\n\n    stdout_lines: list[str] = []\n    assert process.stdout is not None\n    for line in process.stdout:\n        line = line.rstrip(\"\\n\")\n        _print_trace(\"STDOUT\", line)\n        stdout_lines.append(line)\n    return process.wait(), stdout_lines\n\n\n@pytest.mark.parametrize(\"mode\", [\"print\", \"wire\"])\ndef test_scripted_echo_media_e2e(temp_work_dir: KaosPath, tmp_path: Path, mode: str) -> None:\n    image_url = \"data:image/png;base64,AAAA\"\n    video_url = \"data:video/mp4;base64,AAAA\"\n\n    scripts = [\n        \"\\n\".join(\n            [\n                \"id: scripted-1\",\n                'usage: {\"input_other\": 11, \"output\": 5}',\n                \"think: analyzing the image\",\n                \"text: The image shows a simple scene.\",\n            ]\n        ),\n        \"\\n\".join(\n            [\n                \"id: scripted-2\",\n                'usage: {\"input_other\": 13, \"output\": 6}',\n                \"think: analyzing the video\",\n                \"text: The video appears to be a short clip.\",\n            ]\n        ),\n    ]\n\n    scripts_path = tmp_path / \"scripts.json\"\n    scripts_path.write_text(json.dumps(scripts), encoding=\"utf-8\")\n\n    config_path = tmp_path / \"config.json\"\n    trace_env = os.getenv(\"KIMI_SCRIPTED_ECHO_TRACE\", \"0\")\n    config_data = {\n        \"default_model\": \"scripted\",\n        \"models\": {\n            \"scripted\": {\n                \"provider\": \"scripted_provider\",\n                \"model\": \"scripted_echo\",\n                \"max_context_size\": 100000,\n                \"capabilities\": [\"image_in\", \"video_in\", \"thinking\"],\n            }\n        },\n        \"providers\": {\n            \"scripted_provider\": {\n                \"type\": \"_scripted_echo\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\n                    \"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_path),\n                    \"KIMI_SCRIPTED_ECHO_TRACE\": trace_env,\n                },\n            }\n        },\n    }\n    config_path.write_text(json.dumps(config_data), encoding=\"utf-8\")\n\n    work_dir = temp_work_dir.unsafe_to_local_path()\n    if mode == \"print\":\n        messages = [\n            {\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": \"Describe this image.\"},\n                    {\"type\": \"image_url\", \"image_url\": {\"url\": image_url}},\n                ],\n            },\n            {\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": \"Describe this video.\"},\n                    {\"type\": \"video_url\", \"video_url\": {\"url\": video_url}},\n                ],\n            },\n        ]\n        return_code, stdout_lines = _run_print_mode(config_path, work_dir, messages)\n        assert return_code == 0\n        parsed_contents = [_parse_message_content(line) for line in stdout_lines]\n        parsed_contents = [parts for parts in parsed_contents if parts]\n        assert len(parsed_contents) >= 2\n        assert _content_has_part(parsed_contents[0], \"think\")\n        assert _content_has_text(parsed_contents[0], \"The image shows a simple scene.\")\n        assert _content_has_part(parsed_contents[1], \"think\")\n        assert _content_has_text(parsed_contents[1], \"The video appears to be a short clip.\")\n    elif mode == \"wire\":\n        cmd = [\n            \"uv\",\n            \"run\",\n            \"kimi\",\n            \"--wire\",\n            \"--yolo\",\n            \"--config-file\",\n            str(config_path),\n            \"--work-dir\",\n            str(work_dir),\n        ]\n        process = subprocess.Popen(\n            cmd,\n            cwd=_repo_root(),\n            stdin=subprocess.PIPE,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.STDOUT,\n            text=True,\n            env=os.environ.copy(),\n        )\n\n        _send_json(\n            process,\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"init\",\n                \"method\": \"initialize\",\n                \"params\": {\"protocol_version\": \"1.1\"},\n            },\n        )\n        init_resp, _ = _collect_until_response(process, \"init\")\n        assert \"result\" in init_resp\n\n        _send_json(\n            process,\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\n                    \"user_input\": [\n                        {\"type\": \"text\", \"text\": \"Describe this image.\"},\n                        {\"type\": \"image_url\", \"image_url\": {\"url\": image_url}},\n                    ]\n                },\n            },\n        )\n        resp1, events1 = _collect_until_response(process, \"prompt-1\")\n        result1_obj = resp1.get(\"result\")\n        assert isinstance(result1_obj, dict)\n        result1 = cast(dict[str, object], result1_obj)\n        assert result1.get(\"status\") == \"finished\"\n        assert _turn_has_part_type(events1, \"image_url\")\n        assert _has_content_part(events1, \"think\")\n        assert _has_text_content(events1, \"The image shows a simple scene.\")\n\n        _send_json(\n            process,\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-2\",\n                \"method\": \"prompt\",\n                \"params\": {\n                    \"user_input\": [\n                        {\"type\": \"text\", \"text\": \"Describe this video.\"},\n                        {\"type\": \"video_url\", \"video_url\": {\"url\": video_url}},\n                    ]\n                },\n            },\n        )\n        resp2, events2 = _collect_until_response(process, \"prompt-2\")\n        result2_obj = resp2.get(\"result\")\n        assert isinstance(result2_obj, dict)\n        result2 = cast(dict[str, object], result2_obj)\n        assert result2.get(\"status\") == \"finished\"\n        assert _turn_has_part_type(events2, \"video_url\")\n        assert _has_content_part(events2, \"think\")\n        assert _has_text_content(events2, \"The video appears to be a short clip.\")\n\n        assert process.stdin is not None\n        process.stdin.close()\n        process.wait(timeout=10)\n    else:\n        raise AssertionError(f\"Unknown mode: {mode}\")\n"
  },
  {
    "path": "tests/e2e/test_shell_pty_e2e.py",
    "content": "from __future__ import annotations\n\nimport json\nimport sys\nimport textwrap\nimport time\nfrom pathlib import Path\n\nimport pytest\n\nfrom tests.e2e.shell_pty_helpers import (\n    count_wire_messages,\n    find_session_dir,\n    find_tool_result_output,\n    list_turn_begin_inputs,\n    make_home_dir,\n    make_work_dir,\n    read_until_prompt_ready,\n    start_shell_pty,\n    wait_for_wire_message_count,\n    write_scripted_config,\n)\nfrom tests_e2e.wire_helpers import build_ask_user_tool_call, build_shell_tool_call\n\npytestmark = pytest.mark.skipif(\n    sys.platform == \"win32\",\n    reason=\"Shell PTY E2E tests require a Unix-like PTY.\",\n)\n\n\ndef _read_until_prompt(shell, *, after: int, timeout: float = 15.0) -> str:\n    return read_until_prompt_ready(shell, after=after, timeout=timeout)\n\n\ndef _exit_shell(shell) -> None:\n    last_error: AssertionError | None = None\n    for _ in range(2):\n        exit_mark = shell.mark()\n        shell.send_line(\"exit\")\n        try:\n            shell.read_until_contains(\"Bye!\", after=exit_mark, timeout=4.0)\n            assert shell.wait() == 0\n            return\n        except AssertionError as exc:\n            last_error = exc\n            shell.wait_for_quiet(timeout=1.5, quiet_period=0.3, after=exit_mark)\n    assert last_error is not None\n    raise last_error\n\n\ndef test_shell_smoke_multiturn_scripted_echo(tmp_path: Path) -> None:\n    config_path = write_scripted_config(\n        tmp_path,\n        [\n            \"text: Smoke turn one completed.\",\n            \"text: Smoke turn two completed.\",\n        ],\n    )\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=prompt_mark)\n\n        turn_one_mark = shell.mark()\n        shell.send_line(\"run first smoke turn\")\n        shell.read_until_contains(\"Smoke turn one completed.\", after=turn_one_mark)\n        wait_for_wire_message_count(\n            home_dir,\n            work_dir,\n            message_type=\"TurnEnd\",\n            expected_count=1,\n        )\n        first_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=first_prompt_mark)\n\n        turn_two_mark = shell.mark()\n        shell.send_line(\"run second smoke turn\")\n        shell.read_until_contains(\"Smoke turn two completed.\", after=turn_two_mark)\n        wait_for_wire_message_count(\n            home_dir,\n            work_dir,\n            message_type=\"TurnEnd\",\n            expected_count=2,\n        )\n        second_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=second_prompt_mark)\n\n        assert count_wire_messages(home_dir, work_dir, \"TurnEnd\") == 2\n    finally:\n        shell.close()\n\n\ndef test_shell_running_prompt_preserves_unsubmitted_draft(tmp_path: Path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: Running long task.\",\n                build_shell_tool_call(\"tc-draft\", \"sleep 1.5\"),\n            ]\n        ),\n        \"text: First turn finished.\",\n        \"text: Draft carried over.\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        first_turn_mark = shell.mark()\n        shell.send_line(\"start long turn\")\n        shell.read_until_contains(\"Using Shell (sleep 1.5)\", after=first_turn_mark, timeout=15.0)\n        time.sleep(0.3)\n        shell.send_text(\"follow-up draft\")\n        shell.read_until_contains(\"First turn finished.\", after=first_turn_mark, timeout=15.0)\n        first_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=first_prompt_mark)\n\n        second_turn_mark = shell.mark()\n        shell.send_key(\"enter\")\n        shell.read_until_contains(\"Draft carried over.\", after=second_turn_mark, timeout=15.0)\n        second_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=second_prompt_mark)\n\n        assert list_turn_begin_inputs(home_dir, work_dir) == [\n            \"start long turn\",\n            \"follow-up draft\",\n        ]\n    finally:\n        shell.close()\n\n\ndef test_shell_running_prompt_ignores_shift_tab_plan_toggle(tmp_path: Path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: Running long task.\",\n                build_shell_tool_call(\"tc-plan-mode\", \"sleep 1.5\"),\n            ]\n        ),\n        \"text: First turn finished.\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        turn_mark = shell.mark()\n        shell.send_line(\"start long turn\")\n        shell.read_until_contains(\"Using Shell (sleep 1.5)\", after=turn_mark, timeout=15.0)\n        shift_tab_mark = shell.mark()\n        shell.send_key(\"s_tab\")\n        shell.read_until_contains(\"First turn finished.\", after=turn_mark, timeout=15.0)\n        prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=prompt_mark)\n\n        running_segment = shell.normalized_text()[shift_tab_mark:]\n        assert \"plan mode ON\" not in running_segment\n        assert \"plan mode OFF\" not in running_segment\n    finally:\n        shell.close()\n\n\ndef test_shell_exit_command_from_idle_prompt(tmp_path: Path) -> None:\n    config_path = write_scripted_config(tmp_path, [])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n        _exit_shell(shell)\n    finally:\n        shell.close()\n\n\ndef test_shell_shows_mcp_loading_without_blocking_input(tmp_path: Path) -> None:\n    server_path = tmp_path / \"slow_mcp_server.py\"\n    server_path.write_text(\n        textwrap.dedent(\n            \"\"\"\n            import time\n            from fastmcp.server import FastMCP\n\n            time.sleep(1.5)\n            server = FastMCP(\"slow-mcp\")\n\n            @server.tool\n            def ping(text: str) -> str:\n                return f\"pong:{{text}}\"\n\n            if __name__ == \"__main__\":\n                server.run(transport=\"stdio\", show_banner=False)\n            \"\"\"\n        ).strip()\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    mcp_config_path = tmp_path / \"mcp.json\"\n    mcp_config_path.write_text(\n        json.dumps(\n            {\n                \"mcpServers\": {\n                    \"slow-test\": {\n                        \"command\": sys.executable,\n                        \"args\": [str(server_path)],\n                    }\n                }\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n    config_path = write_scripted_config(tmp_path, [])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n        extra_args=[\"--mcp-config-file\", str(mcp_config_path)],\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=prompt_mark)\n        shell.read_until_contains(\"MCP Servers:\", after=prompt_mark, timeout=15.0)\n        transcript = shell.normalized_text()[prompt_mark:]\n        assert \"slow-test\" in transcript\n        assert \"(pending)\" in transcript or \"(connecting)\" in transcript\n        assert \"ping\" not in transcript\n\n        _exit_shell(shell)\n    finally:\n        shell.close()\n\n\ndef test_shell_ctrl_d_from_idle_prompt_after_completed_turn_exits_cleanly(tmp_path: Path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: First turn finished.\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        turn_mark = shell.mark()\n        shell.send_line(\"run first turn\")\n        shell.read_until_contains(\"First turn finished.\", after=turn_mark, timeout=15.0)\n        prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=prompt_mark)\n\n        eof_mark = shell.mark()\n        shell.send_key(\"ctrl_d\")\n        shell.read_until_contains(\"Bye!\", after=eof_mark, timeout=4.0)\n        assert shell.wait() == 0\n    finally:\n        shell.close()\n\n\ndef test_shell_ctrl_c_from_idle_prompt_after_completed_turn_shows_tip(tmp_path: Path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: First turn finished.\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        turn_mark = shell.mark()\n        shell.send_line(\"run first turn\")\n        shell.read_until_contains(\"First turn finished.\", after=turn_mark, timeout=15.0)\n        prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=prompt_mark)\n\n        interrupt_mark = shell.mark()\n        shell.send_key(\"ctrl_c\")\n        shell.read_until_contains(\n            \"Tip: press Ctrl-D or send 'exit' to quit\",\n            after=interrupt_mark,\n            timeout=4.0,\n        )\n        _read_until_prompt(shell, after=shell.mark())\n    finally:\n        shell.close()\n\n\ndef test_shell_question_roundtrip_with_other_answer(tmp_path: Path) -> None:\n    question_payload = [\n        {\n            \"question\": \"Pick a base option?\",\n            \"header\": \"Base\",\n            \"options\": [\n                {\"label\": \"Alpha\", \"description\": \"Pick alpha\"},\n                {\"label\": \"Beta\", \"description\": \"Pick beta\"},\n            ],\n        },\n        {\n            \"question\": \"Need anything else?\",\n            \"header\": \"Extra\",\n            \"options\": [\n                {\"label\": \"Docs\", \"description\": \"Need docs\"},\n                {\"label\": \"Tests\", \"description\": \"Need tests\"},\n            ],\n        },\n    ]\n    config_path = write_scripted_config(\n        tmp_path,\n        [\n            \"\\n\".join(\n                [\n                    \"text: About to ask questions.\",\n                    build_ask_user_tool_call(\"tc-q1\", question_payload),\n                ]\n            ),\n            \"text: Question flow complete.\",\n        ],\n    )\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        turn_mark = shell.mark()\n        shell.send_line(\"ask the interactive questions\")\n        # Wait for the complete question panel to render (including keyboard\n        # hints at the bottom) before sending a key.  On slow CI runners,\n        # prompt_toolkit may not be ready to process key bindings until the\n        # full layout has been painted at least once.\n        shell.read_until_contains(\"esc exit\", after=turn_mark)\n        # Small delay for prompt_toolkit's event loop to finish processing\n        # the render and become ready for input.\n        time.sleep(0.5)\n        # Select \"Beta\" (option 2) for the first question.  The key press\n        # auto-submits and the panel advances to Q2.  We wait for the \"✓\"\n        # checkmark in the tab bar – prompt_toolkit's differential renderer\n        # can fragment the full question text across cursor-positioning\n        # escapes, so the literal \"Need anything else?\" may not survive\n        # CSI stripping in the accumulated PTY transcript.\n        shell.send_key(\"2\")\n        shell.read_until_contains(\"\\u2713\", after=turn_mark)\n        # Select \"Other\" (option 3) for the second question\n        shell.send_key(\"3\")\n        shell.send_key(\"enter\")\n        shell.read_until_contains(\n            \"Enter the custom answer, then press Enter.\", after=turn_mark, timeout=15.0\n        )\n        shell.send_line(\"Custom follow-up\")\n        shell.read_until_contains(\"Question flow complete.\", after=turn_mark, timeout=15.0)\n        prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=prompt_mark)\n\n        output = find_tool_result_output(home_dir, work_dir, \"tc-q1\")\n        assert isinstance(output, str)\n        assert json.loads(output) == {\n            \"answers\": {\n                \"Pick a base option?\": \"Beta\",\n                \"Need anything else?\": \"Custom follow-up\",\n            }\n        }\n    finally:\n        shell.close()\n\n\ndef test_shell_approval_roundtrip_and_session_auto_approve(tmp_path: Path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: First approval incoming.\",\n                build_shell_tool_call(\"tc-a1\", \"printf first-approval > approval_one.txt\"),\n            ]\n        ),\n        \"text: First approval done.\",\n        \"\\n\".join(\n            [\n                \"text: Second approval incoming.\",\n                build_shell_tool_call(\"tc-a2\", \"printf second-approval > approval_two.txt\"),\n            ]\n        ),\n        \"text: Session approval saved.\",\n        \"\\n\".join(\n            [\n                \"text: Third shell action incoming.\",\n                build_shell_tool_call(\"tc-a3\", \"printf auto-approved > approval_three.txt\"),\n            ]\n        ),\n        \"text: Third shell action completed.\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        first_mark = shell.mark()\n        shell.send_line(\"run first approval flow\")\n        shell.read_until_contains(\"requesting approval to run command\", after=first_mark)\n        shell.send_key(\"1\")\n        shell.read_until_contains(\"First approval done.\", after=first_mark)\n        first_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=first_prompt_mark)\n        assert (work_dir / \"approval_one.txt\").read_text(encoding=\"utf-8\") == \"first-approval\"\n\n        second_mark = shell.mark()\n        shell.send_line(\"run second approval flow\")\n        shell.read_until_contains(\"requesting approval to run command\", after=second_mark)\n        shell.send_key(\"2\")\n        shell.read_until_contains(\"Session approval saved.\", after=second_mark)\n        second_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=second_prompt_mark)\n        assert (work_dir / \"approval_two.txt\").read_text(encoding=\"utf-8\") == \"second-approval\"\n\n        third_mark = shell.mark()\n        shell.send_line(\"run third approval flow\")\n        shell.read_until_contains(\"Third shell action completed.\", after=third_mark)\n        third_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=third_prompt_mark)\n        third_segment = shell.normalized_text()[third_mark:]\n        assert \"requesting approval to run command\" not in third_segment\n        assert (work_dir / \"approval_three.txt\").read_text(encoding=\"utf-8\") == \"auto-approved\"\n    finally:\n        shell.close()\n\n\ndef test_shell_approval_reject_and_recover(tmp_path: Path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: Reject path incoming.\",\n                build_shell_tool_call(\"tc-r1\", \"printf rejected > should_not_exist.txt\"),\n            ]\n        ),\n        \"text: Recovery turn completed.\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        reject_mark = shell.mark()\n        shell.send_line(\"reject this shell action\")\n        shell.read_until_contains(\n            \"requesting approval to run command\", after=reject_mark, timeout=15.0\n        )\n        shell.send_key(\"3\")\n        # Wait for the tool call to be fully processed (confirmed by \"Used Shell\" marker)\n        # before looking for the prompt, to avoid matching ✨ from a mid-turn redraw.\n        shell.read_until_contains(\"Used Shell\", after=reject_mark, timeout=15.0)\n        reject_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=reject_prompt_mark)\n        assert not (work_dir / \"should_not_exist.txt\").exists()\n\n        recovery_mark = shell.mark()\n        shell.send_line(\"prove recovery works\")\n        shell.read_until_contains(\"Recovery turn completed.\", after=recovery_mark, timeout=15.0)\n        recovery_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=recovery_prompt_mark)\n    finally:\n        shell.close()\n\n\ndef test_shell_mode_toggle_roundtrip(tmp_path: Path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: Agent mode recovered.\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        toggle_mark = shell.mark()\n        shell.send_key(\"ctrl_x\")\n        shell.wait_for_quiet(after=toggle_mark)\n        shell.send_line(\"printf shell-mode-ok\")\n        shell.read_until_contains(\"shell-mode-ok\", after=toggle_mark)\n        shell_prompt_mark = shell.mark()\n        shell.read_until_contains(\"$\", after=shell_prompt_mark)\n        shell.wait_for_quiet(after=shell_prompt_mark)\n\n        toggle_back_mark = shell.mark()\n        shell.send_key(\"ctrl_x\")\n        shell.wait_for_quiet(after=toggle_back_mark)\n\n        agent_mark = shell.mark()\n        shell.send_line(\"return to agent mode\")\n        shell.read_until_contains(\"Agent mode recovered.\", after=agent_mark)\n        agent_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=agent_prompt_mark)\n\n        assert list_turn_begin_inputs(home_dir, work_dir) == [\"return to agent mode\"]\n    finally:\n        shell.close()\n\n\ndef test_shell_session_resume_and_replay(tmp_path: Path) -> None:\n    first_config_path = write_scripted_config(tmp_path, [\"text: Replay first assistant line.\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    first_shell = start_shell_pty(\n        config_path=first_config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        first_shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(first_shell, after=first_shell.mark())\n\n        first_turn_mark = first_shell.mark()\n        first_shell.send_line(\"remember-session-replay\")\n        first_shell.read_until_contains(\"Replay first assistant line.\", after=first_turn_mark)\n        _read_until_prompt(first_shell, after=first_turn_mark)\n    finally:\n        first_shell.close()\n\n    session_id = find_session_dir(home_dir, work_dir).name\n    resume_root = tmp_path / \"resume\"\n    resume_root.mkdir()\n    second_config_path = write_scripted_config(\n        resume_root,\n        [\"text: Replay second assistant line.\"],\n    )\n    second_shell = start_shell_pty(\n        config_path=second_config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n        extra_args=[\"--session\", session_id],\n    )\n\n    try:\n        second_shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        second_shell.read_until_contains(\"remember-session-replay\")\n        second_shell.read_until_contains(\"Replay first assistant line.\")\n        _read_until_prompt(second_shell, after=second_shell.mark())\n\n        second_turn_mark = second_shell.mark()\n        second_shell.send_line(\"continue-after-replay\")\n        second_shell.read_until_contains(\"Replay second assistant line.\", after=second_turn_mark)\n        second_prompt_mark = second_shell.mark()\n        _read_until_prompt(second_shell, after=second_prompt_mark)\n    finally:\n        second_shell.close()\n\n\n@pytest.mark.skip(reason=\"/clear triggers Reload which hangs the process in inline prompt mode\")\ndef test_shell_clear_reloads_without_replaying_old_turns(tmp_path: Path) -> None:\n    config_path = write_scripted_config(\n        tmp_path,\n        [\n            \"text: Before clear result.\",\n            \"text: After clear result.\",\n        ],\n    )\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        before_mark = shell.mark()\n        shell.send_line(\"history-before-clear\")\n        shell.read_until_contains(\"Before clear result.\", after=before_mark)\n        _read_until_prompt(shell, after=before_mark)\n\n        clear_mark = shell.mark()\n        shell.send_line(\"/clear\")\n        shell.read_until_contains(\"The context has been cleared.\", after=clear_mark)\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\", after=clear_mark)\n        clear_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=clear_prompt_mark)\n\n        post_clear_segment = shell.normalized_text()[clear_mark:]\n        assert \"history-before-clear\" not in post_clear_segment\n        assert \"Before clear result.\" not in post_clear_segment\n\n        after_mark = shell.mark()\n        shell.send_line(\"history-after-clear\")\n        shell.read_until_contains(\"Before clear result.\", after=after_mark)\n        after_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=after_prompt_mark)\n\n        assert list_turn_begin_inputs(home_dir, work_dir) == [\n            \"history-before-clear\",\n            \"/clear\",\n            \"history-after-clear\",\n        ]\n    finally:\n        shell.close()\n\n\ndef test_shell_cancel_running_command_kills_process_and_recovers(tmp_path: Path) -> None:\n    scripts = [\n        build_shell_tool_call(\"tc-c1\", \"sleep 2 && printf should-not-exist > cancel_output.txt\"),\n        \"text: Cancel recovery completed.\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    shell = start_shell_pty(\n        config_path=config_path,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n\n    try:\n        shell.read_until_contains(\"Welcome to Kimi Code CLI!\")\n        _read_until_prompt(shell, after=shell.mark())\n\n        cancel_mark = shell.mark()\n        shell.send_line(\"start cancellable command\")\n        shell.read_until_contains(\"Using Shell (sleep 2 && printf should-\", after=cancel_mark)\n        shell.send_key(\"escape\")\n        shell.read_until_contains(\"Interrupted by user\", after=cancel_mark)\n        cancel_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=cancel_prompt_mark)\n\n        time.sleep(2.3)\n        assert not (work_dir / \"cancel_output.txt\").exists()\n\n        recovery_mark = shell.mark()\n        shell.send_line(\"confirm cancellation recovery\")\n        shell.read_until_contains(\"Cancel recovery completed.\", after=recovery_mark)\n        recovery_prompt_mark = shell.mark()\n        _read_until_prompt(shell, after=recovery_prompt_mark)\n    finally:\n        shell.close()\n"
  },
  {
    "path": "tests/notifications/test_notification_manager.py",
    "content": "from __future__ import annotations\n\nimport time\n\nimport pytest\n\nfrom kimi_cli.notifications import NotificationEvent\n\n\ndef test_publish_dedupes_and_tracks_sink_state(runtime) -> None:\n    manager = runtime.notifications\n    event = NotificationEvent(\n        id=manager.new_id(),\n        category=\"task\",\n        type=\"task.completed\",\n        source_kind=\"background_task\",\n        source_id=\"b1234567\",\n        title=\"Task completed\",\n        body=\"done\",\n        dedupe_key=\"background_task:b1234567:completed\",\n    )\n\n    first = manager.publish(event)\n    second = manager.publish(event.model_copy(update={\"id\": manager.new_id()}))\n\n    assert first.event.id == second.event.id\n\n    claimed = manager.claim_for_sink(\"llm\", limit=1)\n    assert [view.event.id for view in claimed] == [first.event.id]\n    acked = manager.ack(\"llm\", first.event.id)\n    assert acked.delivery.sinks[\"llm\"].status == \"acked\"\n    assert acked.delivery.sinks[\"wire\"].status == \"pending\"\n\n\ndef test_claim_for_sink_is_fifo_and_respects_limit(runtime) -> None:\n    manager = runtime.notifications\n    first = manager.publish(\n        NotificationEvent(\n            id=manager.new_id(),\n            category=\"system\",\n            type=\"system.info\",\n            source_kind=\"test\",\n            source_id=\"source-1\",\n            title=\"First\",\n            body=\"first\",\n            created_at=time.time() - 2,\n        )\n    )\n    second = manager.publish(\n        NotificationEvent(\n            id=manager.new_id(),\n            category=\"system\",\n            type=\"system.info\",\n            source_kind=\"test\",\n            source_id=\"source-2\",\n            title=\"Second\",\n            body=\"second\",\n            created_at=time.time() - 1,\n        )\n    )\n\n    claimed_first = manager.claim_for_sink(\"wire\", limit=1)\n    claimed_second = manager.claim_for_sink(\"wire\", limit=1)\n\n    assert [view.event.id for view in claimed_first] == [first.event.id]\n    assert [view.event.id for view in claimed_second] == [second.event.id]\n\n\ndef test_ack_for_one_sink_does_not_consume_other_sinks(runtime) -> None:\n    manager = runtime.notifications\n    event = manager.publish(\n        NotificationEvent(\n            id=manager.new_id(),\n            category=\"task\",\n            type=\"task.completed\",\n            source_kind=\"background_task\",\n            source_id=\"b1234567\",\n            title=\"Task completed\",\n            body=\"done\",\n            targets=[\"llm\", \"wire\", \"shell\"],\n        )\n    )\n\n    manager.ack(\"llm\", event.event.id)\n    wire_claim = manager.claim_for_sink(\"wire\", limit=1)\n    shell_claim = manager.claim_for_sink(\"shell\", limit=1)\n\n    assert [view.event.id for view in wire_claim] == [event.event.id]\n    assert [view.event.id for view in shell_claim] == [event.event.id]\n\n\ndef test_recover_requeues_stale_claim(runtime) -> None:\n    manager = runtime.notifications\n    event = NotificationEvent(\n        id=manager.new_id(),\n        category=\"system\",\n        type=\"system.info\",\n        source_kind=\"test\",\n        source_id=\"source-1\",\n        title=\"Info\",\n        body=\"hello\",\n    )\n    created = manager.publish(event)\n    delivery = created.delivery.model_copy(deep=True)\n    delivery.sinks[\"wire\"].status = \"claimed\"\n    delivery.sinks[\"wire\"].claimed_at = time.time() - 60\n    manager.store.write_delivery(created.event.id, delivery)\n\n    manager.recover()\n\n    recovered = manager.store.merged_view(created.event.id)\n    assert recovered.delivery.sinks[\"wire\"].status == \"pending\"\n    assert recovered.delivery.sinks[\"wire\"].claimed_at is None\n\n\ndef test_ack_ids_missing_notification_does_not_create_directory(runtime) -> None:\n    manager = runtime.notifications\n\n    manager.ack_ids(\"llm\", {\"nmissing01\"})\n\n    assert manager.store.list_notification_ids() == []\n    assert not manager.store.notification_path(\"nmissing01\").exists()\n\n\ndef test_list_views_skips_incomplete_notification_directories(runtime) -> None:\n    manager = runtime.notifications\n    event = NotificationEvent(\n        id=manager.new_id(),\n        category=\"task\",\n        type=\"task.completed\",\n        source_kind=\"background_task\",\n        source_id=\"b1234567\",\n        title=\"Task completed\",\n        body=\"done\",\n    )\n    manager.publish(event)\n\n    orphan_dir = manager.store.root / \"n-orphan\"\n    orphan_dir.mkdir(parents=True, exist_ok=True)\n    (orphan_dir / manager.store.DELIVERY_FILE).write_text(\"{}\", encoding=\"utf-8\")\n\n    views = manager.store.list_views()\n\n    assert len(views) == 1\n    assert views[0].event.id == event.id\n\n\n@pytest.mark.asyncio\nasync def test_deliver_pending_runs_shared_claim_and_ack_flow(runtime) -> None:\n    manager = runtime.notifications\n    event = NotificationEvent(\n        id=manager.new_id(),\n        category=\"system\",\n        type=\"system.info\",\n        source_kind=\"test\",\n        source_id=\"source-1\",\n        title=\"Info\",\n        body=\"hello\",\n        targets=[\"shell\"],\n    )\n    manager.publish(event)\n\n    calls: list[str] = []\n\n    async def _on_notification(view) -> None:\n        calls.append(view.event.id)\n\n    delivered = await manager.deliver_pending(\n        \"shell\",\n        before_claim=lambda: calls.append(\"before_claim\"),\n        on_notification=_on_notification,\n    )\n\n    assert calls == [\"before_claim\", event.id]\n    assert [view.event.id for view in delivered] == [event.id]\n    stored = manager.store.merged_view(event.id)\n    assert stored.delivery.sinks[\"shell\"].status == \"acked\"\n\n\n@pytest.mark.asyncio\nasync def test_deliver_pending_leaves_claimed_notification_for_recovery_on_handler_error(\n    runtime,\n) -> None:\n    manager = runtime.notifications\n    event = NotificationEvent(\n        id=manager.new_id(),\n        category=\"system\",\n        type=\"system.info\",\n        source_kind=\"test\",\n        source_id=\"source-1\",\n        title=\"Info\",\n        body=\"hello\",\n        targets=[\"wire\"],\n    )\n    manager.publish(event)\n\n    async def _boom(_view) -> None:\n        raise RuntimeError(\"handler failed\")\n\n    # Handler errors are caught and logged; delivery continues for remaining items.\n    delivered = await manager.deliver_pending(\"wire\", on_notification=_boom)\n\n    assert delivered == []\n    stored = manager.store.merged_view(event.id)\n    assert stored.delivery.sinks[\"wire\"].status == \"claimed\"\n"
  },
  {
    "path": "tests/test_additional_dirs_state.py",
    "content": "\"\"\"Tests for additional_dirs session state persistence and restoration.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nfrom kimi_cli.session_state import SessionState, load_session_state, save_session_state\n\n\ndef test_session_state_default_additional_dirs():\n    \"\"\"New session state should have empty additional_dirs.\"\"\"\n    state = SessionState()\n    assert state.additional_dirs == []\n\n\ndef test_session_state_serialization(tmp_path: Path):\n    \"\"\"additional_dirs should persist through save/load cycle.\"\"\"\n    state = SessionState()\n    state.additional_dirs = [\"/home/user/lib\", \"/opt/shared\"]\n    save_session_state(state, tmp_path)\n\n    loaded = load_session_state(tmp_path)\n    assert loaded.additional_dirs == [\"/home/user/lib\", \"/opt/shared\"]\n\n\ndef test_session_state_backward_compatibility(tmp_path: Path):\n    \"\"\"Old state.json without additional_dirs field should load with empty list.\"\"\"\n    old_state = {\"version\": 1, \"approval\": {\"yolo\": False, \"auto_approve_actions\": []}}\n    state_file = tmp_path / \"state.json\"\n    state_file.write_text(json.dumps(old_state))\n\n    loaded = load_session_state(tmp_path)\n    assert loaded.additional_dirs == []\n\n\ndef test_session_state_preserves_other_fields(tmp_path: Path):\n    \"\"\"Saving additional_dirs should not corrupt other fields.\"\"\"\n    state = SessionState()\n    state.approval.yolo = True\n    state.additional_dirs = [\"/extra\"]\n    save_session_state(state, tmp_path)\n\n    loaded = load_session_state(tmp_path)\n    assert loaded.approval.yolo is True\n    assert loaded.additional_dirs == [\"/extra\"]\n"
  },
  {
    "path": "tests/test_attachment_cache.py",
    "content": "from __future__ import annotations\n\nimport base64\n\nfrom PIL import Image\n\nfrom kimi_cli.ui.shell.prompt import AttachmentCache, _parse_attachment_kind\nfrom kimi_cli.wire.types import ImageURLPart, TextPart\n\n\ndef _make_image() -> Image.Image:\n    return Image.new(\"RGB\", (2, 2), color=(10, 20, 30))\n\n\ndef test_attachment_cache_roundtrip(tmp_path) -> None:\n    cache = AttachmentCache(root=tmp_path)\n    image = _make_image()\n\n    cached = cache.store_image(image)\n    assert cached is not None\n    assert cached.path.exists()\n    assert cached.path.parent == tmp_path / \"images\"\n\n    parts = cache.load_content_parts(\"image\", cached.attachment_id)\n    assert parts is not None\n    assert len(parts) == 3\n    assert parts[0] == TextPart(text=f'<image path=\"{cached.path}\">')\n    assert isinstance(parts[1], ImageURLPart)\n    assert parts[2] == TextPart(text=\"</image>\")\n    assert parts[1].image_url.url.startswith(\"data:image/png;base64,\")\n\n    encoded = parts[1].image_url.url.split(\",\", 1)[1]\n    assert base64.b64decode(encoded).startswith(b\"\\x89PNG\")\n\n\ndef test_parse_attachment_kind() -> None:\n    assert _parse_attachment_kind(\"image\") == \"image\"\n    assert _parse_attachment_kind(\"text\") is None\n\n\ndef test_attachment_cache_dedupes_bytes(tmp_path) -> None:\n    cache = AttachmentCache(root=tmp_path)\n    payload = b\"same-bytes\"\n\n    cached_first = cache.store_bytes(\"image\", \".png\", payload)\n    cached_second = cache.store_bytes(\"image\", \".png\", payload)\n\n    assert cached_first is not None\n    assert cached_second is not None\n    assert cached_first.attachment_id == cached_second.attachment_id\n    assert cached_first.path == cached_second.path\n    assert cached_first.path.read_bytes() == payload\n    assert len(list((tmp_path / \"images\").iterdir())) == 1\n"
  },
  {
    "path": "tests/test_clipboard.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\nfrom PIL import Image\n\nfrom kimi_cli.utils.clipboard import _VIDEO_SUFFIXES, _classify_file_paths\n\n\ndef test_classify_video_file(tmp_path: Path) -> None:\n    video = tmp_path / \"clip.mp4\"\n    video.write_bytes(b\"\\x00\" * 10)\n\n    images, file_paths = _classify_file_paths([str(video)])\n    assert images == []\n    assert file_paths == [video]\n\n\ndef test_classify_image_file(tmp_path: Path) -> None:\n    img_path = tmp_path / \"photo.png\"\n    Image.new(\"RGB\", (2, 2)).save(img_path)\n\n    images, file_paths = _classify_file_paths([str(img_path)])\n    assert len(images) == 1\n    assert images[0].size == (2, 2)\n    assert file_paths == []\n\n\ndef test_classify_video_and_image(tmp_path: Path) -> None:\n    \"\"\"Both video and image files are returned in their respective groups.\"\"\"\n    img_path = tmp_path / \"photo.png\"\n    Image.new(\"RGB\", (2, 2)).save(img_path)\n    video = tmp_path / \"clip.mov\"\n    video.write_bytes(b\"\\x00\" * 10)\n\n    images, file_paths = _classify_file_paths([str(img_path), str(video)])\n    assert len(images) == 1\n    assert images[0].size == (2, 2)\n    assert file_paths == [video]\n\n\ndef test_classify_nonexistent_file() -> None:\n    images, file_paths = _classify_file_paths([\"/nonexistent/file.mp4\"])\n    assert images == []\n    assert file_paths == []\n\n\ndef test_classify_non_media_file(tmp_path: Path) -> None:\n    txt = tmp_path / \"notes.txt\"\n    txt.write_text(\"hello\")\n\n    images, file_paths = _classify_file_paths([str(txt)])\n    assert images == []\n    assert file_paths == [txt]\n\n\ndef test_classify_empty() -> None:\n    images, file_paths = _classify_file_paths([])\n    assert images == []\n    assert file_paths == []\n\n\ndef test_classify_pdf_file(tmp_path: Path) -> None:\n    pdf = tmp_path / \"document.pdf\"\n    pdf.write_bytes(b\"%PDF-1.4 fake content\")\n\n    images, file_paths = _classify_file_paths([str(pdf)])\n    assert images == []\n    assert file_paths == [pdf]\n\n\ndef test_classify_csv_file(tmp_path: Path) -> None:\n    csv = tmp_path / \"data.csv\"\n    csv.write_text(\"a,b,c\\n1,2,3\")\n\n    images, file_paths = _classify_file_paths([str(csv)])\n    assert images == []\n    assert file_paths == [csv]\n\n\ndef test_classify_docx_file(tmp_path: Path) -> None:\n    docx = tmp_path / \"report.docx\"\n    docx.write_bytes(b\"\\x00\" * 10)\n\n    images, file_paths = _classify_file_paths([str(docx)])\n    assert images == []\n    assert file_paths == [docx]\n\n\ndef test_classify_multiple_generic_files(tmp_path: Path) -> None:\n    \"\"\"All non-media files should be preserved.\"\"\"\n    pdf = tmp_path / \"a.pdf\"\n    pdf.write_bytes(b\"%PDF\")\n    csv = tmp_path / \"b.csv\"\n    csv.write_text(\"x,y\")\n    txt = tmp_path / \"c.txt\"\n    txt.write_text(\"hello\")\n\n    images, file_paths = _classify_file_paths([str(pdf), str(csv), str(txt)])\n    assert images == []\n    assert file_paths == [pdf, csv, txt]\n\n\ndef test_classify_multiple_videos(tmp_path: Path) -> None:\n    \"\"\"All video files should be preserved.\"\"\"\n    v1 = tmp_path / \"a.mp4\"\n    v1.write_bytes(b\"\\x00\")\n    v2 = tmp_path / \"b.mov\"\n    v2.write_bytes(b\"\\x00\")\n\n    images, file_paths = _classify_file_paths([str(v1), str(v2)])\n    assert images == []\n    assert file_paths == [v1, v2]\n\n\ndef test_classify_multiple_images(tmp_path: Path) -> None:\n    \"\"\"All image files should be preserved.\"\"\"\n    img1 = tmp_path / \"a.png\"\n    Image.new(\"RGB\", (2, 2)).save(img1)\n    img2 = tmp_path / \"b.png\"\n    Image.new(\"RGB\", (3, 3)).save(img2)\n\n    images, file_paths = _classify_file_paths([str(img1), str(img2)])\n    assert len(images) == 2\n    assert images[0].size == (2, 2)\n    assert images[1].size == (3, 3)\n    assert file_paths == []\n\n\ndef test_classify_video_over_generic_file(tmp_path: Path) -> None:\n    \"\"\"Video files are classified as non-image alongside generic files.\"\"\"\n    pdf = tmp_path / \"doc.pdf\"\n    pdf.write_bytes(b\"%PDF\")\n    video = tmp_path / \"clip.mp4\"\n    video.write_bytes(b\"\\x00\" * 10)\n\n    images, file_paths = _classify_file_paths([str(pdf), str(video)])\n    assert images == []\n    assert set(file_paths) == {pdf, video}\n\n\ndef test_classify_image_over_generic_file(tmp_path: Path) -> None:\n    \"\"\"Image and generic files are separated into their groups.\"\"\"\n    pdf = tmp_path / \"doc.pdf\"\n    pdf.write_bytes(b\"%PDF\")\n    img_path = tmp_path / \"photo.png\"\n    Image.new(\"RGB\", (2, 2)).save(img_path)\n\n    images, file_paths = _classify_file_paths([str(pdf), str(img_path)])\n    assert len(images) == 1\n    assert images[0].size == (2, 2)\n    assert file_paths == [pdf]\n\n\ndef test_classify_mixed_all_types(tmp_path: Path) -> None:\n    \"\"\"Mix of videos, images, and generic files.\"\"\"\n    video = tmp_path / \"clip.mp4\"\n    video.write_bytes(b\"\\x00\")\n    img = tmp_path / \"photo.png\"\n    Image.new(\"RGB\", (4, 4)).save(img)\n    pdf = tmp_path / \"doc.pdf\"\n    pdf.write_bytes(b\"%PDF\")\n\n    images, file_paths = _classify_file_paths([str(video), str(img), str(pdf)])\n    assert len(images) == 1\n    assert images[0].size == (4, 4)\n    assert set(file_paths) == {video, pdf}\n\n\ndef test_classify_all_video_suffixes(tmp_path: Path) -> None:\n    for suffix in _VIDEO_SUFFIXES:\n        f = tmp_path / f\"test{suffix}\"\n        f.write_bytes(b\"\\x00\")\n        images, file_paths = _classify_file_paths([str(f)])\n        assert images == [], f\"Failed for {suffix}\"\n        assert file_paths == [f], f\"Failed for {suffix}\"\n"
  },
  {
    "path": "tests/tools/test_additional_dirs.py",
    "content": "\"\"\"Tests for additional directories support in file tools.\"\"\"\n\nfrom __future__ import annotations\n\nimport platform\nimport tempfile\nfrom collections.abc import Generator\nfrom pathlib import Path\n\nimport pytest\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.soul.approval import Approval\nfrom kimi_cli.tools.file.glob import Glob\nfrom kimi_cli.tools.file.glob import Params as GlobParams\nfrom kimi_cli.tools.file.read import Params as ReadParams\nfrom kimi_cli.tools.file.read import ReadFile\nfrom kimi_cli.tools.file.replace import Edit, StrReplaceFile\nfrom kimi_cli.tools.file.replace import Params as ReplaceParams\nfrom kimi_cli.tools.file.write import Params as WriteParams\nfrom kimi_cli.tools.file.write import WriteFile\nfrom tests.conftest import tool_call_context\n\n\n@pytest.fixture\ndef additional_dir(temp_work_dir: KaosPath) -> Generator[KaosPath]:\n    \"\"\"Create a temporary additional directory outside the work directory.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        p = Path(tmpdir).resolve()\n        yield KaosPath.unsafe_from_local_path(p)\n\n\n@pytest.fixture\ndef runtime_with_additional_dir(runtime: Runtime, additional_dir: KaosPath) -> Runtime:\n    \"\"\"Runtime with an additional directory configured.\"\"\"\n    runtime.additional_dirs.append(additional_dir)\n    return runtime\n\n\n# ── Glob tests ──────────────────────────────────────────────────────────────\n\n\nasync def test_glob_in_additional_dir(\n    runtime_with_additional_dir: Runtime, additional_dir: KaosPath\n):\n    \"\"\"Glob should be able to search in an additional directory.\"\"\"\n    glob_tool = Glob(runtime_with_additional_dir)\n    await (additional_dir / \"hello.py\").write_text(\"print('hello')\")\n    await (additional_dir / \"world.py\").write_text(\"print('world')\")\n\n    result = await glob_tool(GlobParams(pattern=\"*.py\", directory=str(additional_dir)))\n    assert not result.is_error\n    assert \"hello.py\" in result.output\n    assert \"world.py\" in result.output\n\n\nasync def test_glob_in_additional_dir_subdirectory(\n    runtime_with_additional_dir: Runtime, additional_dir: KaosPath\n):\n    \"\"\"Glob should work in a subdirectory of an additional directory.\"\"\"\n    glob_tool = Glob(runtime_with_additional_dir)\n    await (additional_dir / \"src\").mkdir()\n    await (additional_dir / \"src\" / \"main.py\").write_text(\"main\")\n\n    sub = str(additional_dir / \"src\")\n    result = await glob_tool(GlobParams(pattern=\"*.py\", directory=sub))\n    assert not result.is_error\n    assert \"main.py\" in result.output\n\n\nasync def test_glob_outside_all_dirs_rejected(\n    runtime_with_additional_dir: Runtime,\n):\n    \"\"\"Glob in a directory outside both work_dir and additional dirs should fail.\"\"\"\n    glob_tool = Glob(runtime_with_additional_dir)\n    outside = \"/tmp/evil\" if platform.system() != \"Windows\" else \"C:/tmp/evil\"\n\n    result = await glob_tool(GlobParams(pattern=\"*.py\", directory=outside))\n    assert result.is_error\n    assert \"outside the workspace\" in result.message\n\n\n# ── ReadFile tests ──────────────────────────────────────────────────────────\n\n\nasync def test_read_file_in_additional_dir(\n    runtime_with_additional_dir: Runtime, additional_dir: KaosPath\n):\n    \"\"\"ReadFile should read files in additional directories.\"\"\"\n    read_tool = ReadFile(runtime_with_additional_dir)\n    test_file = additional_dir / \"readme.txt\"\n    await test_file.write_text(\"Hello from additional dir\\n\")\n\n    result = await read_tool(ReadParams(path=str(test_file)))\n    assert not result.is_error\n    assert \"Hello from additional dir\" in result.output\n\n\nasync def test_read_file_relative_path_in_additional_dir(\n    runtime_with_additional_dir: Runtime, additional_dir: KaosPath\n):\n    \"\"\"Relative paths that resolve outside work_dir but inside additional dir should work.\"\"\"\n    read_tool = ReadFile(runtime_with_additional_dir)\n    test_file = additional_dir / \"data.txt\"\n    await test_file.write_text(\"data content\\n\")\n\n    # Absolute path to the file in additional dir should be allowed\n    result = await read_tool(ReadParams(path=str(test_file)))\n    assert not result.is_error\n\n\n# ── WriteFile tests ─────────────────────────────────────────────────────────\n\n\nasync def test_write_file_in_additional_dir(\n    runtime_with_additional_dir: Runtime, approval: Approval, additional_dir: KaosPath\n):\n    \"\"\"WriteFile should write to files in additional directories.\"\"\"\n    with tool_call_context(\"WriteFile\"):\n        write_tool = WriteFile(runtime_with_additional_dir, approval)\n        target = additional_dir / \"output.txt\"\n\n        result = await write_tool(WriteParams(path=str(target), content=\"new content\"))\n        assert not result.is_error\n        assert await target.read_text() == \"new content\"\n\n\nasync def test_write_file_in_additional_dir_uses_edit_action(\n    runtime_with_additional_dir: Runtime, approval: Approval, additional_dir: KaosPath\n):\n    \"\"\"Writing in additional dir should use EDIT action (not EDIT_OUTSIDE).\"\"\"\n    with tool_call_context(\"WriteFile\"):\n        write_tool = WriteFile(runtime_with_additional_dir, approval)\n        target = additional_dir / \"in_workspace.txt\"\n\n        result = await write_tool(WriteParams(path=str(target), content=\"content\"))\n        assert not result.is_error\n\n\n# ── StrReplaceFile tests ────────────────────────────────────────────────────\n\n\nasync def test_replace_in_additional_dir(\n    runtime_with_additional_dir: Runtime, approval: Approval, additional_dir: KaosPath\n):\n    \"\"\"StrReplaceFile should edit files in additional directories.\"\"\"\n    with tool_call_context(\"StrReplaceFile\"):\n        replace_tool = StrReplaceFile(runtime_with_additional_dir, approval)\n        target = additional_dir / \"code.py\"\n        await target.write_text(\"old_value = 1\\n\")\n\n        result = await replace_tool(\n            ReplaceParams(\n                path=str(target),\n                edit=Edit(old=\"old_value\", new=\"new_value\"),\n            )\n        )\n        assert not result.is_error\n        assert await target.read_text() == \"new_value = 1\\n\"\n\n\n# ── Dynamic mutation tests ──────────────────────────────────────────────────\n\n\nasync def test_add_dir_dynamically_affects_tools(runtime: Runtime, approval: Approval):\n    \"\"\"Adding a dir to runtime.additional_dirs should immediately affect tool behavior.\"\"\"\n    glob_tool = Glob(runtime)\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        extra = KaosPath.unsafe_from_local_path(Path(tmpdir).resolve())\n        await (extra / \"test.py\").write_text(\"pass\")\n\n        # Before adding: should be rejected\n        result = await glob_tool(GlobParams(pattern=\"*.py\", directory=str(extra)))\n        assert result.is_error\n        assert \"outside the workspace\" in result.message\n\n        # Add the directory to runtime (simulating /add-dir)\n        runtime.additional_dirs.append(extra)\n\n        # After adding: should work\n        result = await glob_tool(GlobParams(pattern=\"*.py\", directory=str(extra)))\n        assert not result.is_error\n        assert \"test.py\" in result.output\n\n\nasync def test_subagent_shares_additional_dirs(runtime: Runtime):\n    \"\"\"Subagent runtime should share the same additional_dirs list.\"\"\"\n    fixed = runtime.copy_for_fixed_subagent()\n    dynamic = runtime.copy_for_dynamic_subagent()\n\n    # They should be the exact same list object\n    assert fixed.additional_dirs is runtime.additional_dirs\n    assert dynamic.additional_dirs is runtime.additional_dirs\n\n    # Mutation on parent should be visible to subagents\n    runtime.additional_dirs.append(KaosPath(\"/test/shared\"))\n    assert KaosPath(\"/test/shared\") in fixed.additional_dirs\n    assert KaosPath(\"/test/shared\") in dynamic.additional_dirs\n"
  },
  {
    "path": "tests/tools/test_ask_user.py",
    "content": "\"\"\"Tests for the AskUserQuestion tool.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nimport pytest\n\nfrom kimi_cli.soul import _current_wire\nfrom kimi_cli.soul.toolset import current_tool_call\nfrom kimi_cli.tools.ask_user import AskUserQuestion, Params, QuestionOptionParam, QuestionParam\nfrom kimi_cli.wire import Wire\nfrom kimi_cli.wire.types import QuestionNotSupported, QuestionRequest, ToolCall\n\n\n@pytest.fixture\ndef ask_user_tool() -> AskUserQuestion:\n    return AskUserQuestion()\n\n\ndef _make_params(\n    question: str = \"Which option?\",\n    options: list[tuple[str, str]] | None = None,\n    multi_select: bool = False,\n) -> Params:\n    if options is None:\n        options = [(\"Option A\", \"First option\"), (\"Option B\", \"Second option\")]\n    return Params(\n        questions=[\n            QuestionParam(\n                question=question,\n                header=\"Test\",\n                options=[QuestionOptionParam(label=lab, description=d) for lab, d in options],\n                multi_select=multi_select,\n            )\n        ]\n    )\n\n\nasync def test_ask_user_basic(ask_user_tool: AskUserQuestion):\n    \"\"\"Test normal question-answer flow.\"\"\"\n    wire = Wire()\n    wire_token = _current_wire.set(wire)\n    tool_call = ToolCall(\n        id=\"tc-ask-1\",\n        function=ToolCall.FunctionBody(name=\"AskUserQuestion\", arguments=None),\n    )\n    tc_token = current_tool_call.set(tool_call)\n\n    try:\n        params = _make_params()\n\n        # Start the tool call in a task so we can intercept the QuestionRequest\n        tool_task = asyncio.create_task(ask_user_tool(params))\n\n        # Receive the QuestionRequest from the UI side of the wire\n        ui_side = wire.ui_side(merge=False)\n        msg = await asyncio.wait_for(ui_side.receive(), timeout=2.0)\n        assert isinstance(msg, QuestionRequest)\n        assert len(msg.questions) == 1\n        assert msg.questions[0].question == \"Which option?\"\n        assert msg.questions[0].options[0].label == \"Option A\"\n        assert msg.questions[0].options[1].label == \"Option B\"\n        assert msg.tool_call_id == \"tc-ask-1\"\n\n        # Resolve the request with an answer\n        msg.resolve({\"Which option?\": \"Option A\"})\n\n        result = await asyncio.wait_for(tool_task, timeout=2.0)\n        assert not result.is_error\n        assert isinstance(result.output, str)\n        parsed = json.loads(result.output)\n        assert parsed == {\"answers\": {\"Which option?\": \"Option A\"}}\n    finally:\n        wire.shutdown()\n        current_tool_call.reset(tc_token)\n        _current_wire.reset(wire_token)\n\n\nasync def test_ask_user_dismissed(ask_user_tool: AskUserQuestion):\n    \"\"\"Test that user dismiss returns a non-error result with dismiss note.\"\"\"\n    wire = Wire()\n    wire_token = _current_wire.set(wire)\n    tool_call = ToolCall(\n        id=\"tc-ask-dismiss\",\n        function=ToolCall.FunctionBody(name=\"AskUserQuestion\", arguments=None),\n    )\n    tc_token = current_tool_call.set(tool_call)\n\n    try:\n        params = _make_params()\n        tool_task = asyncio.create_task(ask_user_tool(params))\n\n        ui_side = wire.ui_side(merge=False)\n        msg = await asyncio.wait_for(ui_side.receive(), timeout=2.0)\n        assert isinstance(msg, QuestionRequest)\n\n        # Resolve with empty answers (simulating user dismiss)\n        msg.resolve({})\n\n        result = await asyncio.wait_for(tool_task, timeout=2.0)\n        assert not result.is_error\n        assert isinstance(result.output, str)\n        parsed = json.loads(result.output)\n        assert parsed[\"answers\"] == {}\n        assert \"dismissed\" in parsed.get(\"note\", \"\").lower()\n    finally:\n        wire.shutdown()\n        current_tool_call.reset(tc_token)\n        _current_wire.reset(wire_token)\n\n\nasync def test_ask_user_client_unsupported(ask_user_tool: AskUserQuestion):\n    \"\"\"Test that QuestionNotSupported returns a hard error telling LLM not to retry.\"\"\"\n    wire = Wire()\n    wire_token = _current_wire.set(wire)\n    tool_call = ToolCall(\n        id=\"tc-ask-unsupported\",\n        function=ToolCall.FunctionBody(name=\"AskUserQuestion\", arguments=None),\n    )\n    tc_token = current_tool_call.set(tool_call)\n\n    try:\n        params = _make_params()\n        tool_task = asyncio.create_task(ask_user_tool(params))\n\n        ui_side = wire.ui_side(merge=False)\n        msg = await asyncio.wait_for(ui_side.receive(), timeout=2.0)\n        assert isinstance(msg, QuestionRequest)\n\n        # Reject with QuestionNotSupported (simulating unsupported client)\n        msg.set_exception(QuestionNotSupported())\n\n        result = await asyncio.wait_for(tool_task, timeout=2.0)\n        assert result.is_error\n        assert \"does not support\" in result.message\n        assert \"Do NOT call this tool again\" in result.message\n    finally:\n        wire.shutdown()\n        current_tool_call.reset(tc_token)\n        _current_wire.reset(wire_token)\n\n\nasync def test_ask_user_no_wire(ask_user_tool: AskUserQuestion):\n    \"\"\"Test that the tool returns an error when Wire is not available.\"\"\"\n    # Ensure no wire is set\n    wire_token = _current_wire.set(None)\n    tool_call = ToolCall(\n        id=\"tc-ask-2\",\n        function=ToolCall.FunctionBody(name=\"AskUserQuestion\", arguments=None),\n    )\n    tc_token = current_tool_call.set(tool_call)\n\n    try:\n        params = _make_params()\n        result = await ask_user_tool(params)\n        assert result.is_error\n        assert \"Wire\" in result.message\n    finally:\n        current_tool_call.reset(tc_token)\n        _current_wire.reset(wire_token)\n\n\nasync def test_ask_user_no_tool_call(ask_user_tool: AskUserQuestion):\n    \"\"\"Test that the tool returns an error when no tool_call context is set.\"\"\"\n    wire = Wire()\n    wire_token = _current_wire.set(wire)\n    # Do NOT set current_tool_call\n\n    try:\n        params = _make_params()\n        result = await ask_user_tool(params)\n        assert result.is_error\n        assert \"tool call\" in result.message.lower() or \"context\" in result.message.lower()\n    finally:\n        wire.shutdown()\n        _current_wire.reset(wire_token)\n"
  },
  {
    "path": "tests/tools/test_background_tools.py",
    "content": "from __future__ import annotations\n\nimport time\n\nimport pytest\n\nfrom kimi_cli.background import TaskRuntime, TaskSpec, TaskStatus\nfrom kimi_cli.tools.shell import Params\n\n\ndef _write_task(runtime, task_id: str, *, status: TaskStatus, output: str = \"\"):\n    store = runtime.background_tasks.store\n    spec = TaskSpec(\n        id=task_id,\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"background build\",\n        tool_call_id=\"tool-6\",\n        command=\"make build\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n    )\n    store.create_task(spec)\n    store.output_path(task_id).write_text(output, encoding=\"utf-8\")\n    runtime_state = TaskRuntime(status=status, updated_at=time.time())\n    if status in {\"completed\", \"failed\", \"killed\", \"lost\"}:\n        runtime_state.finished_at = time.time()\n        runtime_state.exit_code = 0 if status == \"completed\" else 1\n    store.write_runtime(task_id, runtime_state)\n    return spec\n\n\n@pytest.mark.asyncio\nasync def test_shell_background_starts_task(shell_tool, runtime, monkeypatch):\n    monkeypatch.setattr(runtime.background_tasks, \"_launch_worker\", lambda task_dir: 9898)\n\n    result = await shell_tool(\n        Params(\n            command=\"sleep 1\",\n            timeout=10,\n            run_in_background=True,\n            description=\"sleep task\",\n        )\n    )\n\n    assert not result.is_error\n    assert \"task_id:\" in result.output\n    assert \"status: starting\" in result.output\n    assert \"automatic_notification: true\" in result.output\n    assert \"human_shell_hint:\" in result.output\n    assert \"/task list\" in result.output\n\n\n@pytest.mark.asyncio\nasync def test_shell_background_requires_description(shell_tool):\n    with pytest.raises(ValueError, match=\"description\"):\n        Params(command=\"sleep 1\", timeout=10, run_in_background=True)\n\n\n@pytest.mark.asyncio\nasync def test_task_output_returns_completed_output(\n    runtime,\n    task_output_tool,\n):\n    spec = _write_task(\n        runtime,\n        \"b5555555\",\n        status=\"completed\",\n        output=\"build line 1\\nbuild line 2\\n\",\n    )\n\n    result = await task_output_tool(task_output_tool.params(task_id=spec.id, block=True, timeout=1))\n\n    output_path = runtime.background_tasks.store.output_path(spec.id).resolve()\n    assert not result.is_error\n    assert \"retrieval_status: success\" in result.output\n    assert \"status: completed\" in result.output\n    assert f\"output_path: {output_path}\" in result.output\n    assert \"output_truncated: false\" in result.output\n    assert \"full_output_tool: ReadFile\" in result.output\n    assert \"full_output_hint:\" in result.output\n    assert \"[output]\" in result.output\n    assert \"build line 1\" in result.output\n    consumer = runtime.background_tasks.store.read_consumer(spec.id)\n    assert consumer.last_seen_output_size == len(b\"build line 1\\nbuild line 2\\n\")\n    assert consumer.last_viewed_at is not None\n\n\n@pytest.mark.asyncio\nasync def test_task_list_returns_active_tasks(runtime, task_list_tool):\n    active_spec = _write_task(\n        runtime,\n        \"b4444444\",\n        status=\"running\",\n        output=\"still going\\n\",\n    )\n    _write_task(\n        runtime,\n        \"b4444445\",\n        status=\"completed\",\n        output=\"done\\n\",\n    )\n\n    result = await task_list_tool(task_list_tool.params(active_only=True, limit=20))\n\n    assert not result.is_error\n    assert \"active_background_tasks: 1\" in result.output\n    assert active_spec.id in result.output\n    assert \"b4444445\" not in result.output\n\n\n@pytest.mark.asyncio\nasync def test_task_output_returns_not_ready_for_running_task(runtime, task_output_tool):\n    spec = _write_task(\n        runtime,\n        \"b6666666\",\n        status=\"running\",\n        output=\"still working\\n\",\n    )\n\n    result = await task_output_tool(\n        task_output_tool.params(task_id=spec.id, block=False, timeout=0)\n    )\n\n    assert not result.is_error\n    assert \"retrieval_status: not_ready\" in result.output\n    assert \"status: running\" in result.output\n    assert \"output_truncated: false\" in result.output\n    assert \"still working\" in result.output\n\n\n@pytest.mark.asyncio\nasync def test_task_output_blocking_timeout_surfaces_timeout_retrieval_status(\n    runtime, task_output_tool\n):\n    spec = _write_task(\n        runtime,\n        \"b6666665\",\n        status=\"running\",\n        output=\"still working\\n\",\n    )\n\n    result = await task_output_tool(task_output_tool.params(task_id=spec.id, block=True, timeout=0))\n\n    assert not result.is_error\n    assert \"retrieval_status: timeout\" in result.output\n    assert \"status: running\" in result.output\n\n\n@pytest.mark.asyncio\nasync def test_task_output_missing_task_does_not_pollute_store(runtime, task_output_tool):\n    result = await task_output_tool(\n        task_output_tool.params(task_id=\"bmissing01\", block=False, timeout=0)\n    )\n\n    assert result.is_error\n    assert result.brief == \"Task not found\"\n    assert runtime.background_tasks.store.list_task_ids() == []\n    assert not runtime.background_tasks.store.task_path(\"bmissing01\").exists()\n\n\n@pytest.mark.asyncio\nasync def test_task_output_explicitly_surfaces_timeout_contract(runtime, task_output_tool):\n    store = runtime.background_tasks.store\n    spec = TaskSpec(\n        id=\"b6666667\",\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=\"timeout build\",\n        tool_call_id=\"tool-6-timeout\",\n        command=\"make build\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=1,\n    )\n    store.create_task(spec)\n    store.output_path(spec.id).write_text(\"partial output\\n\", encoding=\"utf-8\")\n    store.write_runtime(\n        spec.id,\n        TaskRuntime(\n            status=\"failed\",\n            interrupted=True,\n            timed_out=True,\n            updated_at=time.time(),\n            finished_at=time.time(),\n            failure_reason=\"Command timed out after 1s\",\n        ),\n    )\n\n    result = await task_output_tool(task_output_tool.params(task_id=spec.id, block=True, timeout=1))\n\n    output_path = runtime.background_tasks.store.output_path(spec.id).resolve()\n    assert not result.is_error\n    assert \"status: failed\" in result.output\n    assert \"interrupted: true\" in result.output\n    assert \"timed_out: true\" in result.output\n    assert \"terminal_reason: timed_out\" in result.output\n    assert \"reason: Command timed out after 1s\" in result.output\n    assert f\"output_path: {output_path}\" in result.output\n\n\n@pytest.mark.asyncio\nasync def test_task_output_surfaces_truncated_preview_and_full_log_path(runtime, task_output_tool):\n    output = \"first marker\\n\" + (\"x\" * (33 << 10)) + \"\\nlast marker\\n\"\n    spec = _write_task(\n        runtime,\n        \"b9999999\",\n        status=\"completed\",\n        output=output,\n    )\n\n    result = await task_output_tool(task_output_tool.params(task_id=spec.id, block=True, timeout=1))\n\n    assert not result.is_error\n    output_path = runtime.background_tasks.store.output_path(spec.id).resolve()\n    assert f\"output_path: {output_path}\" in result.output\n    assert \"output_preview_bytes: 32768\" in result.output\n    assert f\"output_size_bytes: {len(output.encode('utf-8'))}\" in result.output\n    assert \"output_truncated: true\" in result.output\n    assert f\"[Truncated. Full output: {output_path}]\" in result.output\n    assert \"last marker\" in result.output\n    assert \"first marker\" not in result.output\n    assert (\n        f'Use ReadFile(path=\"{output_path}\", line_offset=1, n_lines=300) to inspect the full log.'\n        in result.output\n    )\n\n\n@pytest.mark.asyncio\nasync def test_task_list_can_include_terminal_tasks(runtime, task_list_tool):\n    _write_task(\n        runtime,\n        \"b4444444\",\n        status=\"running\",\n        output=\"still going\\n\",\n    )\n    completed = _write_task(\n        runtime,\n        \"b4444445\",\n        status=\"completed\",\n        output=\"done\\n\",\n    )\n\n    result = await task_list_tool(task_list_tool.params(active_only=False, limit=1))\n\n    assert not result.is_error\n    assert \"background_tasks: 1\" in result.output\n    assert completed.id in result.output\n\n\n@pytest.mark.asyncio\nasync def test_background_tools_reject_non_root_runtime(\n    runtime, task_list_tool, task_output_tool, task_stop_tool\n):\n    runtime.role = \"fixed_subagent\"\n\n    list_result = await task_list_tool(task_list_tool.params(active_only=True, limit=20))\n    output_result = await task_output_tool(\n        task_output_tool.params(task_id=\"bmissing01\", block=False, timeout=0)\n    )\n    stop_result = await task_stop_tool(task_stop_tool.params(task_id=\"bmissing01\"))\n\n    assert list_result.is_error\n    assert output_result.is_error\n    assert stop_result.is_error\n    assert list_result.brief == \"Background task unavailable\"\n    assert output_result.brief == \"Background task unavailable\"\n    assert stop_result.brief == \"Background task unavailable\"\n\n\n@pytest.mark.asyncio\nasync def test_task_stop_blocks_in_plan_mode(runtime, task_stop_tool):\n    runtime.session.state.plan_mode = True\n    result = await task_stop_tool(task_stop_tool.params(task_id=\"b-noop\"))\n    assert result.is_error\n    assert result.brief == \"Blocked in plan mode\"\n\n\n@pytest.mark.asyncio\nasync def test_task_stop_rejected_by_approval(runtime, task_stop_tool, monkeypatch):\n    spec = _write_task(\n        runtime,\n        \"b7777776\",\n        status=\"running\",\n        output=\"watching\\n\",\n    )\n\n    async def _reject(*_args, **_kwargs):\n        return False\n\n    monkeypatch.setattr(task_stop_tool._approval, \"request\", _reject)\n\n    result = await task_stop_tool(\n        task_stop_tool.params(task_id=spec.id, reason=\"Stop watcher process\")\n    )\n\n    assert result.is_error\n    assert result.brief == \"Rejected by user\"\n    control = runtime.background_tasks.store.read_control(spec.id)\n    assert control.kill_requested_at is None\n\n\n@pytest.mark.asyncio\nasync def test_task_stop_requests_stop_for_running_task(runtime, task_stop_tool):\n    spec = _write_task(\n        runtime,\n        \"b7777777\",\n        status=\"running\",\n        output=\"watching\\n\",\n    )\n\n    result = await task_stop_tool(\n        task_stop_tool.params(task_id=spec.id, reason=\"Stop watcher process\")\n    )\n\n    assert not result.is_error\n    assert result.message == \"Task stop requested.\"\n    control = runtime.background_tasks.store.read_control(spec.id)\n    assert control.kill_requested_at is not None\n    assert control.kill_reason == \"Stop watcher process\"\n\n\n@pytest.mark.asyncio\nasync def test_task_stop_on_terminal_task_is_noop(runtime, task_stop_tool):\n    spec = _write_task(\n        runtime,\n        \"b7777778\",\n        status=\"completed\",\n        output=\"done\\n\",\n    )\n\n    result = await task_stop_tool(task_stop_tool.params(task_id=spec.id, reason=\"Stop anyway\"))\n\n    assert not result.is_error\n    assert \"status: completed\" in result.output\n    control = runtime.background_tasks.store.read_control(spec.id)\n    assert control.kill_requested_at is None\n"
  },
  {
    "path": "tests/tools/test_create_subagent.py",
    "content": "from __future__ import annotations\n\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.tools.multiagent.create import CreateSubagent, Params\n\n\nasync def test_create_subagent(create_subagent_tool: CreateSubagent):\n    \"\"\"Test creating a subagent.\"\"\"\n    result = await create_subagent_tool(\n        Params(\n            name=\"test_agent\",\n            system_prompt=\"You are a test agent.\",\n        )\n    )\n    assert not result.is_error\n    assert result.output == snapshot(\"Available subagents: mocker, test_agent\")\n    assert result.message == snapshot(\"Subagent 'test_agent' created successfully.\")\n    assert \"test_agent\" in create_subagent_tool._runtime.labor_market.subagents\n\n\nasync def test_create_existing_subagent(create_subagent_tool: CreateSubagent):\n    \"\"\"Test creating a subagent with an existing name.\"\"\"\n    # First, create the subagent\n    await create_subagent_tool(\n        Params(\n            name=\"existing_agent\",\n            system_prompt=\"You are an existing agent.\",\n        )\n    )\n    assert \"existing_agent\" in create_subagent_tool._runtime.labor_market.subagents\n\n    # Try to create the same subagent again\n    result = await create_subagent_tool(\n        Params(\n            name=\"existing_agent\",\n            system_prompt=\"You are an existing agent.\",\n        )\n    )\n    assert result.is_error\n    assert result.message == snapshot(\"Subagent with name 'existing_agent' already exists.\")\n    assert result.brief == snapshot(\"Subagent already exists\")\n    assert \"existing_agent\" in create_subagent_tool._runtime.labor_market.subagents\n"
  },
  {
    "path": "tests/tools/test_extract_key_argument.py",
    "content": "from __future__ import annotations\n\nfrom kimi_cli.tools import extract_key_argument\n\n\nclass TestExtractKeyArgument:\n    \"\"\"Tests for extract_key_argument with string inputs.\"\"\"\n\n    def test_fetchurl(self):\n        result = extract_key_argument('{\"url\": \"https://example.com/a/b/c\"}', \"FetchURL\")\n        assert result is not None\n        assert \"example.com\" in result\n\n    def test_shell(self):\n        result = extract_key_argument('{\"command\": \"ls -la\"}', \"Shell\")\n        assert result == \"ls -la\"\n\n    def test_readfile(self):\n        result = extract_key_argument('{\"path\": \"foo/bar.py\"}', \"ReadFile\")\n        assert result is not None\n        assert \"foo/bar.py\" in result\n\n    def test_grep(self):\n        result = extract_key_argument('{\"pattern\": \"hello\"}', \"Grep\")\n        assert result == \"hello\"\n\n    def test_invalid_json(self):\n        result = extract_key_argument(\"invalid\", \"Shell\")\n        assert result is None\n\n    def test_empty_json_object(self):\n        result = extract_key_argument(\"{}\", \"Shell\")\n        assert result is None\n\n    def test_sendmail_returns_none(self):\n        result = extract_key_argument('{\"to\": \"x\"}', \"SendDMail\")\n        assert result is None\n\n    def test_long_content_truncated(self):\n        long_url = \"https://example.com/\" + \"a\" * 200\n        result = extract_key_argument(f'{{\"url\": \"{long_url}\"}}', \"FetchURL\")\n        assert result is not None\n        # shorten_middle(text, width=50) -> text[:25] + \"...\" + text[-25:]  => length 53\n        assert len(result) <= 53\n\n    def test_unknown_tool_returns_raw_content(self):\n        result = extract_key_argument('{\"a\": 1}', \"UnknownTool\")\n        assert result is not None\n        assert result == '{\"a\": 1}'\n"
  },
  {
    "path": "tests/tools/test_fetch_url.py",
    "content": "# ruff: noqa\n\n\"\"\"Tests for WebFetch tool.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import AsyncIterator\nfrom typing import Protocol\n\nimport pytest\nimport pytest_asyncio\nfrom aiohttp import web\nfrom inline_snapshot import snapshot\nfrom kosong.tooling import ToolReturnValue\n\nfrom kimi_cli.tools.web.fetch import FetchURL, Params\n\n\nclass MockServerFactory(Protocol):\n    async def __call__(\n        self,\n        response_body: str,\n        *,\n        content_type: str = \"text/html\",\n        status: int = 200,\n    ) -> str: ...\n\n\n@pytest_asyncio.fixture\nasync def mock_http_server() -> AsyncIterator[MockServerFactory]:\n    \"\"\"Provide a temporary HTTP server factory that returns static content.\"\"\"\n\n    runners: list[web.AppRunner] = []\n\n    async def start_server(\n        response_body: str,\n        *,\n        content_type: str = \"text/html\",\n        status: int = 200,\n    ) -> str:\n        async def handler(request: web.Request) -> web.Response:  # noqa: ARG001\n            ct_part, sep, charset_part = content_type.partition(\";\")\n            charset_value: str | None = None\n            if sep:\n                _, _, charset_value = charset_part.partition(\"=\")\n                charset_value = charset_value.strip() or None\n\n            content_type_value = ct_part.strip() or None\n            return web.Response(\n                text=response_body,\n                status=status,\n                content_type=content_type_value,\n                charset=charset_value,\n            )\n\n        app = web.Application()\n        app.router.add_get(\"/\", handler)\n\n        runner = web.AppRunner(app)\n        await runner.setup()\n        site = web.TCPSite(runner, host=\"127.0.0.1\", port=0)\n        await site.start()\n\n        sockets = site._server.sockets  # type: ignore[attr-defined]\n        assert sockets, \"Server failed to bind to a port.\"\n        port = sockets[0].getsockname()[1]\n\n        runners.append(runner)\n        return f\"http://127.0.0.1:{port}\"\n\n    try:\n        yield start_server\n    finally:\n        for runner in runners:\n            await runner.cleanup()\n\n\nasync def test_fetch_url_basic_functionality(fetch_url_tool: FetchURL) -> None:\n    \"\"\"Test basic WebFetch functionality.\"\"\"\n    # Test with a reliable website that has content\n    test_url = \"https://github.com/MoonshotAI/Moonlight/issues/4\"\n\n    result = await fetch_url_tool(Params(url=test_url))\n\n    assert not result.is_error\n    assert result.output == snapshot(\n        \"\"\"\\\n---\ntitle: Typo: adamw vs adamW · Issue #4 · MoonshotAI/Moonlight\nauthor: MoonshotAI\nurl: https://github.com/MoonshotAI/Moonlight/issues/4\nhostname: github.com\ndescription: The default parameter value for optimizer should probably be adamw instead of adamW according to how get_optimizer is written.\nsitename: GitHub\ndate: 2025-02-23\ncategories: ['issue:2873381615']\n---\nThe default parameter value for `optimizer` should probably be `adamw` instead of `adamW` according to how `get_optimizer` is written.\\\n\"\"\"\n    )\n\n\nasync def test_fetch_url_invalid_url(fetch_url_tool: FetchURL) -> None:\n    \"\"\"Test fetching from an invalid URL.\"\"\"\n    result = await fetch_url_tool(\n        Params(url=\"https://this-domain-definitely-does-not-exist-12345.com/\")\n    )\n\n    # Should fail with network error\n    assert result.is_error\n    assert \"Failed to fetch URL due to network error:\" in result.message\n\n\nasync def test_fetch_url_404_url(fetch_url_tool: FetchURL) -> None:\n    \"\"\"Test fetching from a URL that returns 404.\"\"\"\n    result = await fetch_url_tool(\n        Params(url=\"https://github.com/MoonshotAI/non-existing-repo/issues/1\")\n    )\n\n    # Should fail with HTTP error\n    assert result.is_error\n    assert result.message == snapshot(\n        \"Failed to fetch URL. Status: 404. This may indicate the page is not accessible or the server is down.\"\n    )\n\n\nasync def test_fetch_url_malformed_url(fetch_url_tool: FetchURL) -> None:\n    \"\"\"Test fetching from a malformed URL.\"\"\"\n    result = await fetch_url_tool(Params(url=\"not-a-valid-url\"))\n\n    # Should fail\n    assert result.is_error\n    assert result.message == snapshot(\n        \"Failed to fetch URL due to network error: not-a-valid-url. This may indicate the URL is invalid or the server is unreachable.\"\n    )\n\n\nasync def test_fetch_url_empty_url(fetch_url_tool: FetchURL) -> None:\n    \"\"\"Test fetching with empty URL.\"\"\"\n    result = await fetch_url_tool(Params(url=\"\"))\n\n    # Should fail\n    assert result.is_error\n    assert result.message == snapshot(\n        \"Failed to fetch URL due to network error: . This may indicate the URL is invalid or the server is unreachable.\"\n    )\n\n\nasync def test_fetch_url_javascript_driven_site(fetch_url_tool: FetchURL) -> None:\n    \"\"\"Test fetching from a JavaScript-driven site that may not work with trafilatura.\"\"\"\n    result = await fetch_url_tool(Params(url=\"https://www.moonshot.ai/\"))\n\n    # This may fail due to JavaScript rendering requirements\n    # If it fails, should indicate extraction issues\n    if result.is_error:\n        assert \"failed to extract meaningful content\" in result.message.lower()\n\n\nasync def test_fetch_url_mocked_http_responses(\n    fetch_url_tool: FetchURL,\n    mock_http_server: MockServerFactory,\n) -> None:\n    \"\"\"Test fetching multiple mocked HTTP responses.\"\"\"\n\n    async def mocked_fetch(resp: str, *, content_type: str = \"text/html\") -> ToolReturnValue:\n        server_url = await mock_http_server(resp, content_type=content_type)\n        return await fetch_url_tool(Params(url=f\"{server_url}/\"))\n\n    # plain markdown. Real example: https://lucumr.pocoo.org/2025/10/17/code.md\n    plain_markdown = \"\"\"\\\n# Title\n\nThis is a markdown document.\n\"\"\"\n    result = await mocked_fetch(plain_markdown, content_type=\"text/markdown; charset=utf-8\")\n    assert not result.is_error\n    assert result.output == snapshot(plain_markdown)\n    assert result.message == \"The returned content is the full content of the page.\"\n\n    # Real example: https://langfuse.com/docs.md\n    complex_markdown = \"\"\"\\\n---\ntitle: Markdown Documentation\ndescription: This is a sample markdown document with front-matter.\n---\n\n# Title\n\nThis is a markdown document.\n\n<div><p>But has some html</p></div>\n\"\"\"\n    result = await mocked_fetch(\n        complex_markdown,\n        content_type=\"text/markdown; charset=utf-8\",\n    )\n    assert not result.is_error\n    assert result.output == snapshot(complex_markdown)\n    assert result.message == \"The returned content is the full content of the page.\"\n\n\nasync def test_fetch_url_with_service(runtime) -> None:\n    \"\"\"Test fetching using the moonshot_fetch service.\"\"\"\n    from kimi_cli.config import Config, MoonshotFetchConfig, Services\n    from pydantic import SecretStr\n\n    # Setup mock service response\n    expected_content = \"# Service Content\\n\\nThis content was fetched via the service.\"\n\n    async def service_handler(request: web.Request) -> web.Response:\n        # Verify request\n        assert request.method == \"POST\"\n        assert request.headers.get(\"Authorization\") == \"Bearer test-key\"\n        assert request.headers.get(\"Accept\") == \"text/markdown\"\n        assert request.headers.get(\"X-Custom-Header\") == \"custom-value\"\n\n        data = await request.json()\n        assert data[\"url\"] == \"https://example.com\"\n\n        return web.Response(text=expected_content)\n\n    # Create a mock server for the service\n    app = web.Application()\n    app.router.add_post(\"/fetch\", service_handler)\n    runner = web.AppRunner(app)\n    await runner.setup()\n    site = web.TCPSite(runner, host=\"127.0.0.1\", port=0)\n    await site.start()\n    port = site._server.sockets[0].getsockname()[1]  # type: ignore\n    service_url = f\"http://127.0.0.1:{port}/fetch\"\n\n    try:\n        # Configure tool with service\n        config = Config(\n            services=Services(\n                moonshot_fetch=MoonshotFetchConfig(\n                    base_url=service_url,\n                    api_key=SecretStr(\"test-key\"),\n                    custom_headers={\"X-Custom-Header\": \"custom-value\"},\n                )\n            )\n        )\n\n        fetch_tool = FetchURL(config=config, runtime=runtime)\n\n        # Execute fetch with tool call context\n        from kimi_cli.wire.types import ToolCall\n        from kimi_cli.soul.toolset import current_tool_call\n\n        token = current_tool_call.set(\n            ToolCall(\n                id=\"test-call-id\", function=ToolCall.FunctionBody(name=\"FetchURL\", arguments=None)\n            )\n        )\n        try:\n            result = await fetch_tool(Params(url=\"https://example.com\"))\n        finally:\n            current_tool_call.reset(token)\n\n        assert not result.is_error\n        assert result.output == expected_content\n        assert result.message == snapshot(\n            \"The returned content is the main content extracted from the page.\"\n        )\n\n    finally:\n        await runner.cleanup()\n"
  },
  {
    "path": "tests/tools/test_glob.py",
    "content": "\"\"\"Tests for the glob tool.\"\"\"\n\nfrom __future__ import annotations\n\nimport platform\nfrom pathlib import Path\n\nimport pytest\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.tools.file.glob import MAX_MATCHES, Glob, Params\n\n\n@pytest.fixture\nasync def test_files(temp_work_dir: KaosPath):\n    \"\"\"Create test files for glob testing.\"\"\"\n    # Create a directory structure\n    await (temp_work_dir / \"src\" / \"main\").mkdir(parents=True)\n    await (temp_work_dir / \"src\" / \"test\").mkdir(parents=True)\n    await (temp_work_dir / \"docs\").mkdir()\n\n    # Create test files\n    await (temp_work_dir / \"README.md\").write_text(\"# README\")\n    await (temp_work_dir / \"setup.py\").write_text(\"setup\")\n    await (temp_work_dir / \"src\" / \"main.py\").write_text(\"main\")\n    await (temp_work_dir / \"src\" / \"utils.py\").write_text(\"utils\")\n    await (temp_work_dir / \"src\" / \"main\" / \"app.py\").write_text(\"app\")\n    await (temp_work_dir / \"src\" / \"main\" / \"config.py\").write_text(\"config\")\n    await (temp_work_dir / \"src\" / \"test\" / \"test_app.py\").write_text(\"test app\")\n    await (temp_work_dir / \"src\" / \"test\" / \"test_config.py\").write_text(\"test config\")\n    await (temp_work_dir / \"docs\" / \"guide.md\").write_text(\"guide\")\n    await (temp_work_dir / \"docs\" / \"api.md\").write_text(\"api\")\n\n    return temp_work_dir\n\n\nasync def test_glob_simple_pattern(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test simple glob pattern matching.\"\"\"\n    result = await glob_tool(Params(pattern=\"*.py\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"setup.py\" in result.output\n    assert \"Found 1 matches\" in result.message\n\n\nasync def test_glob_multiple_matches(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test glob pattern with multiple matches.\"\"\"\n    result = await glob_tool(Params(pattern=\"*.md\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"README.md\" in result.output\n    assert \"Found 1 matches\" in result.message\n\n\nasync def test_glob_recursive_pattern_prohibited(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test that recursive glob pattern starting with **/ is prohibited.\"\"\"\n    result = await glob_tool(Params(pattern=\"**/*.py\", directory=str(test_files)))\n\n    assert result.is_error\n    assert \"starts with '**' which is not allowed\" in result.message\n    assert \"Unsafe pattern\" in result.brief\n\n\nasync def test_glob_safe_recursive_pattern(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test safe recursive glob pattern that doesn't start with **/.\"\"\"\n    result = await glob_tool(Params(pattern=\"src/**/*.py\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    output = result.output.replace(\"\\\\\", \"/\")  # Normalize for Windows paths\n    assert \"src/main.py\" in output\n    assert \"src/utils.py\" in output\n    assert \"src/main/app.py\" in output\n    assert \"src/main/config.py\" in output\n    assert \"src/test/test_app.py\" in output\n    assert \"src/test/test_config.py\" in output\n    assert \"Found 6 matches\" in result.message\n\n\nasync def test_glob_specific_directory(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test glob pattern in specific directory.\"\"\"\n    src_dir = str(test_files / \"src\")\n    result = await glob_tool(Params(pattern=\"*.py\", directory=src_dir))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"main.py\" in result.output\n    assert \"utils.py\" in result.output\n    assert \"Found 2 matches\" in result.message\n\n\nasync def test_glob_recursive_in_subdirectory(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test recursive glob in subdirectory.\"\"\"\n    src_dir = str(test_files / \"src\")\n    result = await glob_tool(Params(pattern=\"main/**/*.py\", directory=src_dir))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    output = result.output.replace(\"\\\\\", \"/\")  # Normalize for Windows paths\n    assert \"main/app.py\" in output\n    assert \"main/config.py\" in output\n    assert \"Found 2 matches\" in result.message\n\n\nasync def test_glob_test_files(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test glob pattern for test files.\"\"\"\n    result = await glob_tool(Params(pattern=\"src/**/*test*.py\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    output = result.output.replace(\"\\\\\", \"/\")  # Normalize for Windows paths\n    assert \"src/test/test_app.py\" in output\n    assert \"src/test/test_config.py\" in output\n    assert \"Found 2 matches\" in result.message\n\n\nasync def test_glob_no_matches(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test glob pattern with no matches.\"\"\"\n    result = await glob_tool(Params(pattern=\"*.xyz\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert result.output == \"\"\n    assert \"No matches found\" in result.message\n\n\nasync def test_glob_exclude_directories(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Test glob with include_dirs=False.\"\"\"\n    # Create both files and directories\n    await (temp_work_dir / \"test_file.txt\").write_text(\"content\")\n    await (temp_work_dir / \"test_dir\").mkdir()\n\n    result = await glob_tool(\n        Params(pattern=\"test_*\", directory=str(temp_work_dir), include_dirs=False)\n    )\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"test_file.txt\" in result.output\n    assert \"test_dir\" not in result.output\n    assert \"Found 1 matches\" in result.message\n\n\nasync def test_glob_with_relative_path(glob_tool: Glob):\n    \"\"\"Test glob with relative path (should fail).\"\"\"\n    result = await glob_tool(Params(pattern=\"*.py\", directory=\"relative/path\"))\n\n    assert result.is_error\n    assert \"not an absolute path\" in result.message\n\n\nasync def test_glob_outside_work_directory(glob_tool: Glob):\n    \"\"\"Test glob outside working directory (should fail).\"\"\"\n    dir = \"/tmp/outside\" if platform.system() != \"Windows\" else \"C:/tmp/outside\"\n    result = await glob_tool(Params(pattern=\"*.py\", directory=dir))\n\n    assert result.is_error\n    assert \"outside the workspace\" in result.message\n\n\nasync def test_glob_outside_work_directory_with_prefix(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Paths sharing the work dir prefix but outside should be blocked.\"\"\"\n    base = Path(str(temp_work_dir))\n    sneaky_dir = base.parent / f\"{base.name}-sneaky\"\n    sneaky_dir.mkdir(parents=True, exist_ok=True)\n\n    result = await glob_tool(Params(pattern=\"*.py\", directory=str(sneaky_dir)))\n\n    assert result.is_error\n    assert \"outside the workspace\" in result.message\n\n\nasync def test_glob_nonexistent_directory(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Test glob in nonexistent directory.\"\"\"\n    nonexistent_dir = str(temp_work_dir / \"nonexistent\")\n    result = await glob_tool(Params(pattern=\"*.py\", directory=nonexistent_dir))\n\n    assert result.is_error\n    assert \"does not exist\" in result.message\n\n\nasync def test_glob_not_a_directory(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Test glob on a file instead of directory.\"\"\"\n    test_file = temp_work_dir / \"test.txt\"\n    await test_file.write_text(\"content\")\n\n    result = await glob_tool(Params(pattern=\"*.py\", directory=str(test_file)))\n\n    assert result.is_error\n    assert \"is not a directory\" in result.message\n\n\nasync def test_glob_single_character_wildcard(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test single character wildcard.\"\"\"\n    result = await glob_tool(Params(pattern=\"?.md\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert result.output == \"\"\n    # Should match single character .md files\n\n\nasync def test_glob_max_matches_limit(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Test that glob respects the MAX_MATCHES limit.\"\"\"\n    # Create more than MAX_MATCHES files\n    for i in range(MAX_MATCHES + 50):\n        await (temp_work_dir / f\"file_{i}.txt\").write_text(f\"content {i}\")\n    result = await glob_tool(Params(pattern=\"*.txt\", directory=str(temp_work_dir)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    # Should only return MAX_MATCHES results\n    output_lines = [line for line in result.output.split(\"\\n\") if line.strip()]\n    assert len(output_lines) == MAX_MATCHES\n    # Should contain warning message\n    assert f\"Only the first {MAX_MATCHES} matches are returned\" in result.message\n\n\nasync def test_glob_enhanced_double_star_validation(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Test enhanced ** pattern validation with directory listing.\"\"\"\n    # Create some top-level files and directories for listing\n    await (temp_work_dir / \"file1.txt\").write_text(\"content1\")\n    await (temp_work_dir / \"file2.py\").write_text(\"content2\")\n    await (temp_work_dir / \"src\").mkdir()\n    await (temp_work_dir / \"docs\").mkdir()\n\n    result = await glob_tool(Params(pattern=\"**/*.txt\", directory=str(temp_work_dir)))\n\n    assert result.is_error\n    assert \"starts with '**' which is not allowed\" in result.message\n    assert \"Use more specific patterns instead\" in result.message\n    # Should include directory listing\n    assert isinstance(result.output, str)\n    assert \"file1.txt\" in result.output\n    assert \"file2.py\" in result.output\n    assert \"src\" in result.output\n    assert \"docs\" in result.output\n\n\nasync def test_glob_exactly_max_matches(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Test behavior when exactly MAX_MATCHES files are found.\"\"\"\n    # Create exactly MAX_MATCHES files\n    for i in range(MAX_MATCHES):\n        await (temp_work_dir / f\"test_{i}.py\").write_text(f\"code {i}\")\n    result = await glob_tool(Params(pattern=\"*.py\", directory=str(temp_work_dir)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    output_lines = [line for line in result.output.split(\"\\n\") if line.strip()]\n    assert len(output_lines) == MAX_MATCHES\n    # Should NOT contain warning message since we have exactly MAX_MATCHES\n    assert \"Only the first\" not in result.message\n    assert f\"Found {MAX_MATCHES} matches\" in result.message\n\n\nasync def test_glob_character_class(glob_tool: Glob, temp_work_dir: KaosPath):\n    \"\"\"Test character class pattern.\"\"\"\n    await (temp_work_dir / \"file1.py\").write_text(\"content1\")\n    await (temp_work_dir / \"file2.py\").write_text(\"content2\")\n    await (temp_work_dir / \"file3.txt\").write_text(\"content3\")\n    result = await glob_tool(Params(pattern=\"file[1-2].py\", directory=str(temp_work_dir)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"file1.py\" in result.output\n    assert \"file2.py\" in result.output\n    assert \"file3.txt\" not in result.output\n\n\nasync def test_glob_complex_pattern(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test complex glob pattern combinations.\"\"\"\n    result = await glob_tool(Params(pattern=\"docs/**/main/*.py\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert result.output == \"\"\n    # Should not match anything since there are no Python files in docs/main\n\n\nasync def test_glob_wildcard_with_double_star_patterns(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test various patterns with ** that are allowed.\"\"\"\n    # Test pattern with ** in the middle\n    result = await glob_tool(Params(pattern=\"**/main/*.py\", directory=str(test_files)))\n\n    assert result.is_error\n    assert \"starts with '**' which is not allowed\" in result.message\n\n    # Test pattern with ** not at the beginning\n    result = await glob_tool(Params(pattern=\"src/**/test_*.py\", directory=str(test_files)))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    output = result.output.replace(\"\\\\\", \"/\")  # Normalize for Windows paths\n    assert \"src/test/test_app.py\" in output\n    assert \"src/test/test_config.py\" in output\n\n\nasync def test_glob_pattern_edge_cases(glob_tool: Glob, test_files: KaosPath):\n    \"\"\"Test edge cases for pattern validation.\"\"\"\n    # Test pattern that has ** but not at the start\n    result = await glob_tool(Params(pattern=\"src/**\", directory=str(test_files)))\n    assert not result.is_error\n\n    # Test pattern that starts with * but not **\n    result = await glob_tool(Params(pattern=\"*.py\", directory=str(test_files)))\n    assert not result.is_error\n\n    # Test pattern that starts with **/\n    result = await glob_tool(Params(pattern=\"**/*.txt\", directory=str(test_files)))\n    assert result.is_error\n    assert \"starts with '**' which is not allowed\" in result.message\n"
  },
  {
    "path": "tests/tools/test_grep.py",
    "content": "\"\"\"Tests for the grep tool.\"\"\"\n\nfrom __future__ import annotations\n\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.tools.file.grep_local import Grep, Params\nfrom kimi_cli.tools.utils import DEFAULT_MAX_CHARS\n\n\n@pytest.fixture\ndef temp_test_files():\n    \"\"\"Create temporary test files for grep testing.\"\"\"\n    with tempfile.TemporaryDirectory() as temp_dir:\n        # Create test files\n        test_file1 = Path(temp_dir) / \"test1.py\"\n        test_file1.write_text(\"\"\"def hello_world():\n    print(\"Hello, World!\")\n    return \"hello\"\n\nclass TestClass:\n    def __init__(self):\n        self.message = \"hello there\"\n\"\"\")\n\n        test_file2 = Path(temp_dir) / \"test2.js\"\n        test_file2.write_text(\"\"\"function helloWorld() {\n    console.log(\"Hello, World!\");\n    return \"hello\";\n}\n\nclass TestClass {\n    constructor() {\n        this.message = \"hello there\";\n    }\n}\n\"\"\")\n\n        test_file3 = Path(temp_dir) / \"readme.txt\"\n        test_file3.write_text(\"\"\"This is a readme file.\nIt contains some text.\nHello world example is here.\n\"\"\")\n\n        # Create a subdirectory with files\n        subdir = Path(temp_dir) / \"subdir\"\n        subdir.mkdir()\n        subfile = subdir / \"subtest.py\"\n        subfile.write_text(\"def sub_hello():\\n    return 'hello from subdir'\\n\")\n\n        yield temp_dir, [test_file1, test_file2, test_file3, subfile]\n\n\nasync def test_grep_files_with_matches(grep_tool: Grep, temp_test_files):\n    \"\"\"Test finding files that contain a pattern.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    # Test basic pattern matching to catch \"Hello\" in readme.txt\n    result = await grep_tool(\n        Params(pattern=\"Hello\", path=temp_dir, output_mode=\"files_with_matches\")\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should find all test files that contain \"hello\" (case insensitive)\n    assert \"test1.py\" in result.output\n    assert \"test2.js\" in result.output\n    assert \"readme.txt\" in result.output\n\n\nasync def test_grep_content_mode(grep_tool: Grep, temp_test_files):\n    \"\"\"Test showing matching lines with content.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"hello\",\n                \"path\": temp_dir,\n                \"output_mode\": \"content\",\n                \"-n\": True,\n                \"-i\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should show matching lines with line numbers\n    assert \"hello\" in result.output.lower()\n    assert \":\" in result.output  # Line numbers should be present\n\n\nasync def test_grep_case_insensitive(grep_tool: Grep, temp_test_files):\n    \"\"\"Test case insensitive search.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"HELLO\",\n                \"path\": temp_dir,\n                \"output_mode\": \"files_with_matches\",\n                \"-i\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should find files with \"hello\" (lowercase)\n    assert \"test1.py\" in result.output\n\n\nasync def test_grep_with_context(grep_tool: Grep, temp_test_files):\n    \"\"\"Test showing context around matches.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"TestClass\",\n                \"path\": temp_dir,\n                \"output_mode\": \"content\",\n                \"-C\": 1,\n                \"-n\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should show context lines\n    lines = result.output.split(\"\\n\")\n    assert len(lines) > 2  # Should have more than just the matching line\n\n\nasync def test_grep_count_matches(grep_tool: Grep, temp_test_files):\n    \"\"\"Test counting matches.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"hello\",\n                \"path\": temp_dir,\n                \"output_mode\": \"count_matches\",\n                \"-i\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should show count for each file\n    assert \"test1.py\" in result.output\n    assert \"test2.js\" in result.output\n\n\nasync def test_grep_with_glob_pattern(grep_tool: Grep, temp_test_files):\n    \"\"\"Test filtering files with glob pattern.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"hello\",\n                \"path\": temp_dir,\n                \"output_mode\": \"files_with_matches\",\n                \"glob\": \"*.py\",\n                \"-i\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should only find Python files\n    assert \"test1.py\" in result.output\n    assert \"subtest.py\" in result.output\n    assert \"test2.js\" not in result.output\n    assert \"readme.txt\" not in result.output\n\n\nasync def test_grep_with_type_filter(grep_tool: Grep, temp_test_files):\n    \"\"\"Test filtering by file type.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"hello\",\n                \"path\": temp_dir,\n                \"output_mode\": \"files_with_matches\",\n                \"type\": \"py\",\n                \"-i\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should only find Python files\n    assert \"test1.py\" in result.output\n    assert \"subtest.py\" in result.output\n    assert \"test2.js\" not in result.output\n    assert \"readme.txt\" not in result.output\n\n\nasync def test_grep_head_limit(grep_tool: Grep, temp_test_files):\n    \"\"\"Test limiting number of results.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"hello\",\n                \"path\": temp_dir,\n                \"output_mode\": \"files_with_matches\",\n                \"head_limit\": 2,\n                \"-i\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n\n    # Should limit results to 2 files\n    lines = [\n        line for line in result.output.split(\"\\n\") if line.strip() and not line.startswith(\"...\")\n    ]\n    assert len(lines) <= 2\n    assert \"... (results truncated to 2 lines)\" in result.output\n\n\nasync def test_grep_output_truncation(grep_tool: Grep):\n    \"\"\"Ensure extremely long output is truncated automatically.\"\"\"\n    with tempfile.TemporaryDirectory() as temp_dir:\n        test_file = Path(temp_dir) / \"big.txt\"\n        test_file.write_text(\n            \"match line with filler content that keeps growing for truncation purposes\\n\" * 2000\n        )\n\n        result = await grep_tool(\n            Params.model_validate(\n                {\n                    \"pattern\": \"match\",\n                    \"path\": temp_dir,\n                    \"output_mode\": \"content\",\n                    \"-n\": True,\n                }\n            )\n        )\n\n        assert not result.is_error\n        assert isinstance(result.output, str)\n        assert result.message == snapshot(\"Output is truncated to fit in the message.\")\n        assert len(result.output) < DEFAULT_MAX_CHARS + 100\n\n\nasync def test_grep_multiline_mode(grep_tool: Grep):\n    \"\"\"Test multiline pattern matching.\"\"\"\n    with tempfile.TemporaryDirectory() as temp_dir:\n        # Create a file with multiline content\n        test_file = Path(temp_dir) / \"multiline.py\"\n        test_file.write_text(\n            \"\"\"def function():\n    '''This is a\n    multiline docstring'''\n    pass\n\"\"\",\n            newline=\"\\n\",\n        )\n\n        # Test multiline pattern\n        result = await grep_tool(\n            Params(\n                pattern=r\"This is a\\n    multiline\",\n                path=temp_dir,\n                output_mode=\"content\",\n                multiline=True,\n            )\n        )\n        assert not result.is_error\n        assert isinstance(result.output, str)\n\n        # Should find the multiline pattern\n        assert \"This is a\" in result.output\n        assert \"multiline\" in result.output\n\n\nasync def test_grep_no_matches(grep_tool: Grep):\n    \"\"\"Test when no matches are found.\"\"\"\n    with tempfile.TemporaryDirectory() as temp_dir:\n        test_file = Path(temp_dir) / \"empty.py\"\n        test_file.write_text(\"# This file has no matching content\\n\")\n\n        result = await grep_tool(\n            Params(pattern=\"nonexistent_pattern\", path=temp_dir, output_mode=\"files_with_matches\")\n        )\n        assert not result.is_error\n        assert result.output == \"\"\n        assert \"No matches found\" in result.message\n\n\nasync def test_grep_invalid_pattern(grep_tool: Grep):\n    \"\"\"Test with invalid regex pattern.\"\"\"\n    result = await grep_tool(Params(pattern=\"[invalid\", path=\".\", output_mode=\"files_with_matches\"))\n    # Should handle the error gracefully\n    assert isinstance(result.output, str)  # Should have output either way\n\n\nasync def test_grep_single_file(grep_tool: Grep):\n    \"\"\"Test searching in a single file.\"\"\"\n    with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".py\") as f:\n        f.write(\"def test_function():\\n    return 'hello world'\\n\")\n        f.flush()\n\n        result = await grep_tool(\n            Params.model_validate(\n                {\n                    \"pattern\": \"hello\",\n                    \"path\": f.name,\n                    \"output_mode\": \"content\",\n                    \"-n\": True,\n                }\n            )\n        )\n        assert not result.is_error\n        assert isinstance(result.output, str)\n\n        assert \"hello\" in result.output\n        # For single file search, filename might not be in content output\n        # Let's just check that we got valid content\n        assert len(result.output.strip()) > 0\n\n\nasync def test_grep_before_after_context(grep_tool: Grep, temp_test_files):\n    \"\"\"Test before and after context separately.\"\"\"\n    temp_dir, test_files = temp_test_files\n\n    # Test before context\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"TestClass\",\n                \"path\": temp_dir,\n                \"output_mode\": \"content\",\n                \"-B\": 2,\n                \"-n\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"TestClass\" in result.output\n    assert \"}\" in result.output\n    assert 'return \"hello\"' in result.output\n    assert \"Hello, World!\" not in result.output\n\n    # Test after context\n    result = await grep_tool(\n        Params.model_validate(\n            {\n                \"pattern\": \"TestClass\",\n                \"path\": temp_dir,\n                \"output_mode\": \"content\",\n                \"-A\": 2,\n                \"-n\": True,\n            }\n        )\n    )\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"TestClass\" in result.output\n    assert \"constructor()\" in result.output\n    assert \"this.message\" in result.output\n    assert \"}\" not in result.output\n"
  },
  {
    "path": "tests/tools/test_read_file.py",
    "content": "\"\"\"Tests for the read_file tool.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\nfrom inline_snapshot import snapshot\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.tools.file.read import (\n    MAX_BYTES,\n    MAX_LINE_LENGTH,\n    MAX_LINES,\n    Params,\n    ReadFile,\n)\n\n\n@pytest.fixture\nasync def sample_file(temp_work_dir: KaosPath) -> KaosPath:\n    \"\"\"Create a sample file with test content.\"\"\"\n    file_path = temp_work_dir / \"sample.txt\"\n    content = \"\"\"Line 1: Hello World\nLine 2: This is a test file\nLine 3: With multiple lines\nLine 4: For testing purposes\nLine 5: End of file\"\"\"\n    await file_path.write_text(content)\n    return file_path\n\n\nasync def test_read_entire_file(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test reading an entire file.\"\"\"\n    result = await read_file_tool(Params(path=str(sample_file)))\n    assert not result.is_error\n    assert result.output == snapshot(\n        \"\"\"\\\n     1\tLine 1: Hello World\n     2\tLine 2: This is a test file\n     3\tLine 3: With multiple lines\n     4\tLine 4: For testing purposes\n     5\tLine 5: End of file\\\n\"\"\"\n    )\n    assert result.message == snapshot(\n        \"5 lines read from file starting from line 1. End of file reached.\"\n    )\n\n\nasync def test_read_with_line_offset(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test reading from a specific line offset.\"\"\"\n    result = await read_file_tool(Params(path=str(sample_file), line_offset=3))\n    assert not result.is_error\n    assert result.output == snapshot(\n        \"\"\"\\\n     3\tLine 3: With multiple lines\n     4\tLine 4: For testing purposes\n     5\tLine 5: End of file\\\n\"\"\"\n    )\n    assert result.message == snapshot(\n        \"3 lines read from file starting from line 3. End of file reached.\"\n    )\n\n\nasync def test_read_with_n_lines(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test reading a specific number of lines.\"\"\"\n    result = await read_file_tool(Params(path=str(sample_file), n_lines=2))\n    assert not result.is_error\n    assert result.output == snapshot(\n        \"\"\"\\\n     1\tLine 1: Hello World\n     2\tLine 2: This is a test file\n\"\"\"\n    )\n    assert result.message == snapshot(\"2 lines read from file starting from line 1.\")\n\n\nasync def test_read_with_line_offset_and_n_lines(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test reading with both line offset and n_lines.\"\"\"\n    result = await read_file_tool(Params(path=str(sample_file), line_offset=2, n_lines=2))\n    assert not result.is_error\n    assert result.output == snapshot(\n        \"\"\"\\\n     2\tLine 2: This is a test file\n     3\tLine 3: With multiple lines\n\"\"\"\n    )\n    assert result.message == snapshot(\"2 lines read from file starting from line 2.\")\n\n\nasync def test_read_nonexistent_file(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading a non-existent file.\"\"\"\n    nonexistent_file = temp_work_dir / \"nonexistent.txt\"\n    result = await read_file_tool(Params(path=str(nonexistent_file)))\n    assert result.is_error\n    assert result.message == snapshot(f\"`{nonexistent_file}` does not exist.\")\n    assert result.brief == snapshot(\"File not found\")\n\n\nasync def test_read_directory_instead_of_file(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test attempting to read a directory.\"\"\"\n    result = await read_file_tool(Params(path=str(temp_work_dir)))\n    assert result.is_error\n    assert result.message == snapshot(f\"`{temp_work_dir}` is not a file.\")\n    assert result.brief == snapshot(\"Invalid path\")\n\n\nasync def test_read_with_relative_path(\n    read_file_tool: ReadFile, temp_work_dir: KaosPath, sample_file: KaosPath\n):\n    \"\"\"Test reading with a relative path.\"\"\"\n    result = await read_file_tool(Params(path=str(sample_file.relative_to(temp_work_dir))))\n    assert not result.is_error\n    assert result.message == snapshot(\n        \"5 lines read from file starting from line 1. End of file reached.\"\n    )\n    assert result.output == snapshot(\"\"\"\\\n     1\tLine 1: Hello World\n     2\tLine 2: This is a test file\n     3\tLine 3: With multiple lines\n     4\tLine 4: For testing purposes\n     5\tLine 5: End of file\\\n\"\"\")\n\n\nasync def test_read_with_relative_path_outside_work_dir(\n    read_file_tool: ReadFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test reading a file outside the work directory with a relative path (should fail).\"\"\"\n    path = Path(\"..\") / \"outside_file.txt\"\n    result = await read_file_tool(Params(path=str(path)))\n    assert result.is_error\n    assert result.message == snapshot(\n        f\"`{path}` is not an absolute path. \"\n        \"You must provide an absolute path to read a file outside the working directory.\"\n    )\n    assert result.output == snapshot(\"\")\n\n\nasync def test_read_empty_file(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading an empty file.\"\"\"\n    empty_file = temp_work_dir / \"empty.txt\"\n    await empty_file.write_text(\"\")\n\n    result = await read_file_tool(Params(path=str(empty_file)))\n    assert not result.is_error\n    assert result.output == snapshot(\"\")\n    assert result.message == snapshot(\"No lines read from file. End of file reached.\")\n\n\nasync def test_read_image_file(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading an image file.\"\"\"\n    image_file = temp_work_dir / \"sample.png\"\n    data = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"pngdata\"\n    await image_file.write_bytes(data)\n\n    result = await read_file_tool(Params(path=str(image_file)))\n\n    assert result.is_error\n    assert result.message == snapshot(\n        f\"`{image_file}` is a image file. Use other appropriate tools to read image or video files.\"\n    )\n    assert result.brief == snapshot(\"Unsupported file type\")\n\n\nasync def test_read_extensionless_image_file(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading an extensionless image file.\"\"\"\n    image_file = temp_work_dir / \"sample\"\n    data = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"pngdata\"\n    await image_file.write_bytes(data)\n\n    result = await read_file_tool(Params(path=str(image_file)))\n\n    assert result.is_error\n    assert result.message == snapshot(\n        f\"`{image_file}` is a image file. Use other appropriate tools to read image or video files.\"\n    )\n    assert result.brief == snapshot(\"Unsupported file type\")\n\n\nasync def test_read_video_file(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading a video file.\"\"\"\n    video_file = temp_work_dir / \"sample.mp4\"\n    data = b\"\\x00\\x00\\x00\\x18ftypmp42\\x00\\x00\\x00\\x00mp42isom\"\n    await video_file.write_bytes(data)\n\n    result = await read_file_tool(Params(path=str(video_file)))\n\n    assert result.is_error\n    assert result.message == snapshot(\n        f\"`{video_file}` is a video file. Use other appropriate tools to read image or video files.\"\n    )\n    assert result.brief == snapshot(\"Unsupported file type\")\n\n\nasync def test_read_line_offset_beyond_file_length(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test reading with line offset beyond file length.\"\"\"\n    result = await read_file_tool(Params(path=str(sample_file), line_offset=10))\n    assert not result.is_error\n    assert result.output == snapshot(\"\")\n    assert result.message == snapshot(\"No lines read from file. End of file reached.\")\n\n\nasync def test_read_unicode_file(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading a file with unicode characters.\"\"\"\n    unicode_file = temp_work_dir / \"unicode.txt\"\n    content = \"Hello 世界 🌍\\nUnicode test: café, naïve, résumé\"\n    await unicode_file.write_text(content, encoding=\"utf-8\")\n\n    result = await read_file_tool(Params(path=str(unicode_file)))\n    assert not result.is_error\n    assert result.output == snapshot(\n        \"\"\"\\\n     1\tHello 世界 🌍\n     2\tUnicode test: café, naïve, résumé\\\n\"\"\"\n    )\n    assert result.message == snapshot(\n        \"2 lines read from file starting from line 1. End of file reached.\"\n    )\n\n\nasync def test_read_edge_cases(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test edge cases for line offset reading.\"\"\"\n    # Test reading from line 1 (should be same as default)\n    result = await read_file_tool(Params(path=str(sample_file), line_offset=1))\n    assert not result.is_error\n    assert result.output == snapshot(\n        \"\"\"\\\n     1\tLine 1: Hello World\n     2\tLine 2: This is a test file\n     3\tLine 3: With multiple lines\n     4\tLine 4: For testing purposes\n     5\tLine 5: End of file\\\n\"\"\"\n    )\n    assert result.message == snapshot(\n        \"5 lines read from file starting from line 1. End of file reached.\"\n    )\n\n    # Test reading from line 5 (last line)\n    result = await read_file_tool(Params(path=str(sample_file), line_offset=5))\n    assert not result.is_error\n    assert result.output == snapshot(\"     5\\tLine 5: End of file\")\n    assert result.message == snapshot(\n        \"1 lines read from file starting from line 5. End of file reached.\"\n    )\n\n    # Test reading with offset and n_lines combined\n    result = await read_file_tool(Params(path=str(sample_file), line_offset=2, n_lines=1))\n    assert not result.is_error\n    assert result.output == snapshot(\"     2\\tLine 2: This is a test file\\n\")\n    assert result.message == snapshot(\"1 lines read from file starting from line 2.\")\n\n\nasync def test_line_truncation_and_messaging(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test line truncation functionality and messaging.\"\"\"\n\n    # Test single long line truncation\n    single_line_file = temp_work_dir / \"single_long_line.txt\"\n    long_content = \"A\" * 2500 + \" This should be truncated\"\n    await single_line_file.write_text(long_content)\n\n    result = await read_file_tool(Params(path=str(single_line_file)))\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"1 lines read from\" in result.message\n    # Check that the line is truncated and ends with \"...\"\n    assert result.output.endswith(\"...\")\n\n    # Verify exact length after truncation (accounting for line number prefix)\n    lines = result.output.split(\"\\n\")\n    content_line = [line for line in lines if line.strip()][0]\n    actual_content = content_line.split(\"\\t\", 1)[1] if \"\\t\" in content_line else content_line\n    assert len(actual_content) == MAX_LINE_LENGTH\n\n    # Test multiple long lines with truncation messaging\n    multi_line_file = temp_work_dir / \"multi_truncation_test.txt\"\n    long_line_1 = \"A\" * 2500\n    long_line_2 = \"B\" * 3000\n    normal_line = \"Short line\"\n    content = f\"{long_line_1}\\n{normal_line}\\n{long_line_2}\"\n    await multi_line_file.write_text(content)\n\n    result = await read_file_tool(Params(path=str(multi_line_file)))\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert result.message == snapshot(\n        \"3 lines read from file starting from line 1. End of file reached. \"\n        \"Lines [1, 3] were truncated.\"\n    )\n\n    # Verify truncation actually happened for specific lines\n    lines = result.output.split(\"\\n\")\n    endings = [line[-20:] for line in lines]\n    assert endings == snapshot(\n        [\n            \"AAAAAAAAAAAAAAAAA...\",\n            \"     2\\tShort line\",\n            \"BBBBBBBBBBBBBBBBB...\",\n        ]\n    )\n\n\nasync def test_parameter_validation_line_offset(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test that line_offset parameter validation works correctly.\"\"\"\n    # Test line_offset < 1 should be rejected by Pydantic validation\n    with pytest.raises(ValueError, match=\"line_offset\"):\n        Params(path=str(sample_file), line_offset=0)\n\n    with pytest.raises(ValueError, match=\"line_offset\"):\n        Params(path=str(sample_file), line_offset=-1)\n\n\nasync def test_parameter_validation_n_lines(read_file_tool: ReadFile, sample_file: KaosPath):\n    \"\"\"Test that n_lines parameter validation works correctly.\"\"\"\n    # Test n_lines < 1 should be rejected by Pydantic validation\n    with pytest.raises(ValueError, match=\"n_lines\"):\n        Params(path=str(sample_file), n_lines=0)\n\n    with pytest.raises(ValueError, match=\"n_lines\"):\n        Params(path=str(sample_file), n_lines=-1)\n\n\nasync def test_max_lines_boundary(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test that reading respects the MAX_LINES boundary.\"\"\"\n    # Create a file with more than MAX_LINES lines\n    large_file = temp_work_dir / \"large_file.txt\"\n    content = \"\\n\".join([f\"Line {i}\" for i in range(1, MAX_LINES + 10)])\n    await large_file.write_text(content)\n\n    # Request more than MAX_LINES to trigger the boundary check\n    result = await read_file_tool(Params(path=str(large_file), n_lines=MAX_LINES + 5))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    # Should read MAX_LINES lines, not the full file\n    assert f\"Max {MAX_LINES} lines reached\" in result.message\n    # Count actual lines in output (accounting for line numbers)\n    output_lines = [line for line in result.output.split(\"\\n\") if line.strip()]\n    assert len(output_lines) == MAX_LINES\n\n\nasync def test_max_bytes_boundary(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test that reading respects the MAX_BYTES boundary.\"\"\"\n    # Create a file that exceeds MAX_BYTES\n    large_file = temp_work_dir / \"large_bytes.txt\"\n    # Create content that will exceed 100KB but stay under MAX_LINES\n    line_content = \"A\" * 1000  # 1000 characters per line\n    num_lines = (MAX_BYTES // 1000) + 5  # Enough to exceed MAX_BYTES\n    content = \"\\n\".join([line_content] * num_lines)\n    await large_file.write_text(content)\n\n    result = await read_file_tool(Params(path=str(large_file)))\n\n    assert not result.is_error\n    assert f\"Max {MAX_BYTES} bytes reached\" in result.message\n\n\nasync def test_read_with_tilde_path_expansion(read_file_tool: ReadFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading with ~ path expansion.\"\"\"\n    # Create a test file in temp_work_dir and use ~ to reference it\n    # We simulate by creating a file and checking that ~ expands correctly\n    home = Path.home()\n    test_file = home / \".kimi_test_expanduser_temp\"\n    test_content = \"Test content for tilde expansion\"\n\n    try:\n        # Create the test file in home directory\n        test_file.write_text(test_content)\n\n        # Read using ~ path\n        result = await read_file_tool(Params(path=\"~/.kimi_test_expanduser_temp\"))\n\n        assert not result.is_error\n        assert \"Test content for tilde expansion\" in result.output\n        assert result.message == snapshot(\n            \"1 lines read from file starting from line 1. End of file reached.\"\n        )\n    finally:\n        # Clean up\n        if test_file.exists():\n            test_file.unlink()\n"
  },
  {
    "path": "tests/tools/test_read_media_file.py",
    "content": "\"\"\"Tests for the ReadMediaFile tool.\"\"\"\n\nfrom __future__ import annotations\n\nfrom io import BytesIO\nfrom typing import cast\n\nimport pytest\nfrom inline_snapshot import snapshot\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.tools.file.read_media import Params, ReadMediaFile\nfrom kimi_cli.wire.types import ImageURLPart, TextPart, VideoURLPart\n\n\nasync def test_read_image_file(read_media_file_tool: ReadMediaFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading an image file.\"\"\"\n    image_file = temp_work_dir / \"sample.png\"\n    data = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"pngdata\"\n    await image_file.write_bytes(data)\n\n    result = await read_media_file_tool(Params(path=str(image_file)))\n\n    assert not result.is_error\n    assert isinstance(result.output, list)\n    assert len(result.output) == 3\n    assert result.output[0] == TextPart(text=f'<image path=\"{image_file}\">')\n    assert result.output[2] == TextPart(text=\"</image>\")\n    part = result.output[1]\n    assert isinstance(part, ImageURLPart)\n    assert part.image_url.url.startswith(\"data:image/png;base64,\")\n    assert result.message == snapshot(\n        f\"Loaded image file `{image_file}` (image/png, {len(data)} bytes). \"\n        \"If you need to output coordinates, output relative coordinates first and \"\n        \"compute absolute coordinates using the original image size; if you generate or \"\n        \"edit images/videos via commands or scripts, read the result back immediately \"\n        \"before continuing.\"\n    )\n\n\nasync def test_read_extensionless_image_file(\n    read_media_file_tool: ReadMediaFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test reading an extensionless image file.\"\"\"\n    image_file = temp_work_dir / \"sample\"\n    data = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"pngdata\"\n    await image_file.write_bytes(data)\n\n    result = await read_media_file_tool(Params(path=str(image_file)))\n\n    assert not result.is_error\n    assert isinstance(result.output, list)\n    assert len(result.output) == 3\n    assert result.output[0] == TextPart(text=f'<image path=\"{image_file}\">')\n    assert result.output[2] == TextPart(text=\"</image>\")\n    part = result.output[1]\n    assert isinstance(part, ImageURLPart)\n    assert part.image_url.url.startswith(\"data:image/png;base64,\")\n    assert result.message == snapshot(\n        f\"Loaded image file `{image_file}` (image/png, {len(data)} bytes). \"\n        \"If you need to output coordinates, output relative coordinates first and \"\n        \"compute absolute coordinates using the original image size; if you generate or \"\n        \"edit images/videos via commands or scripts, read the result back immediately \"\n        \"before continuing.\"\n    )\n\n\nasync def test_read_image_file_with_size(\n    read_media_file_tool: ReadMediaFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test reading an image file with detectable dimensions.\"\"\"\n    Image = pytest.importorskip(\"PIL.Image\")\n    image_file = temp_work_dir / \"valid.png\"\n    image = Image.new(\"RGB\", (3, 4), color=(0, 0, 0))\n    buffer = BytesIO()\n    image.save(buffer, format=\"PNG\")\n    data = buffer.getvalue()\n    await image_file.write_bytes(data)\n\n    result = await read_media_file_tool(Params(path=str(image_file)))\n\n    assert not result.is_error\n    assert result.message == snapshot(\n        f\"Loaded image file `{image_file}` (image/png, {len(data)} bytes, original size 3x4px). \"\n        \"If you need to output coordinates, output relative coordinates first and \"\n        \"compute absolute coordinates using the original image size; if you generate or \"\n        \"edit images/videos via commands or scripts, read the result back immediately \"\n        \"before continuing.\"\n    )\n\n\nasync def test_read_video_file(read_media_file_tool: ReadMediaFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading a video file.\"\"\"\n    video_file = temp_work_dir / \"sample.mp4\"\n    data = b\"\\x00\\x00\\x00\\x18ftypmp42\\x00\\x00\\x00\\x00mp42isom\"\n    await video_file.write_bytes(data)\n\n    result = await read_media_file_tool(Params(path=str(video_file)))\n\n    assert not result.is_error\n    assert isinstance(result.output, list)\n    assert len(result.output) == 3\n    assert result.output[0] == TextPart(text=f'<video path=\"{video_file}\">')\n    assert result.output[2] == TextPart(text=\"</video>\")\n    part = result.output[1]\n    assert isinstance(part, VideoURLPart)\n    assert part.video_url.url.startswith(\"data:video/mp4;base64,\")\n    assert result.message == snapshot(\n        f\"Loaded video file `{video_file}` (video/mp4, {len(data)} bytes). \"\n        \"If you need to output coordinates, output relative coordinates first and \"\n        \"compute absolute coordinates using the original image size; if you generate or \"\n        \"edit images/videos via commands or scripts, read the result back immediately \"\n        \"before continuing.\"\n    )\n\n\nasync def test_read_text_file(read_media_file_tool: ReadMediaFile, temp_work_dir: KaosPath):\n    \"\"\"Test reading a text file with ReadMediaFile.\"\"\"\n    text_file = temp_work_dir / \"sample.txt\"\n    await text_file.write_text(\"hello\")\n\n    result = await read_media_file_tool(Params(path=str(text_file)))\n\n    assert result.is_error\n    assert result.message == snapshot(\n        f\"`{text_file}` is a text file. Use ReadFile to read text files.\"\n    )\n    assert result.brief == snapshot(\"Unsupported file type\")\n\n\nasync def test_read_video_file_without_capability(runtime: Runtime, temp_work_dir: KaosPath):\n    \"\"\"Test reading a video file without video capability.\"\"\"\n    assert runtime.llm is not None\n    runtime.llm.capabilities = cast(set[ModelCapability], {\"image_in\"})\n    read_media_file_tool = ReadMediaFile(runtime)\n\n    video_file = temp_work_dir / \"sample.mp4\"\n    data = b\"\\x00\\x00\\x00\\x18ftypmp42\\x00\\x00\\x00\\x00mp42isom\"\n    await video_file.write_bytes(data)\n\n    result = await read_media_file_tool(Params(path=str(video_file)))\n\n    assert result.is_error\n    assert result.message == snapshot(\n        \"The current model does not support video input. \"\n        \"Tell the user to use a model with video input capability.\"\n    )\n    assert result.brief == snapshot(\"Unsupported media type\")\n"
  },
  {
    "path": "tests/tools/test_read_media_file_desc.py",
    "content": "from __future__ import annotations\n\nfrom typing import cast\n\n# ruff: noqa\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.soul.agent import Runtime\nfrom kimi_cli.tools import SkipThisTool\nfrom kimi_cli.tools.file.read_media import ReadMediaFile\n\n\n@pytest.mark.parametrize(\n    (\"capabilities\", \"expected\"),\n    [\n        (\n            {\"image_in\", \"video_in\"},\n            snapshot(\n                \"\"\"\\\nRead media content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read image or video files. To read other types of files, use the ReadFile tool. To list directories, use the Glob tool or `ls` command via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- The maximum size that can be read is 100MB. An error will be returned if the file is larger than this limit.\n- The media content will be returned in a form that you can directly view and understand.\n\n**Capabilities**\n- This tool supports image and video files for the current model.\n\"\"\"\n            ),\n        ),\n        (\n            {\"image_in\"},\n            snapshot(\n                \"\"\"\\\nRead media content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read image or video files. To read other types of files, use the ReadFile tool. To list directories, use the Glob tool or `ls` command via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- The maximum size that can be read is 100MB. An error will be returned if the file is larger than this limit.\n- The media content will be returned in a form that you can directly view and understand.\n\n**Capabilities**\n- This tool supports image files for the current model.\n- Video files are not supported by the current model.\n\"\"\"\n            ),\n        ),\n        (\n            {\"video_in\"},\n            snapshot(\n                \"\"\"\\\nRead media content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read image or video files. To read other types of files, use the ReadFile tool. To list directories, use the Glob tool or `ls` command via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- The maximum size that can be read is 100MB. An error will be returned if the file is larger than this limit.\n- The media content will be returned in a form that you can directly view and understand.\n\n**Capabilities**\n- This tool supports video files for the current model.\n- Image files are not supported by the current model.\n\"\"\"\n            ),\n        ),\n    ],\n)\ndef test_read_media_file_description_by_capabilities(\n    runtime: Runtime, capabilities: set[str], expected: str\n) -> None:\n    assert runtime.llm is not None\n    runtime.llm.capabilities = cast(set[ModelCapability], capabilities)\n    assert ReadMediaFile(runtime).base.description == expected\n\n\ndef test_read_media_file_description_without_capabilities(runtime: Runtime) -> None:\n    assert runtime.llm is not None\n    runtime.llm.capabilities = cast(set[ModelCapability], set())\n    with pytest.raises(SkipThisTool):\n        ReadMediaFile(runtime)\n"
  },
  {
    "path": "tests/tools/test_shell_bash.py",
    "content": "\"\"\"Tests for the shell tool.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport platform\n\nimport pytest\nfrom inline_snapshot import snapshot\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.tools.shell import Params, Shell\nfrom kimi_cli.tools.utils import DEFAULT_MAX_CHARS\n\npytestmark = pytest.mark.skipif(\n    platform.system() == \"Windows\", reason=\"Bash tests run only on non-Windows.\"\n)\n\n\nasync def test_simple_command(shell_tool: Shell):\n    \"\"\"Test executing a simple command.\"\"\"\n    result = await shell_tool(Params(command=\"echo 'Hello World'\"))\n    assert not result.is_error\n    assert result.output == snapshot(\"Hello World\\n\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_command_with_error(shell_tool: Shell):\n    \"\"\"Test executing a command that returns an error.\"\"\"\n    result = await shell_tool(Params(command=\"ls /nonexistent/directory\"))\n    assert result.is_error\n    assert isinstance(result.output, str)\n    assert \"No such file or directory\" in result.output\n    assert \"Command failed with exit code:\" in result.message\n    assert \"Failed with exit code:\" in result.brief\n\n\nasync def test_command_chaining(shell_tool: Shell):\n    \"\"\"Test command chaining with &&.\"\"\"\n    result = await shell_tool(Params(command=\"echo 'First' && echo 'Second'\"))\n    assert not result.is_error\n    assert result.output == snapshot(\"\"\"\\\nFirst\nSecond\n\"\"\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_command_sequential(shell_tool: Shell):\n    \"\"\"Test sequential command execution with ;.\"\"\"\n    result = await shell_tool(Params(command=\"echo 'One'; echo 'Two'\"))\n    assert not result.is_error\n    assert result.output == snapshot(\"\"\"\\\nOne\nTwo\n\"\"\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_command_conditional(shell_tool: Shell):\n    \"\"\"Test conditional command execution with ||.\"\"\"\n    result = await shell_tool(Params(command=\"false || echo 'Success'\"))\n    assert not result.is_error\n    assert result.output == snapshot(\"Success\\n\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_command_pipe(shell_tool: Shell):\n    \"\"\"Test command piping.\"\"\"\n    result = await shell_tool(Params(command=\"echo 'Hello World' | wc -w\"))\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert result.output.strip() == snapshot(\"2\")\n\n\nasync def test_multiple_pipes(shell_tool: Shell):\n    \"\"\"Test multiple pipes in one command.\"\"\"\n    result = await shell_tool(Params(command=\"echo -e '1\\\\n2\\\\n3' | grep '2' | wc -l\"))\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert result.output.strip() == snapshot(\"1\")\n\n\nasync def test_command_with_timeout(shell_tool: Shell):\n    \"\"\"Test command execution with timeout.\"\"\"\n    result = await shell_tool(Params(command=\"sleep 0.1\", timeout=1))\n    assert not result.is_error\n    assert result.output == snapshot(\"\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_command_timeout_expires(shell_tool: Shell):\n    \"\"\"Test command that times out.\"\"\"\n    result = await shell_tool(Params(command=\"sleep 2\", timeout=1))\n    assert result.is_error\n    assert result.message == snapshot(\"Command killed by timeout (1s)\")\n    assert result.brief == snapshot(\"Killed by timeout (1s)\")\n\n\nasync def test_environment_variables(shell_tool: Shell):\n    \"\"\"Test setting and using environment variables.\"\"\"\n    result = await shell_tool(Params(command=\"export TEST_VAR='test_value' && echo $TEST_VAR\"))\n    assert not result.is_error\n    assert result.output == snapshot(\"test_value\\n\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_file_operations(shell_tool: Shell, temp_work_dir: KaosPath):\n    \"\"\"Test basic file operations.\"\"\"\n    # Create a test file\n    result = await shell_tool(\n        Params(command=f\"echo 'Test content' > {temp_work_dir}/test_file.txt\")\n    )\n    assert not result.is_error\n    assert result.output == snapshot(\"\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n    # Read the file\n    result = await shell_tool(Params(command=f\"cat {temp_work_dir}/test_file.txt\"))\n    assert not result.is_error\n    assert result.output == snapshot(\"Test content\\n\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_text_processing(shell_tool: Shell):\n    \"\"\"Test text processing commands.\"\"\"\n    result = await shell_tool(Params(command=\"echo 'apple banana cherry' | sed 's/banana/orange/'\"))\n    assert not result.is_error\n    assert result.output == snapshot(\"apple orange cherry\\n\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_command_substitution(shell_tool: Shell):\n    \"\"\"Test command substitution with a portable command.\"\"\"\n    result = await shell_tool(Params(command='echo \"Result: $(echo hello)\"'))\n    assert not result.is_error\n    assert result.output == snapshot(\"Result: hello\\n\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_arithmetic_substitution(shell_tool: Shell):\n    \"\"\"Test arithmetic substitution - more portable than date command.\"\"\"\n    result = await shell_tool(Params(command='echo \"Answer: $((2 + 2))\"'))\n    assert not result.is_error\n    assert result.output == snapshot(\"Answer: 4\\n\")\n    assert result.message == snapshot(\"Command executed successfully.\")\n\n\nasync def test_very_long_output(shell_tool: Shell):\n    \"\"\"Test command that produces very long output.\"\"\"\n    result = await shell_tool(Params(command=\"seq 1 100 | head -50\"))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert \"1\" in result.output\n    assert \"50\" in result.output\n    assert \"51\" not in result.output  # Should not contain 51\n\n\nasync def test_output_truncation_on_success(shell_tool: Shell):\n    \"\"\"Test that very long output gets truncated on successful command.\"\"\"\n    # Generate output longer than MAX_OUTPUT_LENGTH\n    oversize_length = DEFAULT_MAX_CHARS + 1000\n    result = await shell_tool(Params(command=f\"python3 -c \\\"print('X' * {oversize_length})\\\"\"))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    # Check if output was truncated (it should be)\n    if len(result.output) > DEFAULT_MAX_CHARS:\n        assert result.output.endswith(\"[...truncated]\\n\")\n        assert \"Output is truncated\" in result.message\n    assert \"Command executed successfully\" in result.message\n\n\nasync def test_output_truncation_on_failure(shell_tool: Shell):\n    \"\"\"Test that very long output gets truncated even when command fails.\"\"\"\n    # Generate long output with a command that will fail\n    result = await shell_tool(\n        Params(command=\"python3 -c \\\"import sys; print('ERROR_' * 8000); sys.exit(1)\\\"\")\n    )\n\n    assert result.is_error\n    assert isinstance(result.output, str)\n    # Check if output was truncated\n    if len(result.output) > DEFAULT_MAX_CHARS:\n        assert result.output.endswith(\"[...truncated]\\n\")\n        assert \"Output is truncated\" in result.message\n    assert \"Command failed with exit code:\" in result.message\n\n\nasync def test_timeout_parameter_validation_bounds(shell_tool: Shell):\n    \"\"\"Test timeout parameter validation (bounds checking).\"\"\"\n    # Test timeout < 1 (should fail validation)\n    with pytest.raises(ValueError, match=\"timeout\"):\n        Params(command=\"echo test\", timeout=0)\n\n    with pytest.raises(ValueError, match=\"timeout\"):\n        Params(command=\"echo test\", timeout=-1)\n\n    # Test timeout > MAX_BACKGROUND_TIMEOUT (should fail validation)\n    from kimi_cli.tools.shell import MAX_BACKGROUND_TIMEOUT, MAX_FOREGROUND_TIMEOUT\n\n    with pytest.raises(ValueError, match=\"timeout\"):\n        Params(command=\"echo test\", timeout=MAX_BACKGROUND_TIMEOUT + 1)\n\n    # Test foreground timeout > MAX_FOREGROUND_TIMEOUT (should fail validation)\n    with pytest.raises(ValueError, match=\"foreground\"):\n        Params(command=\"echo test\", timeout=MAX_FOREGROUND_TIMEOUT + 1)\n\n    # Background commands can use longer timeouts\n    params = Params(\n        command=\"make build\",\n        timeout=MAX_FOREGROUND_TIMEOUT + 1,\n        run_in_background=True,\n        description=\"long build\",\n    )\n    assert params.timeout == MAX_FOREGROUND_TIMEOUT + 1\n\n\nasync def test_shell_works_in_plan_mode(shell_tool: Shell, runtime):\n    \"\"\"Shell should still work in plan mode — plan mode constraints are enforced by\n    the dynamic injection prompt, not by hard-blocking the tool.\"\"\"\n    runtime.session.state.plan_mode = True\n\n    result = await shell_tool(Params(command=\"echo plan_ok\"))\n\n    assert not result.is_error\n    assert \"plan_ok\" in result.output\n\n\nasync def test_cancelled_command_kills_process(shell_tool: Shell, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Test that cancelling a shell run kills the underlying process.\"\"\"\n\n    started = asyncio.Event()\n\n    class BlockingReadable:\n        async def readline(self) -> bytes:\n            started.set()\n            await asyncio.Event().wait()\n            raise AssertionError(\"unreachable\")\n\n    class FakeProcess:\n        def __init__(self) -> None:\n            self.stdout = BlockingReadable()\n            self.stderr = BlockingReadable()\n            self.kill_calls = 0\n\n        async def wait(self) -> int:\n            return 0\n\n        async def kill(self) -> None:\n            self.kill_calls += 1\n\n    fake_process = FakeProcess()\n\n    async def fake_exec(*_args, **_kwargs) -> FakeProcess:\n        return fake_process\n\n    monkeypatch.setattr(\"kimi_cli.tools.shell.kaos.exec\", fake_exec)\n\n    task = asyncio.create_task(\n        shell_tool._run_shell_command(\"sleep 10\", lambda _line: None, lambda _line: None, 60)\n    )\n    await asyncio.wait_for(started.wait(), timeout=1.0)\n    task.cancel()\n\n    with pytest.raises(asyncio.CancelledError):\n        await task\n\n    assert fake_process.kill_calls == 1\n"
  },
  {
    "path": "tests/tools/test_shell_powershell.py",
    "content": "\"\"\"Basic Windows cmd tests for the shell tool.\"\"\"\n\nfrom __future__ import annotations\n\nimport platform\n\nimport pytest\nfrom inline_snapshot import snapshot\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.tools.shell import Params, Shell\n\npytestmark = pytest.mark.skipif(\n    platform.system() != \"Windows\", reason=\"PowerShell tests run only on Windows.\"\n)\n\n\nasync def test_simple_command(shell_tool: Shell):\n    \"\"\"Ensure a basic cmd command runs.\"\"\"\n    result = await shell_tool(Params(command='echo \"Hello Windows\"'))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert result.output.strip() == snapshot(\"Hello Windows\")\n    assert \"Command executed successfully\" in result.message\n\n\nasync def test_command_with_error(shell_tool: Shell):\n    \"\"\"Failing commands should return a ToolError with exit code info.\"\"\"\n    result = await shell_tool(Params(command='python -c \"import sys; sys.exit(1)\"'))\n\n    assert result.is_error\n    assert result.output == snapshot(\"\")\n    assert \"Command failed with exit code: 1\" in result.message\n    assert \"Failed with exit code: 1\" in result.brief\n\n\nasync def test_command_chaining(shell_tool: Shell):\n    \"\"\"Chaining commands with && should work.\"\"\"\n    result = await shell_tool(Params(command=\"echo First; if ($?) { echo Second }\"))\n\n    assert not result.is_error\n    assert isinstance(result.output, str)\n    assert result.output.replace(\"\\r\\n\", \"\\n\") == snapshot(\"First\\nSecond\\n\")\n\n\nasync def test_file_operations(shell_tool: Shell, temp_work_dir: KaosPath):\n    \"\"\"Basic file write/read using cmd redirection.\"\"\"\n    file_path = temp_work_dir / \"test_file.txt\"\n\n    create_result = await shell_tool(Params(command=f'echo \"Test content\" > \"{file_path}\"'))\n    assert create_result.output == snapshot(\"\")\n    assert create_result.message == snapshot(\"Command executed successfully.\")\n    assert create_result.brief == snapshot(\"\")\n\n    read_result = await shell_tool(Params(command=f'type \"{file_path}\"'))\n    assert read_result.output == snapshot(\"Test content\\r\\n\")\n    assert read_result.message == snapshot(\"Command executed successfully.\")\n    assert read_result.brief == snapshot(\"\")\n"
  },
  {
    "path": "tests/tools/test_str_replace_file.py",
    "content": "\"\"\"Tests for the str_replace_file tool.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.tools.file.replace import Edit, Params, StrReplaceFile\nfrom kimi_cli.wire.types import DiffDisplayBlock\n\n\nasync def test_replace_single_occurrence(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing a single occurrence.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"Hello world! This is a test.\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(path=str(file_path), edit=Edit(old=\"world\", new=\"universe\"))\n    )\n\n    assert not result.is_error\n    assert \"successfully edited\" in result.message\n    diff_block = next(block for block in result.display if block.type == \"diff\")\n    assert isinstance(diff_block, DiffDisplayBlock)\n    assert diff_block.path == str(file_path)\n    assert diff_block.old_text == original_content\n    assert diff_block.new_text == \"Hello universe! This is a test.\"\n    assert await file_path.read_text() == \"Hello universe! This is a test.\"\n\n\nasync def test_replace_all_occurrences(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing all occurrences.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"apple banana apple cherry apple\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(\n            path=str(file_path),\n            edit=Edit(old=\"apple\", new=\"fruit\", replace_all=True),\n        )\n    )\n\n    assert not result.is_error\n    assert \"successfully edited\" in result.message\n    assert await file_path.read_text() == \"fruit banana fruit cherry fruit\"\n\n\nasync def test_replace_multiple_edits(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test applying multiple edits.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"Hello world! Goodbye world!\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(\n            path=str(file_path),\n            edit=[\n                Edit(old=\"Hello\", new=\"Hi\"),\n                Edit(old=\"Goodbye\", new=\"See you\"),\n            ],\n        )\n    )\n\n    assert not result.is_error\n    assert \"successfully edited\" in result.message\n    assert await file_path.read_text() == \"Hi world! See you world!\"\n\n\nasync def test_replace_multiline_content(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing multi-line content.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"Line 1\\nLine 2\\nLine 3\\n\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(\n            path=str(file_path),\n            edit=Edit(old=\"Line 2\\nLine 3\", new=\"Modified line 2\\nModified line 3\"),\n        )\n    )\n\n    assert not result.is_error\n    assert \"successfully edited\" in result.message\n    assert await file_path.read_text() == \"Line 1\\nModified line 2\\nModified line 3\\n\"\n\n\nasync def test_replace_unicode_content(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing unicode content.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"Hello 世界! café\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(path=str(file_path), edit=Edit(old=\"世界\", new=\"地球\"))\n    )\n\n    assert not result.is_error\n    assert \"successfully edited\" in result.message\n    assert await file_path.read_text() == \"Hello 地球! café\"\n\n\nasync def test_replace_no_match(str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath):\n    \"\"\"Test replacing when the old string is not found.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"Hello world!\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(path=str(file_path), edit=Edit(old=\"notfound\", new=\"replacement\"))\n    )\n\n    assert result.is_error\n    assert \"No replacements were made\" in result.message\n    assert await file_path.read_text() == original_content  # Content unchanged\n\n\nasync def test_replace_with_relative_path(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing with a relative path inside the work directory.\"\"\"\n    relative_dir = temp_work_dir / \"relative\" / \"path\"\n    await relative_dir.mkdir(parents=True, exist_ok=True)\n    file_path = relative_dir / \"file.txt\"\n    await file_path.write_text(\"old content\")\n\n    result = await str_replace_file_tool(\n        Params(path=\"relative/path/file.txt\", edit=Edit(old=\"old\", new=\"new\"))\n    )\n\n    assert not result.is_error\n    assert await file_path.read_text() == \"new content\"\n\n\nasync def test_replace_outside_work_directory(\n    str_replace_file_tool: StrReplaceFile, outside_file: Path\n):\n    \"\"\"Test replacing outside the working directory with an absolute path.\"\"\"\n    outside_file.write_text(\"old content\", encoding=\"utf-8\")\n\n    result = await str_replace_file_tool(\n        Params(path=str(outside_file), edit=Edit(old=\"old\", new=\"new\"))\n    )\n\n    assert not result.is_error\n    assert outside_file.read_text(encoding=\"utf-8\") == \"new content\"\n\n\nasync def test_replace_outside_work_directory_with_prefix(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Paths sharing the work dir prefix but outside should still be editable\n    with absolute paths.\"\"\"\n    base = Path(str(temp_work_dir))\n    sneaky_dir = base.parent / f\"{base.name}-sneaky\"\n    sneaky_dir.mkdir(parents=True, exist_ok=True)\n    sneaky_file = sneaky_dir / \"test.txt\"\n    sneaky_file.write_text(\"content\", encoding=\"utf-8\")\n\n    result = await str_replace_file_tool(\n        Params(path=str(sneaky_file), edit=Edit(old=\"content\", new=\"new\"))\n    )\n\n    assert not result.is_error\n    assert sneaky_file.read_text() == \"new\"\n\n\nasync def test_replace_nonexistent_file(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing in a non-existent file.\"\"\"\n    file_path = temp_work_dir / \"nonexistent.txt\"\n\n    result = await str_replace_file_tool(\n        Params(path=str(file_path), edit=Edit(old=\"old\", new=\"new\"))\n    )\n\n    assert result.is_error\n    assert \"does not exist\" in result.message\n\n\nasync def test_replace_directory_instead_of_file(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing in a directory instead of a file.\"\"\"\n    dir_path = temp_work_dir / \"directory\"\n    await dir_path.mkdir()\n\n    result = await str_replace_file_tool(\n        Params(path=str(dir_path), edit=Edit(old=\"old\", new=\"new\"))\n    )\n\n    assert result.is_error\n    assert \"is not a file\" in result.message\n\n\nasync def test_replace_mixed_multiple_edits(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test multiple edits with different replace_all settings.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"apple apple banana apple cherry\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(\n            path=str(file_path),\n            edit=[\n                Edit(old=\"apple\", new=\"fruit\", replace_all=False),  # Only first occurrence\n                Edit(\n                    old=\"banana\", new=\"tasty\", replace_all=True\n                ),  # All occurrences (though only one)\n            ],\n        )\n    )\n\n    assert not result.is_error\n    assert \"successfully edited\" in result.message\n    assert await file_path.read_text() == \"fruit apple tasty apple cherry\"\n\n\nasync def test_replace_empty_strings(\n    str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath\n):\n    \"\"\"Test replacing with empty strings.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n    original_content = \"Hello world!\"\n    await file_path.write_text(original_content)\n\n    result = await str_replace_file_tool(\n        Params(path=str(file_path), edit=Edit(old=\"world\", new=\"\"))\n    )\n\n    assert not result.is_error\n    assert \"successfully edited\" in result.message\n    assert await file_path.read_text() == \"Hello !\"\n"
  },
  {
    "path": "tests/tools/test_tool_descriptions.py",
    "content": "from __future__ import annotations\n\n# ruff: noqa\n\nimport platform\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.tools.background import TaskList, TaskOutput, TaskStop\nfrom kimi_cli.tools.multiagent.create import CreateSubagent\nfrom kimi_cli.tools.shell import Shell\nfrom kimi_cli.tools.dmail import SendDMail\nfrom kimi_cli.tools.file.glob import Glob\nfrom kimi_cli.tools.file.grep_local import Grep\nfrom kimi_cli.tools.file.read import ReadFile\nfrom kimi_cli.tools.file.read_media import ReadMediaFile\nfrom kimi_cli.tools.file.replace import StrReplaceFile\nfrom kimi_cli.tools.file.write import WriteFile\nfrom kimi_cli.tools.multiagent.task import Task\nfrom kimi_cli.tools.think import Think\nfrom kimi_cli.tools.todo import SetTodoList\nfrom kimi_cli.tools.web.fetch import FetchURL\nfrom kimi_cli.tools.web.search import SearchWeb\n\n\ndef test_task_description(task_tool: Task):\n    \"\"\"Test the description of Task tool.\"\"\"\n    assert task_tool.base.description == snapshot(\n        \"\"\"\\\nSpawn a subagent to perform a specific task. Subagent will be spawned with a fresh context without any history of yours.\n\n**Context Isolation**\n\nContext isolation is one of the key benefits of using subagents. By delegating tasks to subagents, you can keep your main context clean and focused on the main goal requested by the user.\n\nHere are some scenarios you may want this tool for context isolation:\n\n- You wrote some code and it did not work as expected. In this case you can spawn a subagent to fix the code, asking the subagent to return how it is fixed. This can potentially benefit because the detailed process of fixing the code may not be relevant to your main goal, and may clutter your context.\n- When you need some latest knowledge of a specific library, framework or technology to proceed with your task, you can spawn a subagent to search on the internet for the needed information and return to you the gathered relevant information, for example code examples, API references, etc. This can avoid ton of irrelevant search results in your own context.\n\nDO NOT directly forward the user prompt to Task tool. DO NOT simply spawn Task tool for each todo item. This will cause the user confused because the user cannot see what the subagent do. Only you can see the response from the subagent. So, only spawn subagents for very specific and narrow tasks like fixing a compilation error, or searching for a specific solution.\n\n**Parallel Multi-Tasking**\n\nParallel multi-tasking is another key benefit of this tool. When the user request involves multiple subtasks that are independent of each other, you can use Task tool multiple times in a single response to let subagents work in parallel for you.\n\nExamples:\n\n- User requests to code, refactor or fix multiple modules/files in a project, and they can be tested independently. In this case you can spawn multiple subagents each working on a different module/file.\n- When you need to analyze a huge codebase (> hundreds of thousands of lines), you can spawn multiple subagents each exploring on a different part of the codebase and gather the summarized results.\n- When you need to search the web for multiple queries, you can spawn multiple subagents for better efficiency.\n\n**Available Subagents:**\n\n- `mocker`: The mock agent for testing purposes.\n\"\"\"\n    )\n\n\ndef test_create_subagent_description(create_subagent_tool: CreateSubagent):\n    \"\"\"Test the description of CreateSubagent tool.\"\"\"\n    assert create_subagent_tool.base.description == snapshot(\n        \"\"\"\\\nCreate a custom subagent with specific system prompt and name for reuse.\n\nUsage:\n- Define specialized agents with custom roles and boundaries\n- Created agents can be referenced by name in the Task tool\n- Use this when you need a specific agent type not covered by predefined agents\n- The created agent configuration will be saved and can be used immediately\n\nExample workflow:\n1. Use CreateSubagent to define a specialized agent (e.g., 'code_reviewer')\n2. Use the Task tool with agent='code_reviewer' to launch the created agent\n\"\"\"\n    )\n\n\ndef test_send_dmail_description(send_dmail_tool: SendDMail):\n    \"\"\"Test the description of SendDMail tool.\"\"\"\n    assert send_dmail_tool.base.description == snapshot(\n        \"\"\"\\\nSend a message to the past, just like sending a D-Mail in Steins;Gate.\n\nThis tool is provided to enable you to proactively manage the context. You can see some `user` messages with text `CHECKPOINT {checkpoint_id}` wrapped in `<system>` tags in the context. When you feel there is too much irrelevant information in the current context, you can send a D-Mail to revert the context to a previous checkpoint with a message containing only the useful information. When you send a D-Mail, you must specify an existing checkpoint ID from the before-mentioned messages.\n\nTypical scenarios you may want to send a D-Mail:\n\n- You read a file, found it very large and most of the content is not relevant to the current task. In this case you can send a D-Mail immediately to the checkpoint before you read the file and give your past self only the useful part.\n- You searched the web, the result is large.\n  - If you got what you need, you may send a D-Mail to the checkpoint before you searched the web and put only the useful result in the mail message.\n  - If you did not get what you need, you may send a D-Mail to tell your past self to try another query.\n- You wrote some code and it did not work as expected. You spent many struggling steps to fix it but the process is not relevant to the ultimate goal. In this case you can send a D-Mail to the checkpoint before you wrote the code and give your past self the fixed version of the code and tell yourself no need to write it again because you already wrote to the filesystem.\n\nAfter a D-Mail is sent, the system will revert the current context to the specified checkpoint, after which, you will no longer see any messages which you can now see after that checkpoint. The message in the D-Mail will be appended to the end of the context. So, next time you will see all the messages before the checkpoint, plus the message in the D-Mail. You must make it very clear in the message, tell your past self what you have done/changed, what you have learned and any other information that may be useful, so that your past self can continue the task without confusion and will not repeat the steps you have already done.\n\nYou must understand that, unlike D-Mail in Steins;Gate, the D-Mail you send here will not revert the filesystem or any external state. That means, you are basically folding the recent messages in your context into a single message, which can significantly reduce the waste of context window.\n\nWhen sending a D-Mail, DO NOT explain to the user. The user do not care about this. Just explain to your past self.\n\"\"\"\n    )\n\n\ndef test_think_description(think_tool: Think):\n    \"\"\"Test the description of Think tool.\"\"\"\n    assert think_tool.base.description == snapshot(\n        \"Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed.\\n\"\n    )\n\n\ndef test_set_todo_list_description(set_todo_list_tool: SetTodoList):\n    \"\"\"Test the description of SetTodoList tool.\"\"\"\n    assert set_todo_list_tool.base.description == snapshot(\n        \"\"\"\\\nUpdate the whole todo list.\n\nTodo list is a simple yet powerful tool to help you get things done. You typically want to use this tool when the given task involves multiple subtasks/milestones, or, multiple tasks are given in a single request. This tool can help you to break down the task and track the progress.\n\nThis is the only todo list tool available to you. That said, each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly.\n\nOnce you finished a subtask/milestone, remember to update the todo list to reflect the progress. Also, you can give yourself a self-encouragement to keep you motivated.\n\nAbusing this tool to track too small steps will just waste your time and make your context messy. For example, here are some cases you should not use this tool:\n\n- When the user just simply ask you a question. E.g. \"What language and framework is used in the project?\", \"What is the best practice for x?\"\n- When it only takes a few steps/tool calls to complete the task. E.g. \"Fix the unit test function 'test_xxx'\", \"Refactor the function 'xxx' to make it more solid.\"\n- When the user prompt is very specific and the only thing you need to do is brainlessly following the instructions. E.g. \"Replace xxx to yyy in the file zzz\", \"Create a file xxx with content yyy.\"\n\nHowever, do not get stuck in a rut. Be flexible. Sometimes, you may try to use todo list at first, then realize the task is too simple and you can simply stop using it; or, sometimes, you may realize the task is complex after a few steps and then you can start using todo list to break it down.\n\"\"\"\n    )\n\n\n@pytest.mark.skipif(platform.system() == \"Windows\", reason=\"Skipping test on Windows\")\ndef test_shell_description(shell_tool: Shell):\n    \"\"\"Test the description of Shell tool.\"\"\"\n    assert shell_tool.base.description == snapshot(\n        \"\"\"\\\nExecute a bash (`/bin/bash`) command. Use this tool to explore the filesystem, edit files, run scripts, get system information, etc.\n\n**Output:**\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the exit code will be provided in a system tag.\n\nIf `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. You will be automatically notified when the task completes. Use `TaskOutput` if you need progress or want to wait for completion, and use `TaskStop` only if the task must be cancelled. For human users in the interactive shell, background tasks are managed through `/task` only; do not suggest `/task list`, `/task output`, `/task stop`, `/tasks`, or any other invented shell subcommands.\n\n**Guidelines for safety and security:**\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls.\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, you shall set `timeout` argument to a reasonable value.\n- Avoid using `..` to access files or directories outside of the working directory.\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\n\n**Guidelines for efficiency:**\n- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la`\n- Use `;` to run commands sequentially regardless of success/failure\n- Use `||` for conditional execution (run second command only if first fails)\n- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands\n- Always quote file paths containing spaces with double quotes (e.g., cd \"/path with spaces/\")\n- Use `if`, `case`, `for`, `while` control flows to execute complex logic in a single call.\n- Verify directory structure before create/edit/delete files or directories to reduce the risk of failure.\n- Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes.\n- After starting a background task, do not guess its outcome. Rely on the automatic completion notification whenever possible. Use `TaskOutput` only when you need to inspect progress or block until completion.\n- If you need to tell a human shell user how to manage background tasks, only mention `/task`. Do not invent `/task list`, `/task output`, `/task stop`, or `/tasks`.\n\n**Commands available:**\n- Shell environment: cd, pwd, export, unset, env\n- File system operations: ls, find, mkdir, rm, cp, mv, touch, chmod, chown\n- File viewing/editing: cat, grep, head, tail, diff, patch\n- Text processing: awk, sed, sort, uniq, wc\n- System information/operations: ps, kill, top, df, free, uname, whoami, id, date\n- Network operations: curl, wget, ping, telnet, ssh\n- Archive operations: tar, zip, unzip\n- Other: Other commands available in the shell environment. Check the existence of a command by running `which <command>` before using it.\n\"\"\"\n    )\n\n\ndef test_task_output_description(task_output_tool: TaskOutput):\n    assert task_output_tool.base.description == snapshot(\n        \"\"\"\\\nRetrieve output from a running or completed background task.\n\nUse this after `Shell(run_in_background=true)` when you need to inspect progress or explicitly wait for completion.\n\nGuidelines:\n- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives.\n- Use `block=true` to wait for completion or timeout.\n- Use `block=false` for a non-blocking status and output check.\n- This tool returns structured task metadata, a fixed-size output preview, and an `output_path` for the full log.\n- When the preview is truncated, use `ReadFile` with the returned `output_path` to inspect the full log in pages.\n- This tool works with the generic background task system and should remain the primary read path for future task types, not just bash.\n\"\"\"\n    )\n\n\ndef test_task_list_description(task_list_tool: TaskList):\n    assert task_list_tool.base.description == snapshot(\n        \"\"\"\\\nList background tasks from the current session.\n\nUse this when you need to re-enumerate which background tasks still exist, especially after context compaction or when you are no longer confident which task IDs are still active.\n\nGuidelines:\n\n- Prefer the default `active_only=true` unless you specifically need completed or failed tasks.\n- Use `TaskOutput` to inspect one task in detail after you have identified the correct task ID.\n- Do not guess which tasks are still running when you can call this tool directly.\n- This tool is read-only and safe to use in plan mode.\n\"\"\"\n    )\n\n\ndef test_task_stop_description(task_stop_tool: TaskStop):\n    assert task_stop_tool.base.description == snapshot(\n        \"\"\"\\\nStop a running background task.\n\nUse this only when a background task must be cancelled. For normal task completion, prefer waiting for the automatic notification or using `TaskOutput`.\n\nGuidelines:\n- This is a generic task stop capability, not a bash-specific kill tool.\n- Use it sparingly because stopping a task is destructive and may leave partial side effects.\n- If the task is already complete, this tool will simply return its current state.\n\"\"\"\n    )\n\n\ndef test_read_file_description(read_file_tool: ReadFile):\n    \"\"\"Test the description of ReadFile tool.\"\"\"\n    assert read_file_tool.base.description == snapshot(\n        \"\"\"\\\nRead text content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read text files. To read images or videos, use other appropriate tools. To list directories, use the Glob tool or `ls` command via the Shell tool. To read other file types, use appropriate commands via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- If you want to search for a certain content/pattern, prefer Grep tool over ReadFile.\n- Content will be returned with a line number before each line like `cat -n` format.\n- Use `line_offset` and `n_lines` parameters when you only need to read a part of the file.\n- The maximum number of lines that can be read at once is 1000.\n- Any lines longer than 2000 characters will be truncated, ending with \"...\".\n\"\"\"\n    )\n\n\ndef test_read_media_file_description(read_media_file_tool: ReadMediaFile):\n    \"\"\"Test the description of ReadMediaFile tool.\"\"\"\n    assert read_media_file_tool.base.description == snapshot(\n        \"\"\"\\\nRead media content from a file.\n\n**Tips:**\n- Make sure you follow the description of each tool parameter.\n- A `<system>` tag will be given before the read file content.\n- The system will notify you when there is anything wrong when reading the file.\n- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible.\n- This tool can only read image or video files. To read other types of files, use the ReadFile tool. To list directories, use the Glob tool or `ls` command via the Shell tool.\n- If the file doesn't exist or path is invalid, an error will be returned.\n- The maximum size that can be read is 100MB. An error will be returned if the file is larger than this limit.\n- The media content will be returned in a form that you can directly view and understand.\n\n**Capabilities**\n- This tool supports image and video files for the current model.\n\"\"\"\n    )\n\n\ndef test_glob_description(glob_tool: Glob):\n    \"\"\"Test the description of Glob tool.\"\"\"\n    assert glob_tool.base.description == snapshot(\n        \"\"\"\\\nFind files and directories using glob patterns. This tool supports standard glob syntax like `*`, `?`, and `**` for recursive searches.\n\n**When to use:**\n- Find files matching specific patterns (e.g., all Python files: `*.py`)\n- Search for files recursively in subdirectories (e.g., `src/**/*.js`)\n- Locate configuration files (e.g., `*.config.*`, `*.json`)\n- Find test files (e.g., `test_*.py`, `*_test.go`)\n\n**Example patterns:**\n- `*.py` - All Python files in current directory\n- `src/**/*.js` - All JavaScript files in src directory recursively\n- `test_*.py` - Python test files starting with \"test_\"\n- `*.config.{js,ts}` - Config files with .js or .ts extension\n\n**Bad example patterns:**\n- `**`, `**/*.py` - Any pattern starting with '**' will be rejected. Because it would recursively search all directories and subdirectories, which is very likely to yield large result that exceeds your context size. Always use more specific patterns like `src/**/*.py` instead.\n- `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursively searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead.\n\"\"\"\n    )\n\n\ndef test_grep_description(grep_tool: Grep):\n    \"\"\"Test the description of Grep tool.\"\"\"\n    assert grep_tool.base.description == snapshot(\n        \"\"\"\\\nA powerful search tool based-on ripgrep.\n\n**Tips:**\n- ALWAYS use Grep tool instead of running `grep` or `rg` command with Shell tool.\n- Use the ripgrep pattern syntax, not grep syntax. E.g. you need to escape braces like `\\\\\\\\{` to search for `{`.\n\"\"\"\n    )\n\n\ndef test_write_file_description(write_file_tool: WriteFile):\n    \"\"\"Test the description of WriteFile tool.\"\"\"\n    assert write_file_tool.base.description == snapshot(\n        \"\"\"\\\nWrite content to a file.\n\n**Tips:**\n- When `mode` is not specified, it defaults to `overwrite`. Always write with caution.\n- When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use `overwrite` mode at the first time, then use `append` mode after the first write.\n\"\"\"\n    )\n\n\ndef test_str_replace_file_description(str_replace_file_tool: StrReplaceFile):\n    \"\"\"Test the description of StrReplaceFile tool.\"\"\"\n    assert str_replace_file_tool.base.description == snapshot(\n        \"\"\"\\\nReplace specific strings within a specified file.\n\n**Tips:**\n- Only use this tool on text files.\n- Multi-line strings are supported.\n- Can specify a single edit or a list of edits in one call.\n- You should prefer this tool over WriteFile tool and Shell `sed` command.\n\"\"\"\n    )\n\n\ndef test_search_web_description(search_web_tool: SearchWeb):\n    \"\"\"Test the description of MoonshotSearch tool.\"\"\"\n    assert search_web_tool.base.description == snapshot(\n        \"WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc.\\n\"\n    )\n\n\ndef test_fetch_url_description(fetch_url_tool: FetchURL):\n    \"\"\"Test the description of FetchURL tool.\"\"\"\n    assert fetch_url_tool.base.description == snapshot(\n        \"Fetch a web page from a URL and extract main text content from it.\\n\"\n    )\n"
  },
  {
    "path": "tests/tools/test_tool_schemas.py",
    "content": "from __future__ import annotations\n\n# ruff: noqa\n\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.tools.background import TaskList, TaskOutput, TaskStop\nfrom kimi_cli.tools.multiagent.create import CreateSubagent\nfrom kimi_cli.tools.shell import Shell\nfrom kimi_cli.tools.dmail import SendDMail\nfrom kimi_cli.tools.file.glob import Glob\nfrom kimi_cli.tools.file.grep_local import Grep\nfrom kimi_cli.tools.file.read import ReadFile\nfrom kimi_cli.tools.file.read_media import ReadMediaFile\nfrom kimi_cli.tools.file.replace import StrReplaceFile\nfrom kimi_cli.tools.file.write import WriteFile\nfrom kimi_cli.tools.multiagent.task import Task\nfrom kimi_cli.tools.think import Think\nfrom kimi_cli.tools.todo import SetTodoList\nfrom kimi_cli.tools.web.fetch import FetchURL\nfrom kimi_cli.tools.web.search import SearchWeb\n\n\ndef test_task_params_schema(task_tool: Task):\n    \"\"\"Test the schema of Task tool parameters.\"\"\"\n    assert task_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"description\": {\n                    \"description\": \"A short (3-5 word) description of the task\",\n                    \"type\": \"string\",\n                },\n                \"subagent_name\": {\n                    \"description\": \"The name of the specialized subagent to use for this task\",\n                    \"type\": \"string\",\n                },\n                \"prompt\": {\n                    \"description\": \"The task for the subagent to perform. You must provide a detailed prompt with all necessary background information because the subagent cannot see anything in your context.\",\n                    \"type\": \"string\",\n                },\n            },\n            \"required\": [\"description\", \"subagent_name\", \"prompt\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_create_subagent_params_schema(create_subagent_tool: CreateSubagent):\n    \"\"\"Test the schema of CreateSubagent tool parameters.\"\"\"\n    assert create_subagent_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"name\": {\n                    \"description\": \"Unique name for this agent configuration (e.g., 'summarizer', 'code_reviewer'). This name will be used to reference the agent in the Task tool.\",\n                    \"type\": \"string\",\n                },\n                \"system_prompt\": {\n                    \"description\": \"System prompt defining the agent's role, capabilities, and boundaries.\",\n                    \"type\": \"string\",\n                },\n            },\n            \"required\": [\"name\", \"system_prompt\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_send_dmail_params_schema(send_dmail_tool: SendDMail):\n    \"\"\"Test the schema of SendDMail tool parameters.\"\"\"\n    assert send_dmail_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"message\": {\"description\": \"The message to send.\", \"type\": \"string\"},\n                \"checkpoint_id\": {\n                    \"description\": \"The checkpoint to send the message back to.\",\n                    \"minimum\": 0,\n                    \"type\": \"integer\",\n                },\n            },\n            \"required\": [\"message\", \"checkpoint_id\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_think_params_schema(think_tool: Think):\n    \"\"\"Test the schema of Think tool parameters.\"\"\"\n    assert think_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"thought\": {\n                    \"description\": \"A thought to think about.\",\n                    \"type\": \"string\",\n                }\n            },\n            \"required\": [\"thought\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_set_todo_list_params_schema(set_todo_list_tool: SetTodoList):\n    \"\"\"Test the schema of SetTodoList tool parameters.\"\"\"\n    assert set_todo_list_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"todos\": {\n                    \"description\": \"The updated todo list\",\n                    \"items\": {\n                        \"properties\": {\n                            \"title\": {\n                                \"description\": \"The title of the todo\",\n                                \"minLength\": 1,\n                                \"type\": \"string\",\n                            },\n                            \"status\": {\n                                \"description\": \"The status of the todo\",\n                                \"enum\": [\"pending\", \"in_progress\", \"done\"],\n                                \"type\": \"string\",\n                            },\n                        },\n                        \"required\": [\"title\", \"status\"],\n                        \"type\": \"object\",\n                    },\n                    \"type\": \"array\",\n                }\n            },\n            \"required\": [\"todos\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_shell_params_schema(shell_tool: Shell):\n    \"\"\"Test the schema of Shell tool parameters.\"\"\"\n    assert shell_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"command\": {\n                    \"description\": \"The bash command to execute.\",\n                    \"type\": \"string\",\n                },\n                \"timeout\": {\n                    \"default\": 60,\n                    \"description\": \"The timeout in seconds for the command to execute. If the command takes longer than this, it will be killed.\",\n                    \"maximum\": 86400,\n                    \"minimum\": 1,\n                    \"type\": \"integer\",\n                },\n                \"run_in_background\": {\n                    \"default\": False,\n                    \"description\": \"Whether to run the command as a background task.\",\n                    \"type\": \"boolean\",\n                },\n                \"description\": {\n                    \"default\": \"\",\n                    \"description\": \"A short description for the background task. Required when run_in_background=true.\",\n                    \"type\": \"string\",\n                },\n            },\n            \"required\": [\"command\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_task_output_params_schema(task_output_tool: TaskOutput):\n    assert task_output_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"task_id\": {\n                    \"description\": \"The background task ID to inspect.\",\n                    \"type\": \"string\",\n                },\n                \"block\": {\n                    \"default\": True,\n                    \"description\": \"Whether to wait for the task to finish before returning.\",\n                    \"type\": \"boolean\",\n                },\n                \"timeout\": {\n                    \"default\": 30,\n                    \"description\": \"Maximum number of seconds to wait when block=true.\",\n                    \"maximum\": 3600,\n                    \"minimum\": 0,\n                    \"type\": \"integer\",\n                },\n            },\n            \"required\": [\"task_id\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_task_list_params_schema(task_list_tool: TaskList):\n    assert task_list_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"active_only\": {\n                    \"default\": True,\n                    \"description\": \"Whether to list only non-terminal background tasks.\",\n                    \"type\": \"boolean\",\n                },\n                \"limit\": {\n                    \"default\": 20,\n                    \"description\": \"Maximum number of tasks to return.\",\n                    \"maximum\": 100,\n                    \"minimum\": 1,\n                    \"type\": \"integer\",\n                },\n            },\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_task_stop_params_schema(task_stop_tool: TaskStop):\n    assert task_stop_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"task_id\": {\n                    \"description\": \"The background task ID to stop.\",\n                    \"type\": \"string\",\n                },\n                \"reason\": {\n                    \"default\": \"Stopped by TaskStop\",\n                    \"description\": \"Short reason recorded when the task is stopped.\",\n                    \"type\": \"string\",\n                },\n            },\n            \"required\": [\"task_id\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_read_file_params_schema(read_file_tool: ReadFile):\n    \"\"\"Test the schema of ReadFile tool parameters.\"\"\"\n    assert read_file_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"path\": {\n                    \"description\": \"The path to the file to read. Absolute paths are required when reading files outside the working directory.\",\n                    \"type\": \"string\",\n                },\n                \"line_offset\": {\n                    \"default\": 1,\n                    \"description\": \"The line number to start reading from. By default read from the beginning of the file. Set this when the file is too large to read at once.\",\n                    \"minimum\": 1,\n                    \"type\": \"integer\",\n                },\n                \"n_lines\": {\n                    \"default\": 1000,\n                    \"description\": \"The number of lines to read. By default read up to 1000 lines, which is the max allowed value. Set this value when the file is too large to read at once.\",\n                    \"minimum\": 1,\n                    \"type\": \"integer\",\n                },\n            },\n            \"required\": [\"path\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_read_media_file_params_schema(read_media_file_tool: ReadMediaFile):\n    \"\"\"Test the schema of ReadMediaFile tool parameters.\"\"\"\n    assert read_media_file_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"path\": {\n                    \"description\": \"The path to the file to read. Absolute paths are required when reading files outside the working directory.\",\n                    \"type\": \"string\",\n                }\n            },\n            \"required\": [\"path\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_glob_params_schema(glob_tool: Glob):\n    \"\"\"Test the schema of Glob tool parameters.\"\"\"\n    assert glob_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"pattern\": {\n                    \"description\": \"Glob pattern to match files/directories.\",\n                    \"type\": \"string\",\n                },\n                \"directory\": {\n                    \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n                    \"default\": None,\n                    \"description\": \"Absolute path to the directory to search in (defaults to working directory).\",\n                },\n                \"include_dirs\": {\n                    \"default\": True,\n                    \"description\": \"Whether to include directories in results.\",\n                    \"type\": \"boolean\",\n                },\n            },\n            \"required\": [\"pattern\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_grep_params_schema(grep_tool: Grep):\n    \"\"\"Test the schema of Grep tool parameters.\"\"\"\n    assert grep_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"pattern\": {\n                    \"description\": \"The regular expression pattern to search for in file contents\",\n                    \"type\": \"string\",\n                },\n                \"path\": {\n                    \"default\": \".\",\n                    \"description\": \"File or directory to search in. Defaults to current working directory. If specified, it must be an absolute path.\",\n                    \"type\": \"string\",\n                },\n                \"glob\": {\n                    \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n                    \"default\": None,\n                    \"description\": \"Glob pattern to filter files (e.g. `*.js`, `*.{ts,tsx}`). No filter by default.\",\n                },\n                \"output_mode\": {\n                    \"default\": \"files_with_matches\",\n                    \"description\": \"`content`: Show matching lines (supports `-B`, `-A`, `-C`, `-n`, `head_limit`); `files_with_matches`: Show file paths (supports `head_limit`); `count_matches`: Show total number of matches. Defaults to `files_with_matches`.\",\n                    \"type\": \"string\",\n                },\n                \"-B\": {\n                    \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                    \"default\": None,\n                    \"description\": \"Number of lines to show before each match (the `-B` option). Requires `output_mode` to be `content`.\",\n                },\n                \"-A\": {\n                    \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                    \"default\": None,\n                    \"description\": \"Number of lines to show after each match (the `-A` option). Requires `output_mode` to be `content`.\",\n                },\n                \"-C\": {\n                    \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                    \"default\": None,\n                    \"description\": \"Number of lines to show before and after each match (the `-C` option). Requires `output_mode` to be `content`.\",\n                },\n                \"-n\": {\n                    \"default\": False,\n                    \"description\": \"Show line numbers in output (the `-n` option). Requires `output_mode` to be `content`.\",\n                    \"type\": \"boolean\",\n                },\n                \"-i\": {\n                    \"default\": False,\n                    \"description\": \"Case insensitive search (the `-i` option).\",\n                    \"type\": \"boolean\",\n                },\n                \"type\": {\n                    \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n                    \"default\": None,\n                    \"description\": \"File type to search. Examples: py, rust, js, ts, go, java, etc. More efficient than `glob` for standard file types.\",\n                },\n                \"head_limit\": {\n                    \"anyOf\": [{\"type\": \"integer\"}, {\"type\": \"null\"}],\n                    \"default\": None,\n                    \"description\": \"Limit output to first N lines, equivalent to `| head -N`. Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count_matches (limits count entries). By default, no limit is applied.\",\n                },\n                \"multiline\": {\n                    \"default\": False,\n                    \"description\": \"Enable multiline mode where `.` matches newlines and patterns can span lines (the `-U` and `--multiline-dotall` options). By default, multiline mode is disabled.\",\n                    \"type\": \"boolean\",\n                },\n            },\n            \"required\": [\"pattern\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_write_file_params_schema(write_file_tool: WriteFile):\n    \"\"\"Test the schema of WriteFile tool parameters.\"\"\"\n    assert write_file_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"path\": {\n                    \"description\": \"The path to the file to write. Absolute paths are required when writing files outside the working directory.\",\n                    \"type\": \"string\",\n                },\n                \"content\": {\n                    \"description\": \"The content to write to the file\",\n                    \"type\": \"string\",\n                },\n                \"mode\": {\n                    \"default\": \"overwrite\",\n                    \"description\": \"The mode to use to write to the file. Two modes are supported: `overwrite` for overwriting the whole file and `append` for appending to the end of an existing file.\",\n                    \"enum\": [\"overwrite\", \"append\"],\n                    \"type\": \"string\",\n                },\n            },\n            \"required\": [\"path\", \"content\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_str_replace_file_params_schema(str_replace_file_tool: StrReplaceFile):\n    \"\"\"Test the schema of StrReplaceFile tool parameters.\"\"\"\n    assert str_replace_file_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"path\": {\n                    \"description\": \"The path to the file to edit. Absolute paths are required when editing files outside the working directory.\",\n                    \"type\": \"string\",\n                },\n                \"edit\": {\n                    \"anyOf\": [\n                        {\n                            \"properties\": {\n                                \"old\": {\n                                    \"description\": \"The old string to replace. Can be multi-line.\",\n                                    \"type\": \"string\",\n                                },\n                                \"new\": {\n                                    \"description\": \"The new string to replace with. Can be multi-line.\",\n                                    \"type\": \"string\",\n                                },\n                                \"replace_all\": {\n                                    \"default\": False,\n                                    \"description\": \"Whether to replace all occurrences.\",\n                                    \"type\": \"boolean\",\n                                },\n                            },\n                            \"required\": [\"old\", \"new\"],\n                            \"type\": \"object\",\n                        },\n                        {\n                            \"items\": {\n                                \"properties\": {\n                                    \"old\": {\n                                        \"description\": \"The old string to replace. Can be multi-line.\",\n                                        \"type\": \"string\",\n                                    },\n                                    \"new\": {\n                                        \"description\": \"The new string to replace with. Can be multi-line.\",\n                                        \"type\": \"string\",\n                                    },\n                                    \"replace_all\": {\n                                        \"default\": False,\n                                        \"description\": \"Whether to replace all occurrences.\",\n                                        \"type\": \"boolean\",\n                                    },\n                                },\n                                \"required\": [\"old\", \"new\"],\n                                \"type\": \"object\",\n                            },\n                            \"type\": \"array\",\n                        },\n                    ],\n                    \"description\": \"The edit(s) to apply to the file. You can provide a single edit or a list of edits here.\",\n                },\n            },\n            \"required\": [\"path\", \"edit\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_search_web_params_schema(search_web_tool: SearchWeb):\n    \"\"\"Test the schema of MoonshotSearch tool parameters.\"\"\"\n    assert search_web_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"query\": {\n                    \"description\": \"The query text to search for.\",\n                    \"type\": \"string\",\n                },\n                \"limit\": {\n                    \"default\": 5,\n                    \"description\": \"The number of results to return. Typically you do not need to set this value. When the results do not contain what you need, you probably want to give a more concrete query.\",\n                    \"maximum\": 20,\n                    \"minimum\": 1,\n                    \"type\": \"integer\",\n                },\n                \"include_content\": {\n                    \"default\": False,\n                    \"description\": \"Whether to include the content of the web pages in the results. It can consume a large amount of tokens when this is set to True. You should avoid enabling this when `limit` is set to a large value.\",\n                    \"type\": \"boolean\",\n                },\n            },\n            \"required\": [\"query\"],\n            \"type\": \"object\",\n        }\n    )\n\n\ndef test_fetch_url_params_schema(fetch_url_tool: FetchURL):\n    \"\"\"Test the schema of FetchURL tool parameters.\"\"\"\n    assert fetch_url_tool.base.parameters == snapshot(\n        {\n            \"properties\": {\n                \"url\": {\n                    \"description\": \"The URL to fetch content from.\",\n                    \"type\": \"string\",\n                }\n            },\n            \"required\": [\"url\"],\n            \"type\": \"object\",\n        }\n    )\n"
  },
  {
    "path": "tests/tools/test_write_file.py",
    "content": "\"\"\"Tests for the write_file tool.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\nfrom kaos.path import KaosPath\nfrom pydantic import ValidationError\n\nfrom kimi_cli.tools.file.write import Params, WriteFile\nfrom kimi_cli.wire.types import DiffDisplayBlock\n\n\nasync def test_write_new_file(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing a new file.\"\"\"\n    file_path = temp_work_dir / \"new_file.txt\"\n    content = \"Hello, World!\"\n\n    result = await write_file_tool(Params(path=str(file_path), content=content))\n\n    assert not result.is_error\n    assert \"successfully overwritten\" in result.message\n    diff_block = next(block for block in result.display if block.type == \"diff\")\n    assert isinstance(diff_block, DiffDisplayBlock)\n    assert diff_block.path == str(file_path)\n    assert diff_block.old_text == \"\"\n    assert diff_block.new_text == content\n    assert await file_path.exists()\n    assert await file_path.read_text() == content\n\n\nasync def test_overwrite_existing_file(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test overwriting an existing file.\"\"\"\n    file_path = temp_work_dir / \"existing.txt\"\n    original_content = \"Original content\"\n    await file_path.write_text(original_content)\n\n    new_content = \"New content\"\n    result = await write_file_tool(Params(path=str(file_path), content=new_content))\n\n    assert not result.is_error\n    assert \"successfully overwritten\" in result.message\n    assert await file_path.read_text() == new_content\n\n\nasync def test_append_to_file(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test appending to an existing file.\"\"\"\n    file_path = temp_work_dir / \"append_test.txt\"\n    original_content = \"First line\\n\"\n    await file_path.write_text(original_content)\n\n    append_content = \"Second line\\n\"\n    result = await write_file_tool(\n        Params(path=str(file_path), content=append_content, mode=\"append\")\n    )\n\n    assert not result.is_error\n    assert \"successfully appended to\" in result.message\n    expected_content = original_content + append_content\n    assert await file_path.read_text() == expected_content\n\n\nasync def test_write_unicode_content(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing unicode content.\"\"\"\n    file_path = temp_work_dir / \"unicode.txt\"\n    content = \"Hello 世界 🌍\\nUnicode: café, naïve, résumé\"\n\n    result = await write_file_tool(Params(path=str(file_path), content=content))\n\n    assert not result.is_error\n    assert await file_path.exists()\n    assert await file_path.read_text(encoding=\"utf-8\") == content\n\n\nasync def test_write_empty_content(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing empty content.\"\"\"\n    file_path = temp_work_dir / \"empty.txt\"\n    content = \"\"\n\n    result = await write_file_tool(Params(path=str(file_path), content=content))\n\n    assert not result.is_error\n    assert await file_path.exists()\n    assert await file_path.read_text() == content\n\n\nasync def test_write_multiline_content(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing multiline content.\"\"\"\n    file_path = temp_work_dir / \"multiline.txt\"\n    content = \"Line 1\\nLine 2\\nLine 3\\n\"\n\n    result = await write_file_tool(Params(path=str(file_path), content=content))\n\n    assert not result.is_error\n    assert await file_path.read_text() == content\n\n\nasync def test_write_with_relative_path(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing with a relative path inside the work directory.\"\"\"\n    relative_dir = temp_work_dir / \"relative\" / \"path\"\n    await relative_dir.mkdir(parents=True, exist_ok=True)\n\n    result = await write_file_tool(Params(path=\"relative/path/file.txt\", content=\"content\"))\n\n    assert not result.is_error\n    assert await (temp_work_dir / \"relative\" / \"path\" / \"file.txt\").read_text() == \"content\"\n\n\nasync def test_write_outside_work_directory(write_file_tool: WriteFile, outside_file: Path):\n    \"\"\"Test writing outside the working directory with an absolute path.\"\"\"\n    result = await write_file_tool(Params(path=str(outside_file), content=\"content\"))\n\n    assert not result.is_error\n    assert outside_file.read_text() == \"content\"\n\n\nasync def test_write_outside_work_directory_with_prefix(\n    write_file_tool: WriteFile, temp_work_dir: KaosPath\n):\n    \"\"\"Paths sharing the same prefix as work dir should still be writable with absolute paths.\"\"\"\n    base = Path(str(temp_work_dir))\n    sneaky_dir = base.parent / f\"{base.name}-sneaky\"\n    sneaky_dir.mkdir(parents=True, exist_ok=True)\n    sneaky_file = sneaky_dir / \"file.txt\"\n\n    result = await write_file_tool(Params(path=str(sneaky_file), content=\"content\"))\n\n    assert not result.is_error\n    assert sneaky_file.read_text() == \"content\"\n\n\nasync def test_write_to_nonexistent_directory(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing to a non-existent directory.\"\"\"\n    file_path = temp_work_dir / \"nonexistent\" / \"file.txt\"\n\n    result = await write_file_tool(Params(path=str(file_path), content=\"content\"))\n\n    assert result.is_error\n    assert \"parent directory does not exist\" in result.message\n\n\nasync def test_write_with_invalid_mode(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing with an invalid mode.\"\"\"\n    file_path = temp_work_dir / \"test.txt\"\n\n    with pytest.raises(ValidationError):\n        await write_file_tool(Params(path=str(file_path), content=\"content\", mode=\"invalid\"))  # type: ignore[reportArgumentType]\n\n\nasync def test_append_to_nonexistent_file(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test appending to a non-existent file (should create it).\"\"\"\n    file_path = temp_work_dir / \"new_append.txt\"\n    content = \"New content\\n\"\n\n    result = await write_file_tool(Params(path=str(file_path), content=content, mode=\"append\"))\n\n    assert not result.is_error\n    assert \"successfully appended to\" in result.message\n    assert await file_path.exists()\n    assert await file_path.read_text() == content\n\n\nasync def test_write_large_content(write_file_tool: WriteFile, temp_work_dir: KaosPath):\n    \"\"\"Test writing large content.\"\"\"\n    file_path = temp_work_dir / \"large.txt\"\n    content = \"Large content line\\n\" * 1000\n\n    result = await write_file_tool(Params(path=str(file_path), content=content))\n\n    assert not result.is_error\n    assert await file_path.exists()\n    assert await file_path.read_text() == content\n"
  },
  {
    "path": "tests/ui_and_conv/test_acp_convert.py",
    "content": "import acp\n\nfrom kimi_cli.acp.convert import acp_blocks_to_content_parts, tool_result_to_acp_content\nfrom kimi_cli.wire.types import DiffDisplayBlock, TextPart, ToolReturnValue\n\n\ndef test_tool_result_to_acp_content_handles_diff_display():\n    tool_ret = ToolReturnValue(\n        is_error=False,\n        output=\"\",\n        message=\"\",\n        display=[DiffDisplayBlock(path=\"foo.txt\", old_text=\"before\", new_text=\"after\")],\n    )\n\n    contents = tool_result_to_acp_content(tool_ret)\n\n    assert len(contents) == 1\n    content = contents[0]\n    assert isinstance(content, acp.schema.FileEditToolCallContent)\n    assert content.type == \"diff\"\n    assert content.path == \"foo.txt\"\n    assert content.old_text == \"before\"\n    assert content.new_text == \"after\"\n\n\ndef test_acp_blocks_to_content_parts_handles_embedded_text_resource():\n    block = acp.schema.EmbeddedResourceContentBlock(\n        type=\"resource\",\n        resource=acp.schema.TextResourceContents(\n            uri=\"file:///path/to/foo.py\",\n            text=\"print('hello')\",\n        ),\n    )\n    parts = acp_blocks_to_content_parts([block])\n    assert len(parts) == 1\n    assert isinstance(parts[0], TextPart)\n    assert \"file:///path/to/foo.py\" in parts[0].text\n    assert \"print('hello')\" in parts[0].text\n\n\ndef test_acp_blocks_to_content_parts_handles_resource_link():\n    block = acp.schema.ResourceContentBlock(\n        type=\"resource_link\",\n        uri=\"file:///path/to/bar.py\",\n        name=\"bar.py\",\n    )\n    parts = acp_blocks_to_content_parts([block])\n    assert len(parts) == 1\n    assert isinstance(parts[0], TextPart)\n    assert \"file:///path/to/bar.py\" in parts[0].text\n    assert \"bar.py\" in parts[0].text\n\n\ndef test_acp_blocks_to_content_parts_skips_blob_resource():\n    block = acp.schema.EmbeddedResourceContentBlock(\n        type=\"resource\",\n        resource=acp.schema.BlobResourceContents(\n            uri=\"file:///path/to/image.png\",\n            blob=\"iVBORw0KGgo=\",\n        ),\n    )\n    parts = acp_blocks_to_content_parts([block])\n    assert len(parts) == 0\n\n\ndef test_acp_blocks_to_content_parts_mixed_blocks():\n    blocks = [\n        acp.schema.TextContentBlock(type=\"text\", text=\"Check this file:\"),\n        acp.schema.EmbeddedResourceContentBlock(\n            type=\"resource\",\n            resource=acp.schema.TextResourceContents(\n                uri=\"file:///src/main.py\",\n                text=\"def main(): pass\",\n            ),\n        ),\n    ]\n    parts = acp_blocks_to_content_parts(blocks)\n    assert len(parts) == 2\n    assert isinstance(parts[0], TextPart)\n    assert parts[0].text == \"Check this file:\"\n    assert isinstance(parts[1], TextPart)\n    assert \"def main(): pass\" in parts[1].text\n"
  },
  {
    "path": "tests/ui_and_conv/test_acp_server_auth.py",
    "content": "from unittest.mock import MagicMock, patch\n\nimport acp\nimport pytest\n\nfrom kimi_cli.acp.server import ACPServer\n\n\n@pytest.fixture\ndef server() -> ACPServer:\n    \"\"\"Create an ACPServer instance with mocked auth methods.\"\"\"\n    s = ACPServer()\n    s._auth_methods = [\n        acp.schema.AuthMethod(\n            id=\"login\",\n            name=\"Test Login\",\n            description=\"Test description\",\n            field_meta={\n                \"terminal-auth\": {\n                    \"type\": \"terminal\",\n                    \"args\": [\"kimi\", \"login\"],\n                    \"env\": {},\n                }\n            },\n        )\n    ]\n    return s\n\n\ndef test_check_auth_raises_when_no_token(server: ACPServer) -> None:\n    \"\"\"Test that _check_auth raises AUTH_REQUIRED when no token exists.\"\"\"\n    with patch(\"kimi_cli.acp.server.load_tokens\", return_value=None):\n        with pytest.raises(acp.RequestError) as exc_info:\n            server._check_auth()\n\n        assert exc_info.value.code == -32000  # AUTH_REQUIRED error code\n\n\ndef test_check_auth_raises_when_token_has_no_access_token(server: ACPServer) -> None:\n    \"\"\"Test that _check_auth raises AUTH_REQUIRED when token has no access_token.\"\"\"\n    mock_token = MagicMock()\n    mock_token.access_token = None\n\n    with patch(\"kimi_cli.acp.server.load_tokens\", return_value=mock_token):\n        with pytest.raises(acp.RequestError) as exc_info:\n            server._check_auth()\n\n        assert exc_info.value.code == -32000\n\n\ndef test_check_auth_passes_when_valid_token(server: ACPServer) -> None:\n    \"\"\"Test that _check_auth passes when a valid token exists.\"\"\"\n    mock_token = MagicMock()\n    mock_token.access_token = \"valid_token_123\"\n\n    with patch(\"kimi_cli.acp.server.load_tokens\", return_value=mock_token):\n        # Should not raise\n        server._check_auth()\n"
  },
  {
    "path": "tests/ui_and_conv/test_export_import.py",
    "content": "\"\"\"Tests for /export and /import slash commands.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom kosong.message import Message\n\nfrom kimi_cli.soul.message import system, system_reminder\nfrom kimi_cli.utils.export import (\n    _IMPORTABLE_EXTENSIONS,\n    _extract_tool_call_hint,\n    _format_content_part_md,\n    _format_tool_call_md,\n    _format_tool_result_md,\n    _group_into_turns,\n    _is_checkpoint_message,\n    _stringify_content_parts,\n    _stringify_tool_calls,\n    build_export_markdown,\n    build_import_message,\n    is_importable_file,\n    perform_export,\n    perform_import,\n    resolve_import_source,\n    stringify_context_history,\n)\nfrom kimi_cli.wire.types import (\n    AudioURLPart,\n    ContentPart,\n    ImageURLPart,\n    TextPart,\n    ThinkPart,\n    ToolCall,\n    VideoURLPart,\n)\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n\ndef _make_tool_call(\n    call_id: str = \"call_001\",\n    name: str = \"bash\",\n    arguments: str | None = '{\"command\": \"ls\"}',\n) -> ToolCall:\n    return ToolCall(\n        id=call_id,\n        function=ToolCall.FunctionBody(name=name, arguments=arguments),\n    )\n\n\ndef _make_checkpoint_message(checkpoint_id: int = 0) -> Message:\n    return Message(\n        role=\"user\",\n        content=[system(f\"CHECKPOINT {checkpoint_id}\")],\n    )\n\n\ndef _make_system_reminder_message(text: str = \"Stay focused.\") -> Message:\n    return Message(role=\"user\", content=[system_reminder(text)])\n\n\n# ---------------------------------------------------------------------------\n# _stringify_content_parts\n# ---------------------------------------------------------------------------\n\n\nclass TestStringifyContentParts:\n    def test_text_part(self) -> None:\n        parts: list[ContentPart] = [TextPart(text=\"Hello world\")]\n        result = _stringify_content_parts(parts)\n        assert result == \"Hello world\"\n\n    def test_think_part_preserved(self) -> None:\n        parts: list[ContentPart] = [ThinkPart(think=\"Let me analyze this...\")]\n        result = _stringify_content_parts(parts)\n        assert \"<thinking>\" in result\n        assert \"Let me analyze this...\" in result\n        assert \"</thinking>\" in result\n\n    def test_mixed_content(self) -> None:\n        parts: list[ContentPart] = [\n            ThinkPart(think=\"Thinking first\"),\n            TextPart(text=\"Then responding\"),\n        ]\n        result = _stringify_content_parts(parts)\n        assert \"Thinking first\" in result\n        assert \"Then responding\" in result\n\n    def test_image_placeholder(self) -> None:\n        parts: list[ContentPart] = [\n            ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/img.png\")),\n        ]\n        result = _stringify_content_parts(parts)\n        assert result == \"[image]\"\n\n    def test_audio_placeholder(self) -> None:\n        parts: list[ContentPart] = [\n            AudioURLPart(audio_url=AudioURLPart.AudioURL(url=\"https://example.com/audio.mp3\")),\n        ]\n        result = _stringify_content_parts(parts)\n        assert result == \"[audio]\"\n\n    def test_video_placeholder(self) -> None:\n        parts: list[ContentPart] = [\n            VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video.mp4\")),\n        ]\n        result = _stringify_content_parts(parts)\n        assert result == \"[video]\"\n\n    def test_empty_text_skipped(self) -> None:\n        parts: list[ContentPart] = [TextPart(text=\"   \"), TextPart(text=\"Real content\")]\n        result = _stringify_content_parts(parts)\n        assert result == \"Real content\"\n\n    def test_empty_think_skipped(self) -> None:\n        parts: list[ContentPart] = [ThinkPart(think=\"  \"), TextPart(text=\"Response\")]\n        result = _stringify_content_parts(parts)\n        assert result == \"Response\"\n        assert \"<thinking>\" not in result\n\n\n# ---------------------------------------------------------------------------\n# _stringify_tool_calls\n# ---------------------------------------------------------------------------\n\n\nclass TestStringifyToolCalls:\n    def test_single_tool_call(self) -> None:\n        tc = _make_tool_call(name=\"bash\", arguments='{\"command\": \"ls -la\"}')\n        result = _stringify_tool_calls([tc])\n        assert \"Tool Call: bash(\" in result\n        assert \"ls -la\" in result\n\n    def test_multiple_tool_calls(self) -> None:\n        tc1 = _make_tool_call(call_id=\"c1\", name=\"ReadFile\", arguments='{\"path\": \"a.py\"}')\n        tc2 = _make_tool_call(call_id=\"c2\", name=\"WriteFile\", arguments='{\"path\": \"b.py\"}')\n        result = _stringify_tool_calls([tc1, tc2])\n        assert \"Tool Call: ReadFile(\" in result\n        assert \"Tool Call: WriteFile(\" in result\n        assert \"a.py\" in result\n        assert \"b.py\" in result\n\n    def test_invalid_json_arguments(self) -> None:\n        tc = _make_tool_call(name=\"test\", arguments=\"not valid json\")\n        result = _stringify_tool_calls([tc])\n        assert \"Tool Call: test(not valid json)\" in result\n\n    def test_none_arguments(self) -> None:\n        tc = _make_tool_call(name=\"test\", arguments=None)\n        result = _stringify_tool_calls([tc])\n        assert \"Tool Call: test({})\" in result\n\n\n# ---------------------------------------------------------------------------\n# stringify_context_history\n# ---------------------------------------------------------------------------\n\n\nclass TestStringifyContextHistory:\n    def test_simple_user_assistant(self) -> None:\n        history: list[Message] = [\n            Message(role=\"user\", content=[TextPart(text=\"What is 1+1?\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"2\")]),\n        ]\n        result = stringify_context_history(history)\n        assert \"[USER]\" in result\n        assert \"What is 1+1?\" in result\n        assert \"[ASSISTANT]\" in result\n        assert \"2\" in result\n\n    def test_think_part_preserved_in_history(self) -> None:\n        \"\"\"ThinkPart content must appear in the serialized output.\"\"\"\n        history: list[Message] = [\n            Message(role=\"user\", content=[TextPart(text=\"Explain X\")]),\n            Message(\n                role=\"assistant\",\n                content=[\n                    ThinkPart(think=\"Let me reason about X step by step...\"),\n                    TextPart(text=\"X is explained as follows...\"),\n                ],\n            ),\n        ]\n        result = stringify_context_history(history)\n        assert \"Let me reason about X step by step...\" in result\n        assert \"<thinking>\" in result\n        assert \"X is explained as follows...\" in result\n\n    def test_tool_calls_preserved_in_history(self) -> None:\n        \"\"\"Tool call information must appear in the serialized output.\"\"\"\n        tc = _make_tool_call(name=\"ReadFile\", arguments='{\"path\": \"main.py\"}')\n        history: list[Message] = [\n            Message(role=\"user\", content=[TextPart(text=\"Read the file\")]),\n            Message(\n                role=\"assistant\",\n                content=[TextPart(text=\"Reading the file...\")],\n                tool_calls=[tc],\n            ),\n        ]\n        result = stringify_context_history(history)\n        assert \"Tool Call: ReadFile(\" in result\n        assert \"main.py\" in result\n\n    def test_tool_result_preserved_in_history(self) -> None:\n        \"\"\"Tool result messages must appear with their call_id.\"\"\"\n        history: list[Message] = [\n            Message(\n                role=\"tool\",\n                content=[TextPart(text=\"file content here\")],\n                tool_call_id=\"call_001\",\n            ),\n        ]\n        result = stringify_context_history(history)\n        assert \"[TOOL]\" in result\n        assert \"call_id: call_001\" in result\n        assert \"file content here\" in result\n\n    def test_checkpoint_messages_filtered(self) -> None:\n        \"\"\"Checkpoint messages must not appear in the serialized output.\"\"\"\n        history: list[Message] = [\n            Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n            _make_checkpoint_message(0),\n            Message(role=\"assistant\", content=[TextPart(text=\"Hi there\")]),\n            _make_checkpoint_message(1),\n        ]\n        result = stringify_context_history(history)\n        assert \"CHECKPOINT\" not in result\n        assert \"Hello\" in result\n        assert \"Hi there\" in result\n\n    def test_full_conversation_round_trip(self) -> None:\n        \"\"\"A complete conversation with thinking, tool calls, and results.\"\"\"\n        tc = _make_tool_call(\n            call_id=\"call_abc\",\n            name=\"bash\",\n            arguments='{\"command\": \"echo hello\"}',\n        )\n        history: list[Message] = [\n            Message(role=\"user\", content=[TextPart(text=\"Run echo hello\")]),\n            Message(\n                role=\"assistant\",\n                content=[\n                    ThinkPart(think=\"User wants to run a command\"),\n                    TextPart(text=\"I'll run that for you.\"),\n                ],\n                tool_calls=[tc],\n            ),\n            Message(\n                role=\"tool\",\n                content=[TextPart(text=\"hello\\n\")],\n                tool_call_id=\"call_abc\",\n            ),\n            Message(\n                role=\"assistant\",\n                content=[TextPart(text=\"The command output is: hello\")],\n            ),\n        ]\n        result = stringify_context_history(history)\n\n        # All key information must be present\n        assert \"Run echo hello\" in result  # user message\n        assert \"User wants to run a command\" in result  # thinking\n        assert \"I'll run that for you.\" in result  # assistant text\n        assert \"Tool Call: bash(\" in result  # tool call\n        assert \"echo hello\" in result  # tool args\n        assert \"[TOOL] (call_id: call_abc)\" in result  # tool result header\n        assert \"hello\\n\" in result  # tool result content\n        assert \"The command output is: hello\" in result  # final response\n\n    def test_empty_messages_skipped(self) -> None:\n        \"\"\"Messages with no content and no tool_calls should be skipped.\"\"\"\n        history: list[Message] = [\n            Message(role=\"assistant\", content=[TextPart(text=\"\")]),\n            Message(role=\"user\", content=[TextPart(text=\"Real message\")]),\n        ]\n        result = stringify_context_history(history)\n        assert \"[ASSISTANT]\" not in result\n        assert \"Real message\" in result\n\n    def test_system_role_preserved(self) -> None:\n        history: list[Message] = [\n            Message(role=\"system\", content=[TextPart(text=\"You are a helpful assistant\")]),\n        ]\n        result = stringify_context_history(history)\n        assert \"[SYSTEM]\" in result\n        assert \"You are a helpful assistant\" in result\n\n\n# ---------------------------------------------------------------------------\n# _is_checkpoint_message\n# ---------------------------------------------------------------------------\n\n\nclass TestIsCheckpointMessage:\n    def test_checkpoint_detected(self) -> None:\n        msg = _make_checkpoint_message(0)\n        assert _is_checkpoint_message(msg) is True\n\n    def test_regular_user_message(self) -> None:\n        msg = Message(role=\"user\", content=[TextPart(text=\"Hello\")])\n        assert _is_checkpoint_message(msg) is False\n\n    def test_assistant_message_not_checkpoint(self) -> None:\n        msg = Message(role=\"assistant\", content=[TextPart(text=\"<system>CHECKPOINT 0</system>\")])\n        assert _is_checkpoint_message(msg) is False\n\n    def test_multi_part_message_not_checkpoint(self) -> None:\n        msg = Message(\n            role=\"user\",\n            content=[\n                TextPart(text=\"<system>CHECKPOINT 0</system>\"),\n                TextPart(text=\"extra\"),\n            ],\n        )\n        assert _is_checkpoint_message(msg) is False\n\n\n# ---------------------------------------------------------------------------\n# _format_content_part_md (export side)\n# ---------------------------------------------------------------------------\n\n\nclass TestFormatContentPartMd:\n    def test_text_part(self) -> None:\n        result = _format_content_part_md(TextPart(text=\"Hello world\"))\n        assert result == \"Hello world\"\n\n    def test_think_part_wrapped_in_details(self) -> None:\n        result = _format_content_part_md(ThinkPart(think=\"Reasoning here\"))\n        assert \"<details><summary>Thinking</summary>\" in result\n        assert \"Reasoning here\" in result\n        assert \"</details>\" in result\n\n    def test_empty_think_part_returns_empty(self) -> None:\n        assert _format_content_part_md(ThinkPart(think=\"\")) == \"\"\n        assert _format_content_part_md(ThinkPart(think=\"   \")) == \"\"\n\n    def test_image_placeholder(self) -> None:\n        part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/img.png\"))\n        assert _format_content_part_md(part) == \"[image]\"\n\n    def test_audio_placeholder(self) -> None:\n        part = AudioURLPart(audio_url=AudioURLPart.AudioURL(url=\"https://example.com/a.mp3\"))\n        assert _format_content_part_md(part) == \"[audio]\"\n\n    def test_video_placeholder(self) -> None:\n        part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/v.mp4\"))\n        assert _format_content_part_md(part) == \"[video]\"\n\n\n# ---------------------------------------------------------------------------\n# _extract_tool_call_hint\n# ---------------------------------------------------------------------------\n\n\nclass TestExtractToolCallHint:\n    def test_known_key_path(self) -> None:\n        result = _extract_tool_call_hint('{\"path\": \"/src/main.py\"}')\n        assert result == \"/src/main.py\"\n\n    def test_known_key_command(self) -> None:\n        result = _extract_tool_call_hint('{\"command\": \"ls -la\"}')\n        assert result == \"ls -la\"\n\n    def test_fallback_to_first_short_string(self) -> None:\n        result = _extract_tool_call_hint('{\"foo\": \"bar\"}')\n        assert result == \"bar\"\n\n    def test_empty_on_invalid_json(self) -> None:\n        assert _extract_tool_call_hint(\"not json\") == \"\"\n\n    def test_empty_on_non_dict(self) -> None:\n        assert _extract_tool_call_hint(\"[1, 2, 3]\") == \"\"\n\n    def test_empty_on_no_string_values(self) -> None:\n        assert _extract_tool_call_hint('{\"count\": 42}') == \"\"\n\n    def test_long_value_truncated(self) -> None:\n        long_val = \"a\" * 100\n        result = _extract_tool_call_hint(f'{{\"path\": \"{long_val}\"}}')\n        assert len(result) <= 60\n        assert result.endswith(\"…\")\n\n\n# ---------------------------------------------------------------------------\n# _format_tool_call_md\n# ---------------------------------------------------------------------------\n\n\nclass TestFormatToolCallMd:\n    def test_basic_tool_call(self) -> None:\n        tc = _make_tool_call(call_id=\"c1\", name=\"bash\", arguments='{\"command\": \"ls\"}')\n        result = _format_tool_call_md(tc)\n        assert \"#### Tool Call: bash\" in result\n        assert \"(`ls`)\" in result  # hint extracted\n        assert \"call_id: c1\" in result\n        assert \"```json\" in result\n\n    def test_invalid_json_arguments(self) -> None:\n        tc = _make_tool_call(name=\"test\", arguments=\"not json\")\n        result = _format_tool_call_md(tc)\n        assert \"#### Tool Call: test\" in result\n        assert \"not json\" in result\n\n    def test_no_hint_when_no_string_args(self) -> None:\n        tc = _make_tool_call(name=\"test\", arguments='{\"count\": 42}')\n        result = _format_tool_call_md(tc)\n        assert \"#### Tool Call: test\\n\" in result  # no hint in parens\n\n\n# ---------------------------------------------------------------------------\n# _format_tool_result_md\n# ---------------------------------------------------------------------------\n\n\nclass TestFormatToolResultMd:\n    def test_basic_tool_result(self) -> None:\n        msg = Message(\n            role=\"tool\",\n            content=[TextPart(text=\"output text\")],\n            tool_call_id=\"c1\",\n        )\n        result = _format_tool_result_md(msg, \"bash\", \"ls\")\n        assert \"<details><summary>Tool Result: bash (`ls`)</summary>\" in result\n        assert \"call_id: c1\" in result\n        assert \"output text\" in result\n        assert \"</details>\" in result\n\n    def test_system_tagged_content_preserved(self) -> None:\n        \"\"\"Tool results with <system> tags should still include the text.\"\"\"\n        msg = Message(\n            role=\"tool\",\n            content=[system(\"ERROR: command failed\"), TextPart(text=\"stderr output\")],\n            tool_call_id=\"c2\",\n        )\n        result = _format_tool_result_md(msg, \"bash\", \"\")\n        assert \"command failed\" in result\n        assert \"stderr output\" in result\n\n    def test_no_hint(self) -> None:\n        msg = Message(\n            role=\"tool\",\n            content=[TextPart(text=\"data\")],\n            tool_call_id=\"c1\",\n        )\n        result = _format_tool_result_md(msg, \"ReadFile\", \"\")\n        assert \"Tool Result: ReadFile</summary>\" in result\n        assert \"(`\" not in result\n\n\n# ---------------------------------------------------------------------------\n# _group_into_turns\n# ---------------------------------------------------------------------------\n\n\nclass TestGroupIntoTurns:\n    def test_single_turn(self) -> None:\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Hi\")]),\n        ]\n        turns = _group_into_turns(history)\n        assert len(turns) == 1\n        assert len(turns[0]) == 2\n\n    def test_multiple_turns(self) -> None:\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Q1\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"A1\")]),\n            Message(role=\"user\", content=[TextPart(text=\"Q2\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"A2\")]),\n        ]\n        turns = _group_into_turns(history)\n        assert len(turns) == 2\n\n    def test_checkpoints_excluded_from_turns(self) -> None:\n        \"\"\"Checkpoint messages must be filtered out entirely during grouping.\"\"\"\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Q1\")]),\n            _make_checkpoint_message(0),\n            Message(role=\"assistant\", content=[TextPart(text=\"A1\")]),\n        ]\n        turns = _group_into_turns(history)\n        assert len(turns) == 1\n        assert len(turns[0]) == 2  # user + assistant (checkpoint filtered out)\n\n    def test_leading_checkpoints_no_empty_turn(self) -> None:\n        \"\"\"Checkpoints before the first real user message must not produce an empty turn.\"\"\"\n        history = [\n            _make_checkpoint_message(0),\n            _make_checkpoint_message(1),\n            Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Hi\")]),\n        ]\n        turns = _group_into_turns(history)\n        assert len(turns) == 1\n        assert turns[0][0].role == \"user\"\n\n    def test_system_messages_before_first_user(self) -> None:\n        \"\"\"System messages before first user message form a separate initial group.\"\"\"\n        history = [\n            Message(role=\"system\", content=[TextPart(text=\"System prompt\")]),\n            Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Hi\")]),\n        ]\n        turns = _group_into_turns(history)\n        assert len(turns) == 2\n        # First group: system message only\n        assert turns[0][0].role == \"system\"\n        # Second group: user + assistant\n        assert turns[1][0].role == \"user\"\n        assert len(turns[1]) == 2\n\n    def test_system_reminders_excluded_from_turns(self) -> None:\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Q1\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"A1\")]),\n            _make_system_reminder_message(\"Do not split the turn.\"),\n            Message(role=\"assistant\", content=[TextPart(text=\"A2\")]),\n        ]\n\n        turns = _group_into_turns(history)\n\n        assert len(turns) == 1\n        assert [msg.extract_text(\" \") for msg in turns[0]] == [\"Q1\", \"A1\", \"A2\"]\n\n    def test_plain_steer_user_message_starts_new_turn(self) -> None:\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Q1\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"A1\")]),\n            Message(role=\"user\", content=[TextPart(text=\"A steer follow-up\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"A2\")]),\n        ]\n\n        turns = _group_into_turns(history)\n\n        assert len(turns) == 2\n        assert turns[1][0].extract_text(\" \") == \"A steer follow-up\"\n\n\n# ---------------------------------------------------------------------------\n# build_export_markdown\n# ---------------------------------------------------------------------------\n\n\nclass TestBuildExportMarkdown:\n    def test_contains_yaml_frontmatter(self) -> None:\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Hi\")]),\n        ]\n        now = datetime(2026, 3, 2, 12, 0, 0)\n        result = build_export_markdown(\n            session_id=\"test-session\",\n            work_dir=\"/tmp/work\",\n            history=history,\n            token_count=1000,\n            now=now,\n        )\n        assert \"session_id: test-session\" in result\n        assert \"exported_at: 2026-03-02T12:00:00\" in result\n        assert \"work_dir: /tmp/work\" in result\n        assert \"message_count: 2\" in result\n        assert \"token_count: 1000\" in result\n\n    def test_contains_overview_and_turns(self) -> None:\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"What is 2+2?\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"4\")]),\n        ]\n        now = datetime(2026, 1, 1)\n        result = build_export_markdown(\n            session_id=\"s1\",\n            work_dir=\"/w\",\n            history=history,\n            token_count=100,\n            now=now,\n        )\n        assert \"## Overview\" in result\n        assert \"## Turn 1\" in result\n        assert \"### User\" in result\n        assert \"What is 2+2?\" in result\n        assert \"### Assistant\" in result\n        assert \"4\" in result\n\n    def test_tool_calls_in_export(self) -> None:\n        \"\"\"Full round-trip: user -> assistant with tool call -> tool result -> final.\"\"\"\n        tc = _make_tool_call(call_id=\"c1\", name=\"bash\", arguments='{\"command\": \"echo hi\"}')\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Run echo hi\")]),\n            Message(\n                role=\"assistant\",\n                content=[TextPart(text=\"Running...\")],\n                tool_calls=[tc],\n            ),\n            Message(\n                role=\"tool\",\n                content=[TextPart(text=\"hi\\n\")],\n                tool_call_id=\"c1\",\n            ),\n            Message(\n                role=\"assistant\",\n                content=[TextPart(text=\"Done.\")],\n            ),\n        ]\n        now = datetime(2026, 1, 1)\n        result = build_export_markdown(\n            session_id=\"s1\",\n            work_dir=\"/w\",\n            history=history,\n            token_count=500,\n            now=now,\n        )\n        assert \"Tool Call: bash\" in result\n        assert \"echo hi\" in result\n        assert \"Tool Result: bash\" in result\n        assert \"hi\\n\" in result\n        assert \"Done.\" in result\n\n    def test_system_reminders_are_omitted_from_export_and_topic(self) -> None:\n        history = [\n            _make_system_reminder_message(\"Never show this reminder.\"),\n            Message(role=\"user\", content=[TextPart(text=\"Real question\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Real answer\")]),\n        ]\n        now = datetime(2026, 1, 1)\n\n        result = build_export_markdown(\n            session_id=\"s1\",\n            work_dir=\"/w\",\n            history=history,\n            token_count=100,\n            now=now,\n        )\n\n        assert \"Never show this reminder.\" not in result\n        assert \"- **Topic**: Real question\" in result\n\n    def test_plain_steer_is_included_in_export(self) -> None:\n        history = [\n            Message(role=\"user\", content=[TextPart(text=\"Original question\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"First answer\")]),\n            Message(role=\"user\", content=[TextPart(text=\"A steer follow-up\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Second answer\")]),\n        ]\n        now = datetime(2026, 1, 1)\n\n        result = build_export_markdown(\n            session_id=\"s1\",\n            work_dir=\"/w\",\n            history=history,\n            token_count=100,\n            now=now,\n        )\n\n        assert \"A steer follow-up\" in result\n        assert \"## Turn 2\" in result\n\n    def test_stringify_context_history_skips_system_reminders(self) -> None:\n        history = [\n            _make_system_reminder_message(\"Never show this reminder.\"),\n            Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n            Message(role=\"assistant\", content=[TextPart(text=\"Hi\")]),\n        ]\n\n        result = stringify_context_history(history)\n\n        assert \"Never show this reminder.\" not in result\n        assert \"[USER]\\nHello\" in result\n\n\n# ---------------------------------------------------------------------------\n# is_importable_file\n# ---------------------------------------------------------------------------\n\n\nclass TestIsImportableFile:\n    def test_markdown(self) -> None:\n        assert is_importable_file(\"notes.md\") is True\n\n    def test_txt(self) -> None:\n        assert is_importable_file(\"readme.txt\") is True\n\n    def test_python(self) -> None:\n        assert is_importable_file(\"main.py\") is True\n\n    def test_json(self) -> None:\n        assert is_importable_file(\"data.json\") is True\n\n    def test_log(self) -> None:\n        assert is_importable_file(\"server.log\") is True\n\n    def test_no_extension_accepted(self) -> None:\n        assert is_importable_file(\"Makefile\") is True\n        assert is_importable_file(\"README\") is True\n\n    def test_binary_rejected(self) -> None:\n        assert is_importable_file(\"photo.png\") is False\n        assert is_importable_file(\"archive.zip\") is False\n        assert is_importable_file(\"document.pdf\") is False\n        assert is_importable_file(\"binary.exe\") is False\n        assert is_importable_file(\"image.jpg\") is False\n\n    def test_case_insensitive(self) -> None:\n        assert is_importable_file(\"README.MD\") is True\n        assert is_importable_file(\"config.YAML\") is True\n        assert is_importable_file(\"style.CSS\") is True\n\n    def test_importable_extensions_is_frozenset(self) -> None:\n        assert isinstance(_IMPORTABLE_EXTENSIONS, frozenset)\n\n\n# ---------------------------------------------------------------------------\n# perform_export\n# ---------------------------------------------------------------------------\n\n_SIMPLE_HISTORY = [\n    Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n    Message(role=\"assistant\", content=[TextPart(text=\"Hi!\")]),\n]\n\n\nclass TestPerformExport:\n    async def test_empty_history_returns_error(self, tmp_path: Path) -> None:\n        result = await perform_export(\n            history=[],\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=0,\n            args=\"\",\n            default_dir=tmp_path,\n        )\n        assert result == \"No messages to export.\"\n\n    async def test_writes_to_specified_file(self, tmp_path: Path) -> None:\n        output = tmp_path / \"my-export.md\"\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=100,\n            args=str(output),\n            default_dir=tmp_path,\n        )\n        assert isinstance(result, tuple)\n        path, count = result\n        assert path == output\n        assert count == 2\n        assert output.exists()\n        content = output.read_text()\n        assert \"# Kimi Session Export\" in content\n        assert \"Hello\" in content\n\n    async def test_uses_default_dir_when_no_args(self, tmp_path: Path) -> None:\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=100,\n            args=\"\",\n            default_dir=tmp_path,\n        )\n        assert isinstance(result, tuple)\n        path, _ = result\n        assert path.parent == tmp_path\n        assert path.name.startswith(\"kimi-export-abc12345\")\n        assert path.name.endswith(\".md\")\n\n    async def test_dir_arg_appends_default_name(self, tmp_path: Path) -> None:\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=100,\n            args=str(tmp_path),\n            default_dir=tmp_path,\n        )\n        assert isinstance(result, tuple)\n        path, _ = result\n        assert path.parent == tmp_path\n        assert path.name.startswith(\"kimi-export-abc12345\")\n\n    async def test_trailing_separator_uses_directory_semantics_when_missing(\n        self, tmp_path: Path\n    ) -> None:\n        export_dir = tmp_path / \"exports\"\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=100,\n            args=f\"{export_dir}/\",\n            default_dir=tmp_path,\n        )\n        assert isinstance(result, tuple)\n        path, _ = result\n        assert path.parent == export_dir\n        assert path.name.startswith(\"kimi-export-abc12345\")\n        assert export_dir.exists() and export_dir.is_dir()\n        assert path.exists()\n\n    async def test_creates_parent_dirs(self, tmp_path: Path) -> None:\n        nested = tmp_path / \"a\" / \"b\" / \"export.md\"\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=100,\n            args=str(nested),\n            default_dir=tmp_path,\n        )\n        assert isinstance(result, tuple)\n        assert nested.exists()\n\n    async def test_write_error_returns_message(self, tmp_path: Path) -> None:\n        # Point to a path where parent cannot be created (file masquerading as dir)\n        blocker = tmp_path / \"blocker\"\n        blocker.write_text(\"x\")\n        bad_path = blocker / \"sub\" / \"export.md\"\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=100,\n            args=str(bad_path),\n            default_dir=tmp_path,\n        )\n        assert isinstance(result, str)\n        assert \"Failed to write export file\" in result\n\n\n# ---------------------------------------------------------------------------\n# resolve_import_source\n# ---------------------------------------------------------------------------\n\n\nclass TestResolveImportSource:\n    async def test_directory_returns_error(self, tmp_path: Path) -> None:\n        target_dir = tmp_path / \"some-dir\"\n        target_dir.mkdir()\n        result = await resolve_import_source(str(target_dir), \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"directory\" in result.lower()\n\n    async def test_unsupported_file_type_returns_error(self, tmp_path: Path) -> None:\n        img = tmp_path / \"photo.png\"\n        img.write_bytes(b\"\\x89PNG\")\n        result = await resolve_import_source(str(img), \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"Unsupported file type\" in result\n\n    async def test_empty_file_returns_error(self, tmp_path: Path) -> None:\n        empty = tmp_path / \"empty.md\"\n        empty.write_text(\"   \\n  \")\n        result = await resolve_import_source(str(empty), \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"empty\" in result.lower()\n\n    async def test_binary_content_returns_error(self, tmp_path: Path) -> None:\n        bad = tmp_path / \"data.txt\"\n        bad.write_bytes(b\"\\xff\\xfe\" + b\"\\x00\" * 100)\n        result = await resolve_import_source(str(bad), \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"UTF-8\" in result\n\n    async def test_self_import_returns_error(self, tmp_path: Path) -> None:\n        result = await resolve_import_source(\"curr-id\", \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"Cannot import the current session\" in result\n\n    async def test_nonexistent_session_returns_error(self, tmp_path: Path, monkeypatch) -> None:\n        from kimi_cli.session import Session\n\n        async def fake_find(_work_dir, _target):\n            return None\n\n        monkeypatch.setattr(Session, \"find\", fake_find)\n        result = await resolve_import_source(\"no-such-id\", \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"not a valid file path or session ID\" in result\n\n    async def test_file_too_large_returns_error(self, tmp_path: Path, monkeypatch) -> None:\n        import kimi_cli.utils.export as export_mod\n\n        monkeypatch.setattr(export_mod, \"MAX_IMPORT_SIZE\", 10)  # 10 bytes\n        big = tmp_path / \"big.md\"\n        big.write_text(\"x\" * 100, encoding=\"utf-8\")\n        result = await resolve_import_source(str(big), \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"too large\" in result.lower()\n\n    async def test_session_content_too_large_returns_error(\n        self, tmp_path: Path, monkeypatch\n    ) -> None:\n        import kimi_cli.utils.export as export_mod\n        from kimi_cli.session import Session\n\n        # Mock Session.find to return a fake session\n        fake_session = type(\"FakeSession\", (), {\"context_file\": tmp_path / \"ctx.jsonl\"})()\n\n        async def fake_find(_work_dir, _target):\n            return fake_session\n\n        monkeypatch.setattr(Session, \"find\", fake_find)\n\n        # Mock Context to return a large history\n        big_text = \"x\" * 200\n        fake_history = [Message(role=\"user\", content=[TextPart(text=big_text)])]\n\n        class FakeContext:\n            def __init__(self, _path):\n                self.history = fake_history\n\n            async def restore(self):\n                return True\n\n        from kimi_cli.soul import context as context_mod\n\n        monkeypatch.setattr(context_mod, \"Context\", FakeContext)\n        monkeypatch.setattr(export_mod, \"MAX_IMPORT_SIZE\", 10)  # 10 bytes\n\n        result = await resolve_import_source(\"other-id\", \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"too large\" in result.lower()\n\n    async def test_session_restore_failure_returns_error(self, tmp_path: Path, monkeypatch) -> None:\n        from kimi_cli.session import Session\n        from kimi_cli.soul import context as context_mod\n\n        fake_session = type(\"FakeSession\", (), {\"context_file\": tmp_path / \"ctx.jsonl\"})()\n\n        async def fake_find(_work_dir, _target):\n            return fake_session\n\n        monkeypatch.setattr(Session, \"find\", fake_find)\n\n        class FailingContext:\n            def __init__(self, _path):\n                self.history = []\n\n            async def restore(self):\n                raise RuntimeError(\"corrupt context file\")\n\n        monkeypatch.setattr(context_mod, \"Context\", FailingContext)\n\n        result = await resolve_import_source(\"other-id\", \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, str)\n        assert \"Failed to load source session\" in result\n\n    async def test_successful_file_import(self, tmp_path: Path) -> None:\n        src = tmp_path / \"context.md\"\n        src.write_text(\"some important context\", encoding=\"utf-8\")\n        result = await resolve_import_source(str(src), \"curr-id\", tmp_path)  # type: ignore[arg-type]\n        assert isinstance(result, tuple)\n        content, source_desc = result\n        assert content == \"some important context\"\n        assert \"context.md\" in source_desc\n\n\n# ---------------------------------------------------------------------------\n# perform_export — edge cases\n# ---------------------------------------------------------------------------\n\n\nclass TestPerformExportRelativePath:\n    async def test_relative_path_anchored_to_default_dir(self, tmp_path: Path) -> None:\n        \"\"\"A relative output path must resolve against default_dir, not process CWD.\"\"\"\n        work = tmp_path / \"project\"\n        work.mkdir()\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=str(work),\n            token_count=100,\n            args=\"subdir/my-export.md\",\n            default_dir=work,\n        )\n        assert isinstance(result, tuple)\n        path, _ = result\n        assert path == work / \"subdir\" / \"my-export.md\"\n        assert path.exists()\n\n    async def test_absolute_path_not_affected(self, tmp_path: Path) -> None:\n        \"\"\"Absolute paths must not be re-anchored to default_dir.\"\"\"\n        work = tmp_path / \"project\"\n        work.mkdir()\n        abs_output = tmp_path / \"elsewhere\" / \"out.md\"\n        result = await perform_export(\n            history=_SIMPLE_HISTORY,\n            session_id=\"abc12345\",\n            work_dir=str(work),\n            token_count=100,\n            args=str(abs_output),\n            default_dir=work,\n        )\n        assert isinstance(result, tuple)\n        path, _ = result\n        assert path == abs_output\n        assert path.exists()\n\n\nclass TestResolveImportRelativePath:\n    async def test_relative_path_anchored_to_work_dir(self, tmp_path: Path) -> None:\n        \"\"\"A relative import path must resolve against work_dir, not process CWD.\"\"\"\n        work = tmp_path / \"project\"\n        work.mkdir()\n        src = work / \"notes.md\"\n        src.write_text(\"important notes\", encoding=\"utf-8\")\n        result = await resolve_import_source(\"notes.md\", \"curr-id\", work)  # type: ignore[arg-type]\n        assert isinstance(result, tuple)\n        content, desc = result\n        assert content == \"important notes\"\n        assert \"notes.md\" in desc\n\n    async def test_absolute_path_not_affected(self, tmp_path: Path) -> None:\n        \"\"\"Absolute paths must not be re-anchored to work_dir.\"\"\"\n        work = tmp_path / \"project\"\n        work.mkdir()\n        outside = tmp_path / \"other\" / \"data.txt\"\n        outside.parent.mkdir(parents=True)\n        outside.write_text(\"external data\", encoding=\"utf-8\")\n        result = await resolve_import_source(str(outside), \"curr-id\", work)  # type: ignore[arg-type]\n        assert isinstance(result, tuple)\n        content, _ = result\n        assert content == \"external data\"\n\n\nclass TestPerformExportEdgeCases:\n    async def test_checkpoint_only_history_still_exports(self, tmp_path: Path) -> None:\n        \"\"\"History with only checkpoint messages should still export (they are filtered in turns).\"\"\"\n        from kimi_cli.soul.message import system as sys_msg\n\n        history = [\n            Message(role=\"user\", content=[sys_msg(\"CHECKPOINT 0\")]),\n            Message(role=\"user\", content=[sys_msg(\"CHECKPOINT 1\")]),\n        ]\n        result = await perform_export(\n            history=history,\n            session_id=\"abc12345\",\n            work_dir=\"/tmp\",\n            token_count=0,\n            args=\"\",\n            default_dir=tmp_path,\n        )\n        # Not empty (history has 2 messages), but turns will be empty\n        assert isinstance(result, tuple)\n        path, count = result\n        assert count == 2\n        content = path.read_text()\n        assert \"# Kimi Session Export\" in content\n\n\n# ---------------------------------------------------------------------------\n# build_import_message\n# ---------------------------------------------------------------------------\n\n\nclass TestBuildImportMessage:\n    def test_returns_user_message_with_expected_structure(self) -> None:\n        msg = build_import_message(\"hello world\", \"file 'test.md'\")\n        assert msg.role == \"user\"\n        assert len(msg.content) == 2\n\n        # First part is a system hint\n        first = msg.content[0]\n        assert isinstance(first, TextPart)\n        assert \"imported context\" in first.text.lower()\n\n        # Second part contains the wrapped content\n        second = msg.content[1]\n        assert isinstance(second, TextPart)\n        assert \"<imported_context source=\\\"file 'test.md'\\\">\" in second.text\n        assert \"hello world\" in second.text\n        assert \"</imported_context>\" in second.text\n\n\n# ---------------------------------------------------------------------------\n# perform_import\n# ---------------------------------------------------------------------------\n\n\ndef _make_mock_context(token_count: int = 0):\n    \"\"\"Create a minimal mock context for perform_import tests.\"\"\"\n    from unittest.mock import AsyncMock\n\n    ctx = AsyncMock()\n    ctx.token_count = token_count\n    return ctx\n\n\nclass TestPerformImport:\n    async def test_file_exceeding_model_context_budget_returns_error(self, tmp_path: Path) -> None:\n        src = tmp_path / \"context.md\"\n        src.write_text(\"x\" * 2000, encoding=\"utf-8\")\n        ctx = _make_mock_context(token_count=0)\n        result = await perform_import(\n            str(src),\n            \"curr-id\",\n            tmp_path,  # type: ignore[arg-type]\n            context=ctx,\n            max_context_size=128,\n        )\n        assert isinstance(result, str)\n        assert \"model context\" in result.lower()\n        assert \"import tokens\" in result.lower()\n        # Context must NOT be mutated on failure.\n        ctx.append_message.assert_not_awaited()\n        ctx.update_token_count.assert_not_awaited()\n\n    async def test_file_within_model_context_budget_succeeds(self, tmp_path: Path) -> None:\n        src = tmp_path / \"small.md\"\n        src.write_text(\"small context\", encoding=\"utf-8\")\n        ctx = _make_mock_context(token_count=0)\n        result = await perform_import(\n            str(src),\n            \"curr-id\",\n            tmp_path,  # type: ignore[arg-type]\n            context=ctx,\n            max_context_size=4096,\n        )\n        assert isinstance(result, tuple)\n        source_desc, content_len = result\n        assert source_desc == \"file 'small.md'\"\n        assert content_len == len(\"small context\")\n        ctx.append_message.assert_awaited_once()\n        ctx.update_token_count.assert_awaited_once()\n\n    async def test_existing_context_pushes_import_over_budget(self, tmp_path: Path) -> None:\n        \"\"\"Import that fits alone but exceeds budget with existing context tokens.\"\"\"\n        src = tmp_path / \"medium.md\"\n        src.write_text(\"a\" * 100, encoding=\"utf-8\")\n        # current_token_count near the limit — should fail.\n        ctx = _make_mock_context(token_count=180)\n        result = await perform_import(\n            str(src),\n            \"curr-id\",\n            tmp_path,  # type: ignore[arg-type]\n            context=ctx,\n            max_context_size=200,\n        )\n        assert isinstance(result, str)\n        assert \"model context\" in result.lower()\n        assert \"existing\" in result.lower()\n        ctx.append_message.assert_not_awaited()\n\n    async def test_session_exceeding_model_context_budget_returns_error(\n        self, tmp_path: Path, monkeypatch\n    ) -> None:\n        \"\"\"Session import that exceeds model context budget is rejected.\"\"\"\n        from kimi_cli.session import Session\n        from kimi_cli.soul import context as context_mod\n\n        fake_session = type(\"FakeSession\", (), {\"context_file\": tmp_path / \"ctx.jsonl\"})()\n\n        async def fake_find(_work_dir, _target):\n            return fake_session\n\n        monkeypatch.setattr(Session, \"find\", fake_find)\n\n        big_text = \"x\" * 2000\n        fake_history = [Message(role=\"user\", content=[TextPart(text=big_text)])]\n\n        class FakeContext:\n            def __init__(self, _path):\n                self.history = fake_history\n\n            async def restore(self):\n                return True\n\n        monkeypatch.setattr(context_mod, \"Context\", FakeContext)\n\n        ctx = _make_mock_context(token_count=0)\n        result = await perform_import(\n            \"other-id\",\n            \"curr-id\",\n            tmp_path,  # type: ignore[arg-type]\n            context=ctx,\n            max_context_size=128,\n        )\n        assert isinstance(result, str)\n        assert \"model context\" in result.lower()\n        ctx.append_message.assert_not_awaited()\n\n    async def test_returns_raw_content_len(self, tmp_path: Path) -> None:\n        \"\"\"content_len must equal the raw content length, not the wrapped message.\"\"\"\n        src = tmp_path / \"data.txt\"\n        raw = \"hello world\"\n        src.write_text(raw, encoding=\"utf-8\")\n        ctx = _make_mock_context(token_count=0)\n        result = await perform_import(\n            str(src),\n            \"curr-id\",\n            tmp_path,  # type: ignore[arg-type]\n            context=ctx,\n        )\n        assert isinstance(result, tuple)\n        _desc, content_len = result\n        assert content_len == len(raw)\n"
  },
  {
    "path": "tests/ui_and_conv/test_file_completer.py",
    "content": "\"\"\"Tests for the shell file mention completer.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom inline_snapshot import snapshot\nfrom prompt_toolkit.completion import CompleteEvent\nfrom prompt_toolkit.document import Document\n\nfrom kimi_cli.ui.shell.prompt import LocalFileMentionCompleter\n\n\ndef _completion_texts(completer: LocalFileMentionCompleter, text: str) -> list[str]:\n    document = Document(text=text, cursor_position=len(text))\n    event = CompleteEvent(completion_requested=True)\n    return [completion.text for completion in completer.get_completions(document, event)]\n\n\ndef test_top_level_paths_skip_ignored_names(tmp_path: Path):\n    \"\"\"Only surface non-ignored entries when completing the top level.\"\"\"\n    (tmp_path / \"src\").mkdir()\n    (tmp_path / \"node_modules\").mkdir()\n    (tmp_path / \".DS_Store\").write_text(\"\")\n    (tmp_path / \"README.md\").write_text(\"hello\")\n\n    completer = LocalFileMentionCompleter(tmp_path)\n\n    texts = _completion_texts(completer, \"@\")\n\n    assert \"src/\" in texts\n    assert \"README.md\" in texts\n    assert \"node_modules/\" not in texts\n    assert \".DS_Store\" not in texts\n\n\ndef test_directory_completion_continues_after_slash(tmp_path: Path):\n    \"\"\"Continue descending when the fragment ends with a slash.\"\"\"\n    src = tmp_path / \"src\"\n    src.mkdir()\n    nested = src / \"module.py\"\n    nested.write_text(\"print('hi')\\n\")\n\n    completer = LocalFileMentionCompleter(tmp_path)\n\n    texts = _completion_texts(completer, \"@src/\")\n\n    assert \"src/\" in texts\n    assert \"src/module.py\" in texts\n\n\ndef test_completed_file_short_circuits_completions(tmp_path: Path):\n    \"\"\"Stop offering fuzzy matches once the fragment resolves to an existing file.\"\"\"\n    agents = tmp_path / \"AGENTS.md\"\n    agents.write_text(\"# Agents\\n\")\n\n    nested_dir = tmp_path / \"src\" / \"kimi_cli\" / \"agents\"\n    nested_dir.mkdir(parents=True)\n    (nested_dir / \"README.md\").write_text(\"nested\\n\")\n\n    completer = LocalFileMentionCompleter(tmp_path)\n\n    texts = _completion_texts(completer, \"@AGENTS.md\")\n\n    assert not texts\n\n\ndef test_limit_is_enforced(tmp_path: Path):\n    \"\"\"Respect the configured limit when building top-level candidates.\"\"\"\n    for index in range(10):\n        (tmp_path / f\"dir{index}\").mkdir()\n    for index in range(10):\n        (tmp_path / f\"file{index}.txt\").write_text(\"x\")\n\n    limit = 8\n    completer = LocalFileMentionCompleter(tmp_path, limit=limit)\n\n    texts = _completion_texts(completer, \"@\")\n\n    assert len(set(texts)) == limit\n\n\ndef test_at_guard_prevents_email_like_fragments(tmp_path: Path):\n    \"\"\"Ignore `@` that are embedded inside identifiers (e.g. emails).\"\"\"\n    (tmp_path / \"example.py\").write_text(\"\")\n\n    completer = LocalFileMentionCompleter(tmp_path)\n\n    texts = _completion_texts(completer, \"email@example.com\")\n\n    assert not texts\n\n\ndef test_basename_prefix_is_ranked_first(tmp_path: Path):\n    \"\"\"Prefer basename prefix matches over cross-segment fuzzy matches.\n\n    For query 'fetch', we want '.../fetch.py' to appear before paths that only\n    match by spreading characters across segments like 'file/patch.py'.\n    \"\"\"\n    # Build a small tree mimicking the real project structure\n    (tmp_path / \"src\" / \"kimi_cli\" / \"tools\" / \"web\").mkdir(parents=True)\n    (tmp_path / \"src\" / \"kimi_cli\" / \"tools\" / \"file\").mkdir(parents=True)\n\n    fetch_py = tmp_path / \"src\" / \"kimi_cli\" / \"tools\" / \"web\" / \"fetch.py\"\n    fetch_py.write_text(\"# fetch\\n\")\n    patch_py = tmp_path / \"src\" / \"kimi_cli\" / \"tools\" / \"file\" / \"patch.py\"\n    patch_py.write_text(\"# patch\\n\")\n\n    completer = LocalFileMentionCompleter(tmp_path)\n\n    texts = _completion_texts(completer, \"@fetch\")\n\n    # Snapshot the full candidate list to keep order/content deterministic\n    assert texts == snapshot(\n        [\n            \"src/kimi_cli/tools/web/fetch.py\",\n            \"src/kimi_cli/tools/file/patch.py\",\n        ]\n    )\n"
  },
  {
    "path": "tests/ui_and_conv/test_live_view_notifications.py",
    "content": "from __future__ import annotations\n\nfrom rich.console import Console\n\nfrom kimi_cli.ui.shell.console import console as shell_console\nfrom kimi_cli.ui.shell.visualize import _LiveView\nfrom kimi_cli.wire.types import Notification, StatusUpdate\n\n\ndef _render(renderable) -> str:\n    console = Console(width=100, record=True, highlight=False)\n    console.print(renderable)\n    return console.export_text()\n\n\ndef _notification(index: int = 1) -> Notification:\n    return Notification(\n        id=f\"n{index:07d}\",\n        category=\"task\",\n        type=\"task.completed\",\n        source_kind=\"background_task\",\n        source_id=f\"b{index:07d}\",\n        title=f\"Background task completed: build project {index}\",\n        body=(f\"Task ID: b{index:07d}\\nStatus: completed\\nDescription: build project {index}\"),\n        severity=\"success\",\n        created_at=123.456,\n        payload={\"task_id\": f\"b{index:07d}\"},\n    )\n\n\ndef test_live_view_renders_notification_block():\n    view = _LiveView(StatusUpdate())\n\n    view.dispatch_wire_message(_notification())\n\n    rendered = _render(view.compose())\n    assert \"Background task completed: build project 1\" in rendered\n    assert \"Task ID: b0000001\" in rendered\n    assert \"Status: completed\" in rendered\n    assert \"...\" in rendered\n\n\ndef test_cleanup_flushes_notifications_to_terminal_history(monkeypatch):\n    view = _LiveView(StatusUpdate())\n    view.dispatch_wire_message(_notification())\n\n    printed = []\n    monkeypatch.setattr(shell_console, \"print\", lambda *args, **kwargs: printed.extend(args))\n\n    view.cleanup(is_interrupt=False)\n\n    assert not view._notification_blocks\n    assert not view._live_notification_blocks\n    assert printed\n    rendered = _render(printed[0])\n    assert \"Background task completed: build project 1\" in rendered\n    assert \"Task ID: b0000001\" in rendered\n\n\ndef test_cleanup_flushes_all_notifications_even_when_live_view_shows_only_latest_four(monkeypatch):\n    view = _LiveView(StatusUpdate())\n    for index in range(1, 6):\n        view.dispatch_wire_message(_notification(index))\n\n    live_rendered = _render(view.compose())\n    assert \"Background task completed: build project 1\" not in live_rendered\n    for index in range(2, 6):\n        assert f\"Background task completed: build project {index}\" in live_rendered\n\n    printed = []\n    monkeypatch.setattr(shell_console, \"print\", lambda *args, **kwargs: printed.extend(args))\n\n    view.cleanup(is_interrupt=False)\n\n    assert len(printed) == 5\n    rendered = \"\\n\".join(_render(item) for item in printed)\n    for index in range(1, 6):\n        assert f\"Background task completed: build project {index}\" in rendered\n"
  },
  {
    "path": "tests/ui_and_conv/test_print_final_only.py",
    "content": "\"\"\"Tests for final-message-only print mode output.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\n\nfrom kimi_cli.ui.print.visualize import FinalOnlyJsonPrinter, FinalOnlyTextPrinter\nfrom kimi_cli.wire.types import StepBegin, TextPart, ThinkPart\n\n\ndef test_final_only_text_printer_outputs_final_text(capsys):\n    printer = FinalOnlyTextPrinter()\n    printer.feed(StepBegin(n=1))\n    printer.feed(TextPart(text=\"first\"))\n    printer.feed(StepBegin(n=2))\n    printer.feed(TextPart(text=\"final\"))\n    printer.feed(TextPart(text=\" msg\"))\n    printer.flush()\n\n    assert capsys.readouterr().out.strip() == \"final msg\"\n\n\ndef test_final_only_json_printer_outputs_final_message(capsys):\n    printer = FinalOnlyJsonPrinter()\n    printer.feed(StepBegin(n=1))\n    printer.feed(TextPart(text=\"first\"))\n    printer.feed(StepBegin(n=2))\n    printer.feed(ThinkPart(think=\"secret\"))\n    printer.feed(TextPart(text=\"final\"))\n    printer.flush()\n\n    output = capsys.readouterr().out.strip()\n    message = json.loads(output)\n    assert message[\"role\"] == \"assistant\"\n    assert message[\"content\"] == \"final\"\n"
  },
  {
    "path": "tests/ui_and_conv/test_print_notifications.py",
    "content": "from __future__ import annotations\n\nimport json\n\nfrom kosong.tooling import ToolReturnValue\n\nfrom kimi_cli.ui.print.visualize import JsonPrinter\nfrom kimi_cli.wire.types import Notification, TextPart, ToolCall, ToolCallPart, ToolResult\n\n\ndef _notification() -> Notification:\n    return Notification(\n        id=\"n1234567\",\n        category=\"task\",\n        type=\"task.completed\",\n        source_kind=\"background_task\",\n        source_id=\"b1234567\",\n        title=\"Background task completed: build project\",\n        body=\"Task ID: b1234567\\nStatus: completed\",\n        severity=\"success\",\n        created_at=123.456,\n        payload={\"task_id\": \"b1234567\"},\n    )\n\n\ndef _tool_call() -> ToolCall:\n    return ToolCall(\n        id=\"call_123\",\n        function=ToolCall.FunctionBody(name=\"Shell\", arguments='{\"command\"'),\n    )\n\n\ndef _tool_result() -> ToolResult:\n    return ToolResult(\n        tool_call_id=\"call_123\",\n        return_value=ToolReturnValue(\n            is_error=False,\n            output=\"\",\n            message=\"Command completed\",\n            display=[],\n        ),\n    )\n\n\ndef test_json_printer_emits_notification_as_distinct_json_event(capsys):\n    printer = JsonPrinter()\n\n    printer.feed(_notification())\n\n    output = capsys.readouterr().out.strip()\n    payload = json.loads(output)\n    assert payload[\"id\"] == \"n1234567\"\n    assert payload[\"type\"] == \"task.completed\"\n    assert payload[\"title\"] == \"Background task completed: build project\"\n\n\ndef test_json_printer_preserves_tool_result_when_notification_interleaves(capsys):\n    printer = JsonPrinter()\n\n    printer.feed(\n        ToolCall(\n            id=\"call_123\",\n            function=ToolCall.FunctionBody(\n                name=\"Shell\",\n                arguments='{\"command\":\"sleep 2\",\"timeout\":5}',\n            ),\n        )\n    )\n    printer.feed(_notification())\n    printer.feed(_tool_result())\n\n    outputs = [json.loads(line) for line in capsys.readouterr().out.strip().splitlines()]\n\n    assert [item.get(\"role\", \"notification\") for item in outputs] == [\n        \"assistant\",\n        \"notification\",\n        \"tool\",\n    ]\n    assert outputs[0][\"tool_calls\"][0][\"id\"] == \"call_123\"\n    assert outputs[1][\"id\"] == \"n1234567\"\n    assert outputs[2][\"tool_call_id\"] == \"call_123\"\n    assert outputs[2][\"content\"] == \"<system>Command completed</system>\"\n\n\ndef test_json_printer_keeps_merged_tool_call_arguments_before_notification(capsys):\n    printer = JsonPrinter()\n\n    printer.feed(_tool_call())\n    printer.feed(ToolCallPart(arguments_part=': \"ls\"}'))\n    printer.feed(_notification())\n    printer.feed(_tool_result())\n\n    outputs = [json.loads(line) for line in capsys.readouterr().out.strip().splitlines()]\n\n    assert len(outputs) == 3\n    assert outputs[0][\"tool_calls\"][0][\"function\"][\"arguments\"] == '{\"command\": \"ls\"}'\n    assert outputs[1][\"type\"] == \"task.completed\"\n    assert outputs[2][\"tool_call_id\"] == \"call_123\"\n\n\ndef test_json_printer_buffers_notification_during_tool_call_streaming(capsys):\n    \"\"\"Notification arriving between ToolCall and ToolCallPart must not truncate arguments.\n\n    Before the fix, only _content_buffer was checked.  A Notification arriving\n    while _tool_call_buffer was non-empty (but _content_buffer empty) would\n    prematurely flush the incomplete assistant message and clear _last_tool_call,\n    causing subsequent ToolCallPart chunks to be silently dropped.\n    \"\"\"\n    printer = JsonPrinter()\n\n    # ToolCall with partial arguments\n    printer.feed(_tool_call())  # arguments = '{\"command\"'\n    # Notification arrives mid-streaming (no content yet, but tool call is pending)\n    printer.feed(_notification())\n    # Remaining arguments arrive\n    printer.feed(ToolCallPart(arguments_part=': \"ls\"}'))\n    # Tool result completes the cycle\n    printer.feed(_tool_result())\n\n    outputs = [json.loads(line) for line in capsys.readouterr().out.strip().splitlines()]\n\n    # Expected order: assistant (with complete tool call) → notification → tool result\n    assert len(outputs) == 3\n    assert outputs[0][\"role\"] == \"assistant\"\n    assert outputs[0][\"tool_calls\"][0][\"function\"][\"arguments\"] == '{\"command\": \"ls\"}'\n    assert outputs[1][\"id\"] == \"n1234567\"\n    assert outputs[2][\"tool_call_id\"] == \"call_123\"\n\n\ndef test_json_printer_does_not_split_streamed_assistant_message_for_notification(capsys):\n    printer = JsonPrinter()\n\n    printer.feed(TextPart(text=\"hello\"))\n    printer.feed(_notification())\n    printer.feed(TextPart(text=\" world\"))\n    printer.flush()\n\n    outputs = [json.loads(line) for line in capsys.readouterr().out.strip().splitlines()]\n\n    assert len(outputs) == 2\n    assert outputs[0][\"role\"] == \"assistant\"\n    assert outputs[0][\"content\"] == \"hello world\"\n    assert outputs[1][\"id\"] == \"n1234567\"\n"
  },
  {
    "path": "tests/ui_and_conv/test_prompt_clipboard.py",
    "content": "from __future__ import annotations\n\nimport shlex\nfrom pathlib import Path\nfrom types import SimpleNamespace\nfrom typing import TYPE_CHECKING, cast\n\nfrom PIL import Image\nfrom prompt_toolkit.key_binding import KeyPressEvent\n\nif TYPE_CHECKING:\n    from prompt_toolkit.buffer import Buffer\n\nfrom kimi_cli.llm import ModelCapability\nfrom kimi_cli.ui.shell import prompt as shell_prompt\nfrom kimi_cli.ui.shell.prompt import PromptMode\nfrom kimi_cli.utils.clipboard import ClipboardResult\nfrom kimi_cli.wire.types import TextPart\n\n\nclass _DummyBuffer:\n    def __init__(self) -> None:\n        self.inserted: list[str] = []\n\n    def insert_text(self, text: str) -> None:\n        self.inserted.append(text)\n\n\nclass _DummyApp:\n    def __init__(self) -> None:\n        self.invalidated = False\n\n    def invalidate(self) -> None:\n        self.invalidated = True\n\n\nclass _FakeAttachmentCache(shell_prompt.AttachmentCache):\n    def __init__(self, store_result: shell_prompt.CachedAttachment | None) -> None:\n        self.store_result = store_result\n\n    def store_image(self, image: Image.Image) -> shell_prompt.CachedAttachment | None:\n        return self.store_result\n\n\ndef _make_prompt_session(\n    mode: PromptMode, *, supports_image: bool = True\n) -> shell_prompt.CustomPromptSession:\n    ps = object.__new__(shell_prompt.CustomPromptSession)\n    ps._mode = mode\n    ps._model_capabilities = cast(\n        set[ModelCapability],\n        {\"image_in\"} if supports_image else set(),\n    )\n    cached = shell_prompt.CachedAttachment(\n        kind=\"image\",\n        attachment_id=\"abc123\",\n        path=Path(\"/tmp/abc123.png\"),\n    )\n    ps._attachment_cache = _FakeAttachmentCache(cached)\n    return ps\n\n\n# --- File path tests (videos, PDFs, etc.) ---\n\n\ndef test_paste_video_path_in_shell_mode(monkeypatch) -> None:\n    video_path = Path(\"/tmp/My Clip (final).mp4\")\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(), file_paths=(video_path,)),\n    )\n\n    ps = _make_prompt_session(PromptMode.SHELL)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is True\n    assert buffer.inserted == [shlex.quote(str(video_path))]\n    assert app.invalidated is True\n\n\ndef test_paste_video_path_in_agent_mode(monkeypatch) -> None:\n    video_path = Path(\"/tmp/My Clip (final).mp4\")\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(), file_paths=(video_path,)),\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is True\n    assert buffer.inserted == [str(video_path)]\n\n\ndef test_paste_single_pdf_in_agent_mode(monkeypatch) -> None:\n    pdf_path = Path(\"/tmp/document.pdf\")\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(), file_paths=(pdf_path,)),\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is True\n    assert buffer.inserted == [str(pdf_path)]\n\n\ndef test_paste_multiple_files(monkeypatch) -> None:\n    \"\"\"Multiple non-image files should all be inserted, space-separated.\"\"\"\n    paths = (Path(\"/tmp/a.pdf\"), Path(\"/tmp/b.csv\"), Path(\"/tmp/c.mp4\"))\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(), file_paths=paths),\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is True\n    assert buffer.inserted == [\"/tmp/a.pdf /tmp/b.csv /tmp/c.mp4\"]\n\n\ndef test_paste_multiple_files_quoted_in_shell_mode(monkeypatch) -> None:\n    paths = (Path(\"/tmp/My Doc.pdf\"), Path(\"/tmp/data (1).csv\"))\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(), file_paths=paths),\n    )\n\n    ps = _make_prompt_session(PromptMode.SHELL)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is True\n    expected = \" \".join(shlex.quote(str(p)) for p in paths)\n    assert buffer.inserted == [expected]\n\n\n# --- Image tests ---\n\n\ndef test_paste_single_image(monkeypatch) -> None:\n    img = Image.new(\"RGB\", (10, 10))\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(img,), file_paths=()),\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT, supports_image=True)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is True\n    assert len(buffer.inserted) == 1\n    assert buffer.inserted[0].startswith(\"[image:\")\n\n\ndef test_paste_image_unsupported_model(monkeypatch, capsys) -> None:\n    img = Image.new(\"RGB\", (10, 10))\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(img,), file_paths=()),\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT, supports_image=False)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    # No image placeholder inserted, returns False so caller can fall back to text paste\n    assert result is False\n    assert buffer.inserted == []\n\n\n# --- Mixed content tests ---\n\n\ndef test_paste_files_and_images_together(monkeypatch) -> None:\n    \"\"\"Both file paths and images should be inserted.\"\"\"\n    img = Image.new(\"RGB\", (5, 5))\n    pdf_path = Path(\"/tmp/doc.pdf\")\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(img,), file_paths=(pdf_path,)),\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT, supports_image=True)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is True\n    # Should have one insert_text call with file path + image placeholder\n    joined = \"\".join(buffer.inserted)\n    assert \"/tmp/doc.pdf\" in joined\n    assert \"[image:\" in joined\n\n\ndef test_paste_returns_false_when_all_images_fail_to_cache(monkeypatch) -> None:\n    \"\"\"When store_image fails for every image, fall back to text paste.\"\"\"\n    img = Image.new(\"RGB\", (10, 10))\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: ClipboardResult(images=(img,), file_paths=()),\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT, supports_image=True)\n    cast(_FakeAttachmentCache, ps._attachment_cache).store_result = None\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is False\n    assert buffer.inserted == []\n\n\ndef test_paste_returns_false_when_no_media(monkeypatch) -> None:\n    monkeypatch.setattr(\n        shell_prompt,\n        \"grab_media_from_clipboard\",\n        lambda: None,\n    )\n\n    ps = _make_prompt_session(PromptMode.AGENT)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(current_buffer=buffer, app=app)\n\n    result = ps._try_paste_media(cast(KeyPressEvent, event))\n\n    assert result is False\n    assert buffer.inserted == []\n\n\ndef test_insert_pasted_text_placeholderizes_long_text_in_agent_mode() -> None:\n    ps = _make_prompt_session(PromptMode.AGENT)\n    buffer = _DummyBuffer()\n    long_text = \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n\n    ps._insert_pasted_text(cast(\"Buffer\", buffer), long_text)\n\n    assert len(buffer.inserted) == 1\n    inserted = buffer.inserted[0]\n    assert inserted == \"[Pasted text #1 +15 lines]\"\n\n    user_input = ps._build_user_input(inserted)\n    assert user_input.command == inserted\n    assert user_input.resolved_command == long_text\n    assert user_input.content == [TextPart(text=long_text)]\n\n\ndef test_insert_pasted_text_keeps_raw_text_in_shell_mode() -> None:\n    ps = _make_prompt_session(PromptMode.SHELL)\n    buffer = _DummyBuffer()\n    long_text = \"alpha\\nbeta\\ngamma\"\n\n    ps._insert_pasted_text(cast(\"Buffer\", buffer), long_text)\n\n    assert buffer.inserted == [long_text]\n\n\ndef test_build_user_input_expands_text_placeholders_for_slash_parsing() -> None:\n    ps = _make_prompt_session(PromptMode.AGENT)\n    long_text = \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    token = ps._get_placeholder_manager().maybe_placeholderize_pasted_text(long_text)\n\n    user_input = ps._build_user_input(f\"/echo {token}\")\n\n    assert user_input.command == f\"/echo {token}\"\n    assert user_input.resolved_command == f\"/echo {long_text}\"\n\n\ndef test_handle_bracketed_paste_placeholderizes_long_text_in_agent_mode() -> None:\n    ps = _make_prompt_session(PromptMode.AGENT)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    data_lines = \"\\r\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    event = SimpleNamespace(\n        current_buffer=buffer,\n        app=app,\n        data=data_lines,\n    )\n\n    ps._handle_bracketed_paste(cast(KeyPressEvent, event))\n\n    assert buffer.inserted == [\"[Pasted text #1 +15 lines]\"]\n    assert app.invalidated is True\n    resolved_text = \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    user_input = ps._build_user_input(buffer.inserted[0])\n    assert user_input.resolved_command == resolved_text\n\n\ndef test_handle_bracketed_paste_keeps_normalized_text_in_shell_mode() -> None:\n    ps = _make_prompt_session(PromptMode.SHELL)\n    buffer = _DummyBuffer()\n    app = _DummyApp()\n    event = SimpleNamespace(\n        current_buffer=buffer,\n        app=app,\n        data=\"line1\\r\\nline2\\r\\nline3\",\n    )\n\n    ps._handle_bracketed_paste(cast(KeyPressEvent, event))\n\n    assert buffer.inserted == [\"line1\\nline2\\nline3\"]\n    assert app.invalidated is True\n"
  },
  {
    "path": "tests/ui_and_conv/test_prompt_external_editor.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport importlib\nfrom types import SimpleNamespace\nfrom typing import cast\n\nimport pytest\nfrom prompt_toolkit.key_binding import KeyPressEvent\n\nfrom kimi_cli.ui.shell import prompt as shell_prompt\nfrom kimi_cli.ui.shell.placeholders import PromptPlaceholderManager\n\n\nclass _DummyApp:\n    def __init__(self) -> None:\n        self.tasks: list[asyncio.Task[None]] = []\n\n    def create_background_task(self, coro):\n        task = asyncio.create_task(coro)\n        self.tasks.append(task)\n        return task\n\n\nclass _DummyBuffer:\n    def __init__(self, text: str) -> None:\n        self.text = text\n        self.document = None\n\n\nasync def test_open_in_external_editor_uses_provider_value(monkeypatch) -> None:\n    configured_editor = \"vim -u NONE\"\n    prompt_session = object.__new__(shell_prompt.CustomPromptSession)\n    prompt_session._editor_command_provider = lambda: configured_editor\n\n    app = _DummyApp()\n    buff = _DummyBuffer(\"hello world\")\n    event = SimpleNamespace(current_buffer=buff, app=app)\n\n    get_editor_calls: list[str] = []\n    edit_calls: list[tuple[str, str]] = []\n\n    def fake_get_editor_command(configured: str | None = None):\n        get_editor_calls.append(configured or \"\")\n        return [\"vim\"]\n\n    def fake_edit_text_in_editor(text: str, configured: str | None = None):\n        edit_calls.append((text, configured or \"\"))\n        return \"edited content\"\n\n    async def fake_run_in_terminal(func, in_executor=True):\n        assert in_executor is True\n        return func()\n\n    monkeypatch.setattr(\"kimi_cli.utils.editor.get_editor_command\", fake_get_editor_command)\n    monkeypatch.setattr(\"kimi_cli.utils.editor.edit_text_in_editor\", fake_edit_text_in_editor)\n    run_in_terminal_module = importlib.import_module(\"prompt_toolkit.application.run_in_terminal\")\n    monkeypatch.setattr(run_in_terminal_module, \"run_in_terminal\", fake_run_in_terminal)\n\n    prompt_session._open_in_external_editor(cast(KeyPressEvent, event))\n    assert get_editor_calls == [configured_editor]\n    assert len(app.tasks) == 1\n\n    await asyncio.gather(*app.tasks)\n\n    assert edit_calls == [(\"hello world\", configured_editor)]\n    assert buff.document is not None\n    assert buff.document.text == \"edited content\"\n    assert buff.document.cursor_position == len(\"edited content\")\n\n\n@pytest.mark.asyncio\nasync def test_open_in_external_editor_expands_and_refolds_text_placeholders(monkeypatch) -> None:\n    configured_editor = \"vim -u NONE\"\n    prompt_session = object.__new__(shell_prompt.CustomPromptSession)\n    prompt_session._editor_command_provider = lambda: configured_editor\n    prompt_session._placeholder_manager = PromptPlaceholderManager()\n    prompt_session._attachment_cache = prompt_session._placeholder_manager.attachment_cache\n\n    pasted_text = \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    token = prompt_session._placeholder_manager.maybe_placeholderize_pasted_text(pasted_text)\n\n    app = _DummyApp()\n    buff = _DummyBuffer(f\"before {token} after\")\n    event = SimpleNamespace(current_buffer=buff, app=app)\n\n    edit_calls: list[tuple[str, str]] = []\n\n    monkeypatch.setattr(\"kimi_cli.utils.editor.get_editor_command\", lambda configured=None: [\"vim\"])\n\n    def fake_edit_text_in_editor(text: str, configured: str | None = None):\n        edit_calls.append((text, configured or \"\"))\n        return f\"before {pasted_text} after\\nnotes\"\n\n    async def fake_run_in_terminal(func, in_executor=True):\n        assert in_executor is True\n        return func()\n\n    monkeypatch.setattr(\"kimi_cli.utils.editor.edit_text_in_editor\", fake_edit_text_in_editor)\n    run_in_terminal_module = importlib.import_module(\"prompt_toolkit.application.run_in_terminal\")\n    monkeypatch.setattr(run_in_terminal_module, \"run_in_terminal\", fake_run_in_terminal)\n\n    prompt_session._open_in_external_editor(cast(KeyPressEvent, event))\n    assert len(app.tasks) == 1\n\n    await asyncio.gather(*app.tasks)\n\n    assert edit_calls == [(f\"before {pasted_text} after\", configured_editor)]\n    assert buff.document is not None\n    assert buff.document.text == f\"before {token} after\\nnotes\"\n    assert buff.document.cursor_position == len(buff.document.text)\n\n\n@pytest.mark.asyncio\nasync def test_open_in_external_editor_leaves_moved_text_expanded_when_refold_is_ambiguous(\n    monkeypatch,\n) -> None:\n    configured_editor = \"vim -u NONE\"\n    prompt_session = object.__new__(shell_prompt.CustomPromptSession)\n    prompt_session._editor_command_provider = lambda: configured_editor\n    prompt_session._placeholder_manager = PromptPlaceholderManager()\n    prompt_session._attachment_cache = prompt_session._placeholder_manager.attachment_cache\n\n    pasted_text = \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    token = prompt_session._placeholder_manager.maybe_placeholderize_pasted_text(pasted_text)\n\n    app = _DummyApp()\n    buff = _DummyBuffer(f\"{pasted_text}\\n---\\n{token}\")\n    event = SimpleNamespace(current_buffer=buff, app=app)\n\n    monkeypatch.setattr(\"kimi_cli.utils.editor.get_editor_command\", lambda configured=None: [\"vim\"])\n\n    def fake_edit_text_in_editor(text: str, configured: str | None = None):\n        return f\"{pasted_text}\\n{pasted_text}\\n---\\n\"\n\n    async def fake_run_in_terminal(func, in_executor=True):\n        assert in_executor is True\n        return func()\n\n    monkeypatch.setattr(\"kimi_cli.utils.editor.edit_text_in_editor\", fake_edit_text_in_editor)\n    run_in_terminal_module = importlib.import_module(\"prompt_toolkit.application.run_in_terminal\")\n    monkeypatch.setattr(run_in_terminal_module, \"run_in_terminal\", fake_run_in_terminal)\n\n    prompt_session._open_in_external_editor(cast(KeyPressEvent, event))\n    assert len(app.tasks) == 1\n\n    await asyncio.gather(*app.tasks)\n\n    assert buff.document is not None\n    assert buff.document.text == f\"{pasted_text}\\n{pasted_text}\\n---\\n\"\n    assert buff.document.cursor_position == len(buff.document.text)\n\n\ndef test_open_in_external_editor_toast_when_no_editor(monkeypatch) -> None:\n    configured_editor = \"non-existent-editor\"\n    prompt_session = object.__new__(shell_prompt.CustomPromptSession)\n    prompt_session._editor_command_provider = lambda: configured_editor\n\n    app = _DummyApp()\n    buff = _DummyBuffer(\"hello world\")\n    event = SimpleNamespace(current_buffer=buff, app=app)\n\n    toast_calls: list[str] = []\n\n    def fake_toast(message: str, *_, **__):\n        toast_calls.append(message)\n\n    monkeypatch.setattr(\"kimi_cli.utils.editor.get_editor_command\", lambda configured=None: None)\n    monkeypatch.setattr(shell_prompt, \"toast\", fake_toast)\n\n    prompt_session._open_in_external_editor(cast(KeyPressEvent, event))\n\n    assert toast_calls == [\"No editor found. Set $VISUAL/$EDITOR or run /editor.\"]\n    assert app.tasks == []\n    assert buff.document is None\n"
  },
  {
    "path": "tests/ui_and_conv/test_prompt_history.py",
    "content": "from __future__ import annotations\n\nimport json\n\nfrom PIL import Image\n\nfrom kimi_cli.ui.shell import prompt as shell_prompt\nfrom kimi_cli.ui.shell.placeholders import AttachmentCache, PromptPlaceholderManager\n\n\ndef _make_prompt_session(\n    tmp_path, manager: PromptPlaceholderManager\n) -> shell_prompt.CustomPromptSession:\n    prompt_session = object.__new__(shell_prompt.CustomPromptSession)\n    prompt_session._history_file = tmp_path / \"history.jsonl\"\n    prompt_session._last_history_content = None\n    prompt_session._placeholder_manager = manager\n    prompt_session._attachment_cache = manager.attachment_cache\n    return prompt_session\n\n\ndef _read_history_lines(path) -> list[dict[str, str]]:\n    return [json.loads(line) for line in path.read_text(encoding=\"utf-8\").splitlines()]\n\n\ndef test_append_history_entry_expands_text_placeholders_but_preserves_images(tmp_path) -> None:\n    manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path / \"cache\"))\n    pasted_text = \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    text_token = manager.maybe_placeholderize_pasted_text(pasted_text)\n    image = Image.new(\"RGB\", (4, 4), color=(10, 20, 30))\n    image_token = manager.create_image_placeholder(image)\n\n    assert image_token is not None\n\n    prompt_session = _make_prompt_session(tmp_path, manager)\n    prompt_session._append_history_entry(f\"before {text_token} {image_token} after\")\n\n    assert _read_history_lines(prompt_session._history_file) == [\n        {\"content\": f\"before {pasted_text} {image_token} after\"}\n    ]\n\n\ndef test_append_history_entry_deduplicates_consecutive_tokens_with_same_expanded_text(\n    tmp_path,\n) -> None:\n    manager = PromptPlaceholderManager()\n    prompt_session = _make_prompt_session(tmp_path, manager)\n    token_one = manager.maybe_placeholderize_pasted_text(\"alpha\\nbeta\\ngamma\")\n    token_two = manager.maybe_placeholderize_pasted_text(\"alpha\\nbeta\\ngamma\")\n\n    prompt_session._append_history_entry(token_one)\n    prompt_session._append_history_entry(token_two)\n\n    assert _read_history_lines(prompt_session._history_file) == [{\"content\": \"alpha\\nbeta\\ngamma\"}]\n\n\ndef test_append_history_entry_writes_sanitized_surrogate_text(tmp_path) -> None:\n    manager = PromptPlaceholderManager()\n    prompt_session = _make_prompt_session(tmp_path, manager)\n    token = manager.maybe_placeholderize_pasted_text(\"A\" * 1000 + \"\\ud83d\")\n\n    prompt_session._append_history_entry(token)\n\n    lines = _read_history_lines(prompt_session._history_file)\n    assert len(lines) == 1\n    assert \"\\ud83d\" not in lines[0][\"content\"]\n    assert \"\\ufffd\" in lines[0][\"content\"]\n    assert lines[0][\"content\"].startswith(\"A\" * 1000)\n"
  },
  {
    "path": "tests/ui_and_conv/test_prompt_placeholders.py",
    "content": "from __future__ import annotations\n\nfrom PIL import Image\n\nfrom kimi_cli.ui.shell.placeholders import (\n    AttachmentCache,\n    PromptPlaceholderManager,\n    should_placeholderize_pasted_text,\n)\nfrom kimi_cli.wire.types import ImageURLPart, TextPart\n\n\ndef test_placeholder_manager_serializes_text_tokens_for_history(tmp_path) -> None:\n    manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path))\n    text_token = manager.maybe_placeholderize_pasted_text(\"alpha\\nbeta\\ngamma\")\n    image = Image.new(\"RGB\", (4, 4), color=(10, 20, 30))\n    image_token = manager.create_image_placeholder(image)\n\n    assert image_token is not None\n\n    history_text = manager.serialize_for_history(f\"before {text_token} {image_token} after\")\n\n    assert history_text == f\"before alpha\\nbeta\\ngamma {image_token} after\"\n\n\ndef test_placeholder_manager_refolds_editor_text_for_known_text_tokens() -> None:\n    manager = PromptPlaceholderManager()\n    text_token = manager.maybe_placeholderize_pasted_text(\"alpha\\nbeta\\ngamma\")\n    original_command = f\"before {text_token} after\"\n\n    refolded = manager.refold_after_editor(\n        \"before alpha\\nbeta\\ngamma after\\nnotes\", original_command\n    )\n\n    assert refolded == f\"before {text_token} after\\nnotes\"\n\n\ndef test_placeholder_manager_refolds_original_placeholder_span_not_first_duplicate() -> None:\n    manager = PromptPlaceholderManager()\n    pasted_text = \"alpha\\nbeta\\ngamma\"\n    text_token = manager.maybe_placeholderize_pasted_text(pasted_text)\n    original_command = f\"{pasted_text}\\n---\\n{text_token}\"\n\n    refolded = manager.refold_after_editor(f\"{pasted_text}\\n---\\n{pasted_text}\", original_command)\n\n    assert refolded == f\"{pasted_text}\\n---\\n{text_token}\"\n\n\ndef test_placeholder_manager_does_not_refold_moved_pasted_text() -> None:\n    manager = PromptPlaceholderManager()\n    pasted_text = \"alpha\\nbeta\\ngamma\"\n    text_token = manager.maybe_placeholderize_pasted_text(pasted_text)\n    original_command = f\"{pasted_text}\\n---\\n{text_token}\"\n    edited_text = f\"{pasted_text}\\n{pasted_text}\\n---\\n\"\n\n    refolded = manager.refold_after_editor(edited_text, original_command)\n\n    assert refolded == edited_text\n\n\ndef test_placeholder_manager_refolds_multiple_unedited_placeholders() -> None:\n    manager = PromptPlaceholderManager()\n    first = \"alpha\\nbeta\\ngamma\"\n    second = \"one\\ntwo\\nthree\"\n    first_token = manager.maybe_placeholderize_pasted_text(first)\n    second_token = manager.maybe_placeholderize_pasted_text(second)\n    original_command = f\"{first_token}\\n---\\n{second_token}\"\n\n    refolded = manager.refold_after_editor(f\"{first}\\n---\\n{second}\", original_command)\n\n    assert refolded == original_command\n\n\ndef test_placeholder_manager_only_refolds_unedited_placeholder_when_multiple_exist() -> None:\n    manager = PromptPlaceholderManager()\n    first = \"alpha\\nbeta\\ngamma\"\n    second = \"one\\ntwo\\nthree\"\n    first_token = manager.maybe_placeholderize_pasted_text(first)\n    second_token = manager.maybe_placeholderize_pasted_text(second)\n    original_command = f\"{first_token}\\n---\\n{second_token}\"\n\n    refolded = manager.refold_after_editor(\n        f\"{first}\\n---\\none\\ntwo changed\\nthree\", original_command\n    )\n\n    assert refolded == f\"{first_token}\\n---\\none\\ntwo changed\\nthree\"\n\n\ndef test_placeholder_manager_leaves_unknown_text_token_literal() -> None:\n    manager = PromptPlaceholderManager()\n\n    resolved = manager.resolve_command(\"[Pasted text #999 +3 lines]\")\n\n    assert resolved.resolved_text == \"[Pasted text #999 +3 lines]\"\n    assert resolved.content == [TextPart(text=\"[Pasted text #999 +3 lines]\")]\n\n\ndef test_placeholder_manager_resolves_mixed_text_and_image_tokens(tmp_path) -> None:\n    manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path))\n    pasted_text = \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    text_token = manager.maybe_placeholderize_pasted_text(pasted_text)\n    image = Image.new(\"RGB\", (4, 4), color=(10, 20, 30))\n    image_token = manager.create_image_placeholder(image)\n\n    assert image_token is not None\n\n    resolved = manager.resolve_command(f\"look {text_token} {image_token}\")\n\n    assert resolved.resolved_text == f\"look {pasted_text} {image_token}\"\n    assert resolved.content[0] == TextPart(text=\"look \")\n    assert resolved.content[1] == TextPart(text=pasted_text)\n    assert resolved.content[2] == TextPart(text=\" \")\n    assert resolved.content[3].type == \"text\"\n    assert resolved.content[4].type == \"image_url\"\n    assert isinstance(resolved.content[4], ImageURLPart)\n    assert resolved.content[5] == TextPart(text=\"</image>\")\n\n\ndef test_placeholder_manager_expands_text_but_not_image_for_editor(tmp_path) -> None:\n    manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path))\n    text_token = manager.maybe_placeholderize_pasted_text(\"alpha\\nbeta\\ngamma\")\n    image = Image.new(\"RGB\", (4, 4), color=(10, 20, 30))\n    image_token = manager.create_image_placeholder(image)\n\n    assert image_token is not None\n\n    editor_text = manager.expand_for_editor(f\"before {text_token} {image_token} after\")\n\n    assert editor_text == f\"before alpha\\nbeta\\ngamma {image_token} after\"\n\n\ndef test_placeholder_manager_leaves_unknown_image_placeholder_literal() -> None:\n    manager = PromptPlaceholderManager()\n\n    resolved = manager.resolve_command(\"[image:missing.png,10x10]\")\n\n    assert resolved.resolved_text == \"[image:missing.png,10x10]\"\n    assert resolved.content == [TextPart(text=\"[image:missing.png,10x10]\")]\n\n\ndef test_placeholder_manager_sanitizes_surrogates_in_pasted_text() -> None:\n    manager = PromptPlaceholderManager()\n    # Lone surrogate \\ud83d (half of an emoji pair) must not survive into the entry.\n    text_with_surrogate = \"A\" * 1000 + \"\\ud83d\"\n    token = manager.maybe_placeholderize_pasted_text(text_with_surrogate)\n\n    resolved = manager.resolve_command(token)\n\n    # The surrogate must not survive; it is replaced with U+FFFD characters.\n    assert \"\\ud83d\" not in resolved.resolved_text\n    assert resolved.resolved_text.startswith(\"A\" * 1000)\n    assert \"\\ufffd\" in resolved.resolved_text\n\n    # Serialization for history must not raise.\n    history = manager.serialize_for_history(token)\n    assert \"\\ud83d\" not in history\n\n\ndef test_placeholderize_thresholds_cover_char_and_line_boundaries() -> None:\n    assert should_placeholderize_pasted_text(\"A\" * 999) is False\n    assert should_placeholderize_pasted_text(\"A\" * 1000) is True\n    assert should_placeholderize_pasted_text(\"line1\\nline2\") is False\n    assert should_placeholderize_pasted_text(\"\\n\".join([f\"line{i}\" for i in range(1, 15)])) is False\n    assert should_placeholderize_pasted_text(\"\\n\".join([f\"line{i}\" for i in range(1, 16)])) is True\n\n\ndef test_placeholder_manager_normalizes_crlf_before_threshold_and_resolution() -> None:\n    manager = PromptPlaceholderManager()\n    lines = \"\\r\\n\".join([f\"line{i}\" for i in range(1, 16)])\n    token = manager.maybe_placeholderize_pasted_text(lines)\n\n    assert token == \"[Pasted text #1 +15 lines]\"\n\n    resolved = manager.resolve_command(token)\n    assert resolved.resolved_text == \"\\n\".join([f\"line{i}\" for i in range(1, 16)])\n\n\ndef test_attachment_cache_loads_legacy_root(tmp_path) -> None:\n    legacy_root = tmp_path / \"legacy\"\n    legacy_image_dir = legacy_root / \"images\"\n    legacy_image_dir.mkdir(parents=True)\n    attachment_id = \"legacy.png\"\n    payload = b\"\\x89PNG\\r\\n\\x1a\\nlegacy\"\n    (legacy_image_dir / attachment_id).write_bytes(payload)\n\n    cache = AttachmentCache(root=tmp_path / \"new-root\", legacy_roots=(legacy_root,))\n    loaded = cache.load_bytes(\"image\", attachment_id)\n\n    assert loaded is not None\n    path, image_bytes = loaded\n    assert path == legacy_image_dir / attachment_id\n    assert image_bytes == payload\n"
  },
  {
    "path": "tests/ui_and_conv/test_prompt_tips.py",
    "content": "from __future__ import annotations\n\nimport os\nimport time\nfrom collections.abc import Callable\nfrom types import SimpleNamespace\nfrom typing import Any, cast\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom kimi_cli.soul import StatusSnapshot\nfrom kimi_cli.ui.shell import prompt as shell_prompt\nfrom kimi_cli.ui.shell.prompt import (\n    _GIT_STATUS_TTL,\n    PROMPT_SYMBOL,\n    CustomPromptSession,\n    PromptMode,\n    UserInput,\n    _build_toolbar_tips,\n    _display_width,\n    _format_git_badge,\n    _get_git_branch,\n    _get_git_status,\n    _git_branch_state,\n    _git_status_state,\n    _shorten_cwd,\n    _toast_queues,\n    _truncate_left,\n    _truncate_right,\n    toast,\n)\n\n# ── Shared helpers ─────────────────────────────────────────────────────────────\n\n\nclass _DummyRunningPrompt:\n    def render_running_prompt_body(self, columns: int) -> str:\n        return f\"live view ({columns})\"\n\n    def running_prompt_placeholder(self) -> None:\n        return None\n\n    def should_handle_running_prompt_key(self, key: str) -> bool:\n        return key == \"enter\"\n\n    def handle_running_prompt_key(self, key: str, event) -> None:\n        raise AssertionError(\"Should not be called in this test\")\n\n\ndef _make_toolbar_session(*, model_name: str | None = None, tips: list[str] | None = None) -> Any:\n    \"\"\"Build a minimal CustomPromptSession for toolbar rendering tests.\"\"\"\n    prompt_session = object.__new__(CustomPromptSession)\n    prompt_session._mode = PromptMode.AGENT\n    prompt_session._model_name = model_name\n    prompt_session._thinking = False\n    prompt_session._status_provider = lambda: StatusSnapshot(context_usage=0.0)\n    prompt_session._background_task_count_provider = None\n    prompt_session._tips = tips if tips is not None else []\n    prompt_session._tip_rotation_index = 0\n    prompt_session._last_tip_rotate_time = float(\"inf\")  # prevent time-based rotation\n    return prompt_session\n\n\ndef _render_toolbar_lines(\n    prompt_session: Any,\n    width: int,\n    monkeypatch: Any,\n    *,\n    git_branch: str | None = None,\n    git_status_result: tuple[bool, int, int] = (False, 0, 0),\n    cwd: str = \"~/proj\",\n    before_render: Callable[[], None] | None = None,\n) -> list[str]:\n    \"\"\"Patch the environment, optionally run setup, render the toolbar, return lines.\"\"\"\n\n    class _DummyOutput:\n        @staticmethod\n        def get_size() -> Any:\n            return SimpleNamespace(columns=width)\n\n    monkeypatch.setattr(\n        shell_prompt, \"get_app_or_none\", lambda: SimpleNamespace(output=_DummyOutput())\n    )\n    monkeypatch.setattr(shell_prompt, \"_get_git_branch\", lambda: git_branch)\n    monkeypatch.setattr(shell_prompt, \"_get_git_status\", lambda: git_status_result)\n    monkeypatch.setattr(shell_prompt, \"_shorten_cwd\", lambda _: cwd)\n    _toast_queues[\"left\"].clear()\n    _toast_queues[\"right\"].clear()\n    if before_render is not None:\n        before_render()\n\n    rendered = prompt_session._render_bottom_toolbar()\n    plain = \"\".join(fragment[1] for fragment in rendered)\n    return cast(list[str], plain.split(\"\\n\"))\n\n\n# ── _build_toolbar_tips ────────────────────────────────────────────────────────\n\n\ndef test_build_toolbar_tips_without_clipboard() -> None:\n    assert _build_toolbar_tips(clipboard_available=False) == [\n        \"ctrl-x: toggle mode\",\n        \"shift-tab: plan mode\",\n        \"ctrl-o: editor\",\n        \"ctrl-j: newline\",\n        \"@: mention files\",\n    ]\n\n\ndef test_build_toolbar_tips_with_clipboard() -> None:\n    assert _build_toolbar_tips(clipboard_available=True) == [\n        \"ctrl-x: toggle mode\",\n        \"shift-tab: plan mode\",\n        \"ctrl-o: editor\",\n        \"ctrl-j: newline\",\n        \"ctrl-v: paste clipboard\",\n        \"@: mention files\",\n    ]\n\n\n# ── _display_width ─────────────────────────────────────────────────────────────\n\n\ndef test_display_width_empty() -> None:\n    assert _display_width(\"\") == 0\n\n\ndef test_display_width_ascii() -> None:\n    assert _display_width(\"hello\") == 5\n\n\ndef test_display_width_cjk_wide_chars() -> None:\n    # Each CJK character occupies 2 terminal columns.\n    assert _display_width(\"中文\") == 4\n\n\ndef test_display_width_mixed_ascii_and_cjk() -> None:\n    assert _display_width(\"a中b\") == 4  # 1 + 2 + 1\n\n\n# ── _truncate_left / _truncate_right ──────────────────────────────────────────\n\n\ndef test_truncate_left_within_limit_unchanged() -> None:\n    assert _truncate_left(\"hello\", 10) == \"hello\"\n\n\ndef test_truncate_left_ascii_exceeds_limit() -> None:\n    # \"abcde\" width=5, max=4 → budget=3 → keep last 3 chars → \"…cde\"\n    result = _truncate_left(\"abcde\", 4)\n    assert result == \"…cde\"\n    assert _display_width(result) == 4\n\n\ndef test_truncate_left_cjk_exceeds_limit() -> None:\n    # \"中文中文\" = 8 cols, max=5 → budget=4 → keep last 2 wide chars → \"…中文\"\n    result = _truncate_left(\"中文中文\", 5)\n    assert result == \"…中文\"\n    assert _display_width(result) == 5\n\n\ndef test_truncate_right_within_limit_unchanged() -> None:\n    assert _truncate_right(\"hello\", 10) == \"hello\"\n\n\ndef test_truncate_right_ascii_exceeds_limit() -> None:\n    # \"abcde\" width=5, max=4 → budget=3 → keep first 3 chars → \"abc…\"\n    result = _truncate_right(\"abcde\", 4)\n    assert result == \"abc…\"\n    assert _display_width(result) == 4\n\n\ndef test_truncate_right_cjk_exceeds_limit() -> None:\n    # \"中文中文\" = 8 cols, max=5 → budget=4 → keep first 2 wide chars → \"中文…\"\n    result = _truncate_right(\"中文中文\", 5)\n    assert result == \"中文…\"\n    assert _display_width(result) == 5\n\n\ndef test_truncate_right_zero_max_cols_returns_empty() -> None:\n    # Contract: output width must be ≤ max_cols; when max_cols=0, must return \"\"\n    assert _truncate_right(\"hello\", 0) == \"\"\n    assert _truncate_right(\"中文\", 0) == \"\"\n\n\ndef test_truncate_left_zero_max_cols_returns_empty() -> None:\n    # Contract: output width must be ≤ max_cols; when max_cols=0, must return \"\"\n    assert _truncate_left(\"hello\", 0) == \"\"\n    assert _truncate_left(\"中文\", 0) == \"\"\n\n\n# ── _shorten_cwd ──────────────────────────────────────────────────────────────\n\n\ndef test_shorten_cwd_home_itself() -> None:\n    home = os.path.expanduser(\"~\")\n    assert _shorten_cwd(home) == \"~\"\n\n\ndef test_shorten_cwd_subdirectory() -> None:\n    home = os.path.expanduser(\"~\")\n    subdir = os.path.join(home, \"projects\", \"myapp\")\n    assert _shorten_cwd(subdir) == \"~/projects/myapp\"\n\n\ndef test_shorten_cwd_unrelated_path() -> None:\n    # A path outside of home is returned unchanged.\n    assert _shorten_cwd(\"/etc/hosts\") == \"/etc/hosts\"\n\n\n# ── _format_git_badge ─────────────────────────────────────────────────────────\n\n\ndef test_format_git_badge_clean() -> None:\n    assert _format_git_badge(\"main\", False, 0, 0) == \"main\"\n\n\ndef test_format_git_badge_dirty_only() -> None:\n    assert _format_git_badge(\"main\", True, 0, 0) == \"main [±]\"\n\n\ndef test_format_git_badge_ahead_only() -> None:\n    assert _format_git_badge(\"main\", False, 3, 0) == \"main [↑3]\"\n\n\ndef test_format_git_badge_behind_only() -> None:\n    assert _format_git_badge(\"main\", False, 0, 1) == \"main [↓1]\"\n\n\ndef test_format_git_badge_all_three() -> None:\n    assert _format_git_badge(\"main\", True, 3, 1) == \"main [± ↑3↓1]\"\n\n\n# ── Tip rotation logic ────────────────────────────────────────────────────────\n\n\ndef test_rotating_tips_empty_returns_none() -> None:\n    session = _make_toolbar_session(tips=[])\n    assert session._get_two_rotating_tips() is None\n    assert session._get_one_rotating_tip() is None\n\n\ndef test_rotating_tips_single_tip_always_returned() -> None:\n    session = _make_toolbar_session(tips=[\"ctrl-x: toggle mode\"])\n    assert session._get_two_rotating_tips() == \"ctrl-x: toggle mode\"\n    assert session._get_one_rotating_tip() == \"ctrl-x: toggle mode\"\n\n\n@pytest.mark.parametrize(\n    \"index,expected_two,expected_one\",\n    [\n        (0, \"tip-a | tip-b\", \"tip-a\"),\n        (1, \"tip-b | tip-c\", \"tip-b\"),\n        (2, \"tip-c | tip-a\", \"tip-c\"),  # pair wraps around end of list\n    ],\n)\ndef test_rotating_tips_rotation_and_wrap(index: int, expected_two: str, expected_one: str) -> None:\n    session = _make_toolbar_session(tips=[\"tip-a\", \"tip-b\", \"tip-c\"])\n    session._tip_rotation_index = index\n    assert session._get_two_rotating_tips() == expected_two\n    assert session._get_one_rotating_tip() == expected_one\n\n\n# ── Toolbar overflow invariants ───────────────────────────────────────────────\n\n\n@pytest.mark.parametrize(\"width\", [40, 60, 80])\ndef test_bottom_toolbar_never_overflows(width: int, monkeypatch: Any) -> None:\n    # Toolbar must always render exactly 3 lines (separator + info + toast/context).\n    # Neither content line may exceed the terminal width, even with a tip far longer\n    # than the terminal width or on narrow terminals where degradation kicks in.\n    prompt_session = _make_toolbar_session(tips=[\"x\" * (width * 2)])\n\n    lines = _render_toolbar_lines(prompt_session, width, monkeypatch)\n\n    assert len(lines) == 3, f\"expected 3 lines, got {len(lines)}: {lines!r}\"\n    assert _display_width(lines[1]) <= width, (\n        f\"info line overflows at width={width}: {_display_width(lines[1])}\"\n    )\n    assert _display_width(lines[2]) <= width, (\n        f\"toast/context line overflows at width={width}: {_display_width(lines[2])}\"\n    )\n\n\n@pytest.mark.parametrize(\"width\", [40, 50, 60])\ndef test_bottom_toolbar_narrow_terminal_with_full_decoration(width: int, monkeypatch: Any) -> None:\n    # Regression: on narrow terminals, combining a long CWD path, a git branch badge\n    # (dirty + ahead + behind), an active bg bash task, and a model name must never\n    # push line 1 past the terminal width. The toolbar must degrade gracefully.\n    prompt_session = _make_toolbar_session(\n        model_name=\"kimi-latest\",\n        tips=[\"ctrl-x: toggle mode\"],\n    )\n    prompt_session._background_task_count_provider = lambda: 2\n\n    lines = _render_toolbar_lines(\n        prompt_session,\n        width,\n        monkeypatch,\n        git_branch=\"feature/very-long-branch-name\",\n        git_status_result=(True, 3, 1),\n        cwd=\"~/src/project/subdir\",\n    )\n\n    assert len(lines) == 3, f\"expected 3 lines at width={width}, got {len(lines)}\"\n    assert _display_width(lines[1]) <= width, (\n        f\"info line overflows at width={width}: {_display_width(lines[1])}\"\n    )\n    assert _display_width(lines[2]) <= width, (\n        f\"toast/context line overflows at width={width}: {_display_width(lines[2])}\"\n    )\n\n\ndef test_mode_shows_full_with_model_name_on_wide_terminal(monkeypatch: Any) -> None:\n    \"\"\"On a wide terminal the full mode string (with model name and thinking dot) is shown.\"\"\"\n    session = _make_toolbar_session(model_name=\"fast-model\")\n    session._thinking = False\n    lines = _render_toolbar_lines(session, 80, monkeypatch)\n    assert \"fast-model\" in lines[1], f\"model name missing on wide terminal: {lines[1]!r}\"\n    assert \"○\" in lines[1], f\"thinking dot missing on wide terminal: {lines[1]!r}\"\n\n\ndef test_mode_drops_model_name_on_narrow_terminal(monkeypatch: Any) -> None:\n    \"\"\"On a terminal too narrow for the full mode string, model name is dropped but\n    the thinking dot is still shown.\"\"\"\n    # \"agent (a-very-long-model-name-that-is-40-chars ○)\" is ~50 cols;\n    # a 30-col terminal forces mid-level degradation.\n    long_model = \"a-very-long-model-name-that-is-40-chars\"\n    session = _make_toolbar_session(model_name=long_model)\n    session._thinking = True\n    lines = _render_toolbar_lines(session, 30, monkeypatch)\n    assert long_model not in lines[1], (\n        f\"model name should be dropped on 30-col terminal: {lines[1]!r}\"\n    )\n    assert \"●\" in lines[1], f\"thinking dot should still appear at mid level: {lines[1]!r}\"\n    assert _display_width(lines[1]) <= 30\n\n\ndef test_mode_drops_model_name_and_dot_on_very_narrow_terminal(monkeypatch: Any) -> None:\n    \"\"\"On a terminal too narrow even for 'agent ○', only the bare mode name is shown.\"\"\"\n    # Force remaining to be tiny by using a very short model name but very narrow width.\n    # \"agent ○\" = 8 cols; needs 10 cols with spacing. Use width=8 to force bare mode.\n    session = _make_toolbar_session(model_name=\"m\")\n    session._thinking = False\n    lines = _render_toolbar_lines(session, 8, monkeypatch)\n    assert \"m\" not in lines[1] or lines[1].startswith(\"agent\"), (\n        f\"bare mode expected on 8-col terminal: {lines[1]!r}\"\n    )\n    assert _display_width(lines[1]) <= 8\n\n\n# ── Line 2 structural correctness ─────────────────────────────────────────────\n\n\ndef test_toolbar_line2_context_appears_on_line2_not_line1(monkeypatch: Any) -> None:\n    prompt_session = _make_toolbar_session(tips=[])\n    lines = _render_toolbar_lines(prompt_session, 80, monkeypatch)\n\n    assert \"context: 0.0%\" in lines[2]\n    assert \"context: 0.0%\" not in lines[1]\n\n\ndef test_toolbar_line2_left_toast_appears_on_line2_not_line1(monkeypatch: Any) -> None:\n    prompt_session = _make_toolbar_session(tips=[])\n\n    lines = _render_toolbar_lines(\n        prompt_session,\n        80,\n        monkeypatch,\n        before_render=lambda: toast(\"mcp servers connected\", topic=\"mcp\", duration=10.0),\n    )\n\n    assert len(lines) == 3\n    assert \"mcp servers connected\" in lines[2]\n    assert \"mcp servers connected\" not in lines[1]\n\n\ndef test_toolbar_line2_long_left_toast_truncated_to_fit(monkeypatch: Any) -> None:\n    width = 60\n    prompt_session = _make_toolbar_session(tips=[])\n\n    lines = _render_toolbar_lines(\n        prompt_session,\n        width,\n        monkeypatch,\n        before_render=lambda: toast(\"x\" * 200, duration=10.0),\n    )\n\n    assert _display_width(lines[2]) <= width\n\n\ndef test_toolbar_line2_right_toast_replaces_context(monkeypatch: Any) -> None:\n    prompt_session = _make_toolbar_session(tips=[])\n\n    lines = _render_toolbar_lines(\n        prompt_session,\n        80,\n        monkeypatch,\n        before_render=lambda: toast(\"mcp connected\", topic=\"mcp\", duration=10.0, position=\"right\"),\n    )\n\n    assert \"mcp connected\" in lines[2]\n    assert \"context:\" not in lines[2]\n\n\n# ── Fix #4 regression: branch change invalidates in-flight status subprocess ──\n\n\ndef test_git_branch_change_terminates_in_flight_status_proc(monkeypatch: Any) -> None:\n    \"\"\"Regression: switching branches must discard any in-flight status subprocess\n    so stale results from the old branch are never applied to the new branch.\"\"\"\n    mock_branch_proc = MagicMock()\n    mock_branch_proc.poll.return_value = 0  # process completed\n    mock_branch_proc.communicate.return_value = (\"feature-branch\\n\", \"\")\n\n    mock_status_proc = MagicMock()\n\n    # Simulate: branch proc has a result ready; status proc is still in-flight.\n    monkeypatch.setattr(_git_branch_state, \"branch\", \"main\")\n    monkeypatch.setattr(_git_branch_state, \"proc\", mock_branch_proc)\n    monkeypatch.setattr(_git_branch_state, \"timestamp\", float(\"inf\"))  # TTL fresh, won't re-launch\n    monkeypatch.setattr(_git_status_state, \"proc\", mock_status_proc)\n    monkeypatch.setattr(_git_status_state, \"timestamp\", float(\"inf\"))  # TTL fresh\n\n    _get_git_branch()\n\n    mock_status_proc.terminate.assert_called_once()\n    assert _git_status_state.proc is None\n    assert _git_status_state.timestamp == 0.0\n    assert _git_branch_state.branch == \"feature-branch\"\n\n\ndef test_git_status_stuck_subprocess_terminated_after_ttl(monkeypatch: Any) -> None:\n    \"\"\"Regression: a subprocess that never exits (pipe buffer deadlock) must be\n    terminated after TTL to prevent the toolbar from being permanently frozen.\"\"\"\n    mock_proc = MagicMock()\n    mock_proc.poll.return_value = None  # subprocess never finishes (deadlocked)\n\n    spawn_time = time.monotonic() - _GIT_STATUS_TTL - 1.0  # spawned > TTL ago\n    monkeypatch.setattr(_git_status_state, \"proc\", mock_proc)\n    monkeypatch.setattr(_git_status_state, \"timestamp\", spawn_time)\n    monkeypatch.setattr(_git_status_state, \"dirty\", True)  # stale value preserved\n\n    result = _get_git_status()\n\n    # Must have been terminated\n    mock_proc.terminate.assert_called_once()\n    assert _git_status_state.proc is None\n    # timestamp reset to ~now so next spawn is delayed by one full TTL\n    assert time.monotonic() - _git_status_state.timestamp < 2.0\n    # Stale cached values are still returned (better than crashing)\n    assert result == (True, 0, 0)\n\n\ndef test_git_status_recent_subprocess_not_terminated(monkeypatch: Any) -> None:\n    \"\"\"A subprocess that is still within TTL must not be terminated prematurely.\"\"\"\n    mock_proc = MagicMock()\n    mock_proc.poll.return_value = None  # not finished yet but within TTL\n\n    monkeypatch.setattr(_git_status_state, \"proc\", mock_proc)\n    monkeypatch.setattr(_git_status_state, \"timestamp\", time.monotonic() - 1.0)  # only 1s old\n\n    _get_git_status()\n\n    mock_proc.terminate.assert_not_called()\n    assert _git_status_state.proc is mock_proc  # unchanged\n\n\ndef test_git_status_not_called_when_branch_is_none(monkeypatch: Any) -> None:\n    \"\"\"When not in a git repo (branch=None), _get_git_status must not be called.\n\n    Avoids spawning a subprocess that will immediately fail in non-git directories.\n    \"\"\"\n    status_call_count = 0\n\n    def _fake_status() -> tuple[bool, int, int]:\n        nonlocal status_call_count\n        status_call_count += 1\n        return (False, 0, 0)\n\n    class _DummyOutput:\n        @staticmethod\n        def get_size() -> Any:\n            return SimpleNamespace(columns=80)\n\n    monkeypatch.setattr(\n        shell_prompt, \"get_app_or_none\", lambda: SimpleNamespace(output=_DummyOutput())\n    )\n    monkeypatch.setattr(shell_prompt, \"_get_git_branch\", lambda: None)\n    monkeypatch.setattr(shell_prompt, \"_get_git_status\", _fake_status)\n    monkeypatch.setattr(shell_prompt, \"_shorten_cwd\", lambda _: \"~/proj\")\n    _toast_queues[\"left\"].clear()\n    _toast_queues[\"right\"].clear()\n\n    prompt_session = _make_toolbar_session()\n    prompt_session._render_bottom_toolbar()\n\n    assert status_call_count == 0, \"_get_git_status must not be called when branch is None\"\n\n\n# ── Prompt layout (separator, running/idle message) ───────────────────────────\n\n\ndef test_running_prompt_uses_shared_toolbar_and_separator_layout(monkeypatch: Any) -> None:\n    width = 72\n    prompt_session = object.__new__(CustomPromptSession)\n    prompt_session._mode = PromptMode.AGENT\n    prompt_session._model_name = None\n    prompt_session._running_prompt_delegate = _DummyRunningPrompt()\n    prompt_session._status_provider = lambda: StatusSnapshot(context_usage=0.0)\n    prompt_session._background_task_count_provider = None\n    prompt_session._thinking = False\n    prompt_session._tips = [\"tip\"]\n    prompt_session._tip_rotation_index = 0\n    prompt_session._last_tip_rotate_time = float(\"inf\")  # prevent time-based rotation\n\n    class _DummyOutput:\n        @staticmethod\n        def get_size() -> Any:\n            return SimpleNamespace(columns=width)\n\n    monkeypatch.setattr(\n        shell_prompt, \"get_app_or_none\", lambda: SimpleNamespace(output=_DummyOutput())\n    )\n    monkeypatch.setattr(shell_prompt, \"_get_git_branch\", lambda: None)\n    monkeypatch.setattr(shell_prompt, \"_get_git_status\", lambda: (False, 0, 0))\n    monkeypatch.setattr(shell_prompt, \"_shorten_cwd\", lambda _: \"~/proj\")\n\n    rendered_message = prompt_session._render_agent_prompt_message()\n    plain_message = \"\".join(fragment[1] for fragment in rendered_message)\n    assert plain_message.startswith(f\"live view ({width})\\n\\n\")\n    assert f\"\\n{'─' * width}\\n\" in plain_message\n    assert plain_message.endswith(f\"{PROMPT_SYMBOL} \")\n\n    _toast_queues[\"left\"].clear()\n    _toast_queues[\"right\"].clear()\n    rendered_toolbar = prompt_session._render_bottom_toolbar()\n    plain_toolbar = \"\".join(fragment[1] for fragment in rendered_toolbar)\n    assert \"tip\" in plain_toolbar\n    assert \"context: 0.0%\" in plain_toolbar\n\n\ndef test_idle_agent_prompt_uses_same_separator_layout(monkeypatch: Any) -> None:\n    width = 64\n    prompt_session = object.__new__(CustomPromptSession)\n    prompt_session._running_prompt_delegate = None\n    prompt_session._status_provider = lambda: StatusSnapshot(context_usage=0.0)\n    prompt_session._thinking = False\n\n    class _DummyOutput:\n        @staticmethod\n        def get_size() -> Any:\n            return SimpleNamespace(columns=width)\n\n    monkeypatch.setattr(\n        shell_prompt, \"get_app_or_none\", lambda: SimpleNamespace(output=_DummyOutput())\n    )\n\n    rendered_message = prompt_session._render_agent_prompt_message()\n    plain_message = \"\".join(fragment[1] for fragment in rendered_message)\n    assert plain_message.startswith(\"\\n\")\n    assert f\"\\n{'─' * width}\\n\" in plain_message\n    assert plain_message.endswith(f\"{PROMPT_SYMBOL} \")\n\n\n# ── Session mode / erase_when_done behavior ───────────────────────────────────\n\n\ndef test_apply_mode_syncs_erase_when_done_with_current_mode() -> None:\n    prompt_session = object.__new__(CustomPromptSession)\n    prompt_session._session = cast(\n        Any,\n        SimpleNamespace(\n            app=SimpleNamespace(erase_when_done=False),\n            default_buffer=SimpleNamespace(completer=None),\n        ),\n    )\n    prompt_session._agent_mode_completer = cast(Any, object())\n    prompt_session._shell_mode_completer = cast(Any, object())\n    prompt_session._mode = PromptMode.AGENT\n\n    prompt_session._apply_mode()\n\n    assert prompt_session._session.default_buffer.completer is prompt_session._agent_mode_completer\n    assert prompt_session._session.app.erase_when_done is True\n\n    prompt_session._mode = PromptMode.SHELL\n    prompt_session._apply_mode()\n\n    assert prompt_session._session.default_buffer.completer is prompt_session._shell_mode_completer\n    assert prompt_session._session.app.erase_when_done is False\n\n\ndef test_attach_running_prompt_enables_erase_when_done_and_detach_restores_state() -> None:\n    prompt_session = object.__new__(CustomPromptSession)\n    prompt_session._mode = PromptMode.SHELL\n    prompt_session._running_prompt_delegate = None\n    prompt_session._running_prompt_previous_mode = None\n    prompt_session._session = cast(Any, SimpleNamespace(app=SimpleNamespace(erase_when_done=False)))\n\n    delegate = _DummyRunningPrompt()\n    trace: list[tuple[str, object, object, object]] = []\n\n    def fake_apply_mode(event=None) -> None:\n        prompt_session._session.app.erase_when_done = prompt_session._mode == PromptMode.AGENT\n        trace.append(\n            (\n                \"apply\",\n                prompt_session._mode,\n                prompt_session._session.app.erase_when_done,\n                prompt_session._running_prompt_delegate,\n            )\n        )\n\n    def fake_invalidate() -> None:\n        trace.append(\n            (\n                \"invalidate\",\n                prompt_session._mode,\n                prompt_session._session.app.erase_when_done,\n                prompt_session._running_prompt_delegate,\n            )\n        )\n\n    async def fake_prompt_once(*, append_history: bool) -> UserInput:\n        trace.append(\n            (\n                \"prompt\",\n                append_history,\n                prompt_session._session.app.erase_when_done,\n                prompt_session._running_prompt_delegate,\n            )\n        )\n        return UserInput(mode=PromptMode.AGENT, command=\"hi\", resolved_command=\"hi\", content=[])\n\n    prompt_session._apply_mode = fake_apply_mode\n    prompt_session.invalidate = fake_invalidate\n\n    prompt_session.attach_running_prompt(delegate)\n\n    assert prompt_session._mode == PromptMode.AGENT\n    assert prompt_session._running_prompt_delegate is delegate\n    assert prompt_session._session.app.erase_when_done is True\n\n    prompt_session.detach_running_prompt(delegate)\n\n    assert prompt_session._mode == PromptMode.SHELL\n    assert prompt_session._running_prompt_delegate is None\n    assert prompt_session._session.app.erase_when_done is False\n    assert [entry[0] for entry in trace] == [\"apply\", \"invalidate\", \"apply\", \"invalidate\"]\n\n\n# ── Prompt async contract ─────────────────────────────────────────────────────\n\n\n@pytest.mark.asyncio\n@pytest.mark.parametrize(\"running_prompt\", [_DummyRunningPrompt(), None])\nasync def test_prompt_once_uses_prompt_delegate_placeholder_contract(running_prompt: Any) -> None:\n    prompt_session = object.__new__(CustomPromptSession)\n    prompt_session._running_prompt_delegate = running_prompt\n    prompt_session._tip_rotation_index = 0\n\n    captured: list[object | None] = []\n\n    class _DummySession:\n        async def prompt_async(self, **kwargs: Any) -> str:\n            captured.append(kwargs.get(\"placeholder\"))\n            return \"hello\"\n\n    prompt_session._session = cast(Any, _DummySession())\n    prompt_session._build_user_input = lambda command: UserInput(\n        mode=PromptMode.AGENT,\n        command=command,\n        resolved_command=command,\n        content=[],\n    )\n\n    result = await prompt_session._prompt_once(append_history=False)\n\n    assert result.command == \"hello\"\n    assert captured == [None]\n\n\n@pytest.mark.asyncio\nasync def test_prompt_next_skips_history_for_running_submission() -> None:\n    prompt_session = object.__new__(CustomPromptSession)\n    prompt_session._running_prompt_delegate = _DummyRunningPrompt()\n    prompt_session._tip_rotation_index = 0\n    prompt_session._append_history_entry = lambda text: (_ for _ in ()).throw(\n        AssertionError(\"running submissions must not append history\")\n    )\n    prompt_session._build_user_input = lambda command: UserInput(\n        mode=PromptMode.AGENT,\n        command=command,\n        resolved_command=command,\n        content=[],\n    )\n\n    class _DummySession:\n        async def prompt_async(self, **kwargs: Any) -> str:\n            return \"follow-up\"\n\n    prompt_session._session = cast(Any, _DummySession())\n\n    result = await prompt_session.prompt_next()\n\n    assert result.command == \"follow-up\"\n    assert prompt_session.last_submission_was_running is True\n"
  },
  {
    "path": "tests/ui_and_conv/test_question_panel.py",
    "content": "\"\"\"Tests for _QuestionRequestPanel state machine logic.\"\"\"\n\nfrom __future__ import annotations\n\nfrom io import StringIO\n\nfrom rich.console import Console\n\nfrom kimi_cli.ui.shell.visualize import _QuestionRequestPanel\nfrom kimi_cli.wire.types import QuestionItem, QuestionOption, QuestionRequest\n\n\ndef _render_to_str(panel: _QuestionRequestPanel) -> str:\n    \"\"\"Render the panel to a plain-text string via a Rich Console.\"\"\"\n    buf = StringIO()\n    console = Console(file=buf, force_terminal=False, width=120)\n    console.print(panel.render())\n    return buf.getvalue()\n\n\ndef _make_request(\n    questions: list[dict] | None = None,\n) -> QuestionRequest:\n    \"\"\"Helper to build a QuestionRequest from simplified dicts.\"\"\"\n    if questions is None:\n        questions = [\n            {\n                \"question\": \"Pick one?\",\n                \"options\": [(\"A\", \"desc A\"), (\"B\", \"desc B\"), (\"C\", \"desc C\")],\n                \"multi_select\": False,\n            }\n        ]\n    items = []\n    for q in questions:\n        items.append(\n            QuestionItem(\n                question=q[\"question\"],\n                header=q.get(\"header\", \"\"),\n                options=[QuestionOption(label=lab, description=d) for lab, d in q[\"options\"]],\n                multi_select=q.get(\"multi_select\", False),\n            )\n        )\n    return QuestionRequest(id=\"qr-test\", tool_call_id=\"tc-test\", questions=items)\n\n\ndef test_single_select_submit():\n    \"\"\"Default selection (index 0) should submit the first option.\"\"\"\n    request = _make_request()\n    panel = _QuestionRequestPanel(request)\n\n    # Default selected_index is 0, submit should complete all questions\n    all_done = panel.submit()\n    assert all_done is True\n    assert panel.get_answers() == {\"Pick one?\": \"A\"}\n\n\ndef test_single_select_navigate_and_submit():\n    \"\"\"Navigate down twice and submit should select the third option.\"\"\"\n    request = _make_request()\n    panel = _QuestionRequestPanel(request)\n\n    panel.move_down()\n    panel.move_down()\n    all_done = panel.submit()\n    assert all_done is True\n    assert panel.get_answers() == {\"Pick one?\": \"C\"}\n\n\ndef test_single_select_other():\n    \"\"\"Selecting 'Other' should require custom text input.\"\"\"\n    request = _make_request()\n    panel = _QuestionRequestPanel(request)\n\n    # Move to Other (last option = index 3: A, B, C, Other)\n    panel.move_down()  # index 1 (B)\n    panel.move_down()  # index 2 (C)\n    panel.move_down()  # index 3 (Other)\n    assert panel.is_other_selected\n\n    # submit() returns False because Other needs text input\n    all_done = panel.submit()\n    assert all_done is False\n\n    # Provide custom text\n    all_done = panel.submit_other(\"custom text\")\n    assert all_done is True\n    assert panel.get_answers() == {\"Pick one?\": \"custom text\"}\n\n\ndef test_multi_select_toggle_and_submit():\n    \"\"\"Toggle options 0 and 2, submit should produce comma-joined labels.\"\"\"\n    request = _make_request(\n        [\n            {\n                \"question\": \"Select many?\",\n                \"options\": [(\"X\", \"\"), (\"Y\", \"\"), (\"Z\", \"\")],\n                \"multi_select\": True,\n            }\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Toggle option 0\n    panel.toggle_select()  # cursor at 0, toggle X\n    # Move to option 2 and toggle\n    panel.move_down()  # cursor at 1\n    panel.move_down()  # cursor at 2\n    panel.toggle_select()  # toggle Z\n\n    all_done = panel.submit()\n    assert all_done is True\n    assert panel.get_answers() == {\"Select many?\": \"X, Z\"}\n\n\ndef test_multi_select_with_other():\n    \"\"\"Multi-select with Other selected should require text, then combine.\"\"\"\n    request = _make_request(\n        [\n            {\n                \"question\": \"Features?\",\n                \"options\": [(\"Auth\", \"\"), (\"Cache\", \"\")],\n                \"multi_select\": True,\n            }\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Toggle Auth (index 0)\n    panel.toggle_select()\n\n    # Move to Other (index 2: Auth, Cache, Other) and toggle\n    panel.move_down()  # index 1 (Cache)\n    panel.move_down()  # index 2 (Other)\n    panel.toggle_select()\n\n    # submit() returns False because Other is selected\n    all_done = panel.submit()\n    assert all_done is False\n\n    # Provide custom text\n    all_done = panel.submit_other(\"extra feature\")\n    assert all_done is True\n    assert panel.get_answers() == {\"Features?\": \"Auth, extra feature\"}\n\n\ndef test_multi_question_advance():\n    \"\"\"Multi-question panel should advance through questions.\"\"\"\n    request = _make_request(\n        [\n            {\n                \"question\": \"Q1?\",\n                \"options\": [(\"A1\", \"\"), (\"B1\", \"\")],\n            },\n            {\n                \"question\": \"Q2?\",\n                \"options\": [(\"A2\", \"\"), (\"B2\", \"\")],\n            },\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Submit first question (default selection = A1)\n    all_done = panel.submit()\n    assert all_done is False  # still have Q2\n\n    # Navigate to second option for Q2\n    panel.move_down()\n    all_done = panel.submit()\n    assert all_done is True\n\n    answers = panel.get_answers()\n    assert answers == {\"Q1?\": \"A1\", \"Q2?\": \"B2\"}\n\n\ndef test_multi_select_other_cursor_not_on_other():\n    \"\"\"When Other is checked but cursor is elsewhere, should_prompt_other_input() should still return True.\"\"\"\n    request = _make_request(\n        [\n            {\n                \"question\": \"Features?\",\n                \"options\": [(\"Auth\", \"\"), (\"Cache\", \"\")],\n                \"multi_select\": True,\n            }\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Toggle Auth (index 0)\n    panel.toggle_select()\n\n    # Move to Other (index 2) and toggle\n    panel.move_down()  # index 1 (Cache)\n    panel.move_down()  # index 2 (Other)\n    panel.toggle_select()\n\n    # Move cursor back to Auth (index 0) — cursor is NOT on Other\n    panel.move_up()  # index 1\n    panel.move_up()  # index 0\n    assert not panel.is_other_selected\n\n    # should_prompt_other_input() must still return True because Other is in _multi_selected\n    assert panel.should_prompt_other_input() is True\n\n    # submit() should return False (Other needs text input)\n    assert panel.submit() is False\n\n\ndef test_multi_select_empty_submit_blocked():\n    \"\"\"Submitting with no options selected in multi-select mode should be blocked.\"\"\"\n    request = _make_request(\n        [\n            {\n                \"question\": \"Select many?\",\n                \"options\": [(\"X\", \"\"), (\"Y\", \"\"), (\"Z\", \"\")],\n                \"multi_select\": True,\n            }\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Don't select anything, try to submit\n    all_done = panel.submit()\n    assert all_done is False\n\n    # Answers should still be empty (nothing was stored)\n    assert panel.get_answers() == {}\n\n\ndef test_wrap_around_navigation():\n    \"\"\"move_up at first option should wrap to the last option (Other).\"\"\"\n    request = _make_request()\n    panel = _QuestionRequestPanel(request)\n\n    # At index 0, move_up should wrap to last (Other at index 3)\n    panel.move_up()\n    assert panel.is_other_selected\n\n    # move_down from last should wrap to first (index 0)\n    panel.move_down()\n    assert not panel.is_other_selected\n    # Verify it's at index 0 by submitting\n    all_done = panel.submit()\n    assert all_done is True\n    assert panel.get_answers() == {\"Pick one?\": \"A\"}\n\n\n# ---------------------------------------------------------------------------\n# Tab navigation\n# ---------------------------------------------------------------------------\n\n\ndef _make_multi_question_request() -> QuestionRequest:\n    \"\"\"Helper: 3 questions with headers for tab navigation tests.\"\"\"\n    return _make_request(\n        [\n            {\n                \"question\": \"Q1?\",\n                \"header\": \"Food\",\n                \"options\": [(\"Rice\", \"\"), (\"Noodle\", \"\"), (\"Bread\", \"\")],\n            },\n            {\n                \"question\": \"Q2?\",\n                \"header\": \"Drink\",\n                \"options\": [(\"Tea\", \"\"), (\"Coffee\", \"\")],\n            },\n            {\n                \"question\": \"Q3?\",\n                \"header\": \"Dessert\",\n                \"options\": [(\"Cake\", \"\"), (\"IceCream\", \"\")],\n                \"multi_select\": True,\n            },\n        ]\n    )\n\n\ndef test_tab_navigation_no_wraparound():\n    \"\"\"prev_tab at first and next_tab at last should be no-ops.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    assert panel._current_question_index == 0\n    panel.prev_tab()  # already at first — should stay\n    assert panel._current_question_index == 0\n\n    panel.go_to(2)\n    assert panel._current_question_index == 2\n    panel.next_tab()  # already at last — should stay\n    assert panel._current_question_index == 2\n\n\ndef test_tab_navigation_preserves_cursor():\n    \"\"\"Switching tabs should save and restore cursor position.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    # Move cursor to option 2 on Q1\n    panel.move_down()  # index 1\n    panel.move_down()  # index 2\n    assert panel._selected_index == 2\n\n    # Switch to Q2\n    panel.next_tab()\n    assert panel._current_question_index == 1\n    assert panel._selected_index == 0  # Q2 starts at 0\n\n    # Move cursor on Q2\n    panel.move_down()  # index 1\n    assert panel._selected_index == 1\n\n    # Switch back to Q1 — cursor should be restored to 2\n    panel.prev_tab()\n    assert panel._current_question_index == 0\n    assert panel._selected_index == 2\n\n\ndef test_tab_navigation_preserves_multi_select():\n    \"\"\"Switching tabs should save and restore multi-select checkbox state.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    # Go to Q3 (multi-select)\n    panel.go_to(2)\n    assert panel.is_multi_select\n\n    # Toggle Cake and IceCream\n    panel.toggle_select()  # Cake\n    panel.move_down()\n    panel.toggle_select()  # IceCream\n    assert panel._multi_selected == {0, 1}\n\n    # Switch to Q1\n    panel.prev_tab()\n    assert panel._current_question_index == 1  # goes to Q2 (index 1), not Q1\n    panel.prev_tab()\n    assert panel._current_question_index == 0\n\n    # Switch back to Q3 — multi-select state should be restored\n    panel.go_to(2)\n    assert panel._multi_selected == {0, 1}\n\n\ndef test_go_to_same_index_is_noop():\n    \"\"\"go_to(current_index) should not overwrite saved state.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n    panel.move_down()\n    panel.go_to(0)  # same index — should be no-op\n    assert panel._selected_index == 1  # cursor unchanged\n\n\ndef test_go_to_out_of_bounds():\n    \"\"\"go_to with invalid index should be no-op.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n    panel.go_to(-1)\n    assert panel._current_question_index == 0\n    panel.go_to(99)\n    assert panel._current_question_index == 0\n\n\n# ---------------------------------------------------------------------------\n# Answer restoration after submission\n# ---------------------------------------------------------------------------\n\n\ndef test_submit_clears_saved_selections():\n    \"\"\"After submit(), stale draft should be cleared so answer is used for restoration.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    # Move cursor to Noodle (index 1) on Q1, then switch to Q2 (saves draft)\n    panel.move_down()  # index 1 (Noodle)\n    panel.next_tab()  # saves draft {selected_index: 1} for Q1\n\n    # Switch back to Q1, change to Bread (index 2), submit\n    panel.prev_tab()\n    assert panel._selected_index == 1  # restored from draft\n    panel.move_down()  # index 2 (Bread)\n    all_done = panel.submit()\n    assert all_done is False  # Q2 and Q3 still pending\n\n    # Verify the draft for Q1 is cleared\n    assert 0 not in panel._saved_selections\n\n    # Submit Q2 (default) and go to Q3\n    all_done = panel.submit()\n    assert all_done is False\n\n    # Now go back to Q1 — should show Bread (from answer), not Noodle (stale draft)\n    panel.go_to(0)\n    assert panel._selected_index == 2  # Bread is at index 2\n\n\ndef test_submit_other_clears_saved_selections():\n    \"\"\"After submit_other(), stale draft should be cleared.\"\"\"\n    request = _make_request(\n        [\n            {\"question\": \"Q1?\", \"options\": [(\"A\", \"\"), (\"B\", \"\")]},\n            {\"question\": \"Q2?\", \"options\": [(\"C\", \"\"), (\"D\", \"\")]},\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Move to Other on Q1\n    panel.move_down()  # B\n    panel.move_down()  # Other\n    assert panel.is_other_selected\n\n    # Switch to Q2 (saves draft with cursor on Other)\n    panel.next_tab()\n\n    # Switch back, submit with Other text\n    panel.prev_tab()\n    assert panel.is_other_selected  # restored from draft\n    assert panel.submit() is False  # needs text\n    panel.submit_other(\"custom\")\n\n    # Draft should be cleared\n    assert 0 not in panel._saved_selections\n\n    # Go back — should restore from answer, not stale draft\n    panel.go_to(0)\n    # \"custom\" doesn't match A or B, so it should be recognized as Other text\n    # and cursor should land on the synthetic Other option.\n    assert panel.is_other_selected\n\n\n# ---------------------------------------------------------------------------\n# Multi-select answer restoration from comma-separated string\n# ---------------------------------------------------------------------------\n\n\ndef test_multi_select_answer_restoration():\n    \"\"\"Returning to a submitted multi-select question should restore checkboxes.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    # Go to Q3 (multi-select: Cake, IceCream)\n    panel.go_to(2)\n\n    # Select both options\n    panel.toggle_select()  # Cake (index 0)\n    panel.move_down()\n    panel.toggle_select()  # IceCream (index 1)\n\n    # Submit Q3\n    all_done = panel.submit()\n    assert all_done is False  # Q1 and Q2 still pending\n    assert panel.get_answers()[\"Q3?\"] == \"Cake, IceCream\"\n\n    # Submit Q1 (now current after Q3 advance)\n    panel.submit()  # Q1 default (Rice)\n\n    # Submit Q2\n    panel.submit()  # Q2 default (Tea)\n\n    # Verify all answers\n    answers = panel.get_answers()\n    assert answers == {\"Q1?\": \"Rice\", \"Q2?\": \"Tea\", \"Q3?\": \"Cake, IceCream\"}\n\n\ndef test_multi_select_answer_restoration_after_revisit():\n    \"\"\"Revisiting a submitted multi-select question should show correct checkboxes.\"\"\"\n    request = _make_request(\n        [\n            {\"question\": \"Q1?\", \"options\": [(\"A\", \"\")], \"multi_select\": True},\n            {\"question\": \"Q2?\", \"options\": [(\"B\", \"\")]},\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Select A and submit Q1\n    panel.toggle_select()  # A\n    panel.submit()  # → advances to Q2\n\n    # Go back to Q1\n    panel.go_to(0)\n\n    # _multi_selected should contain {0} (A was checked)\n    assert 0 in panel._multi_selected\n    assert panel._selected_index == 0\n\n\ndef test_multi_select_answer_restoration_with_other():\n    \"\"\"Restoring multi-select with Other text should mark Other as selected.\"\"\"\n    request = _make_request(\n        [\n            {\n                \"question\": \"Pick?\",\n                \"options\": [(\"X\", \"\"), (\"Y\", \"\")],\n                \"multi_select\": True,\n            },\n            {\"question\": \"Q2?\", \"options\": [(\"Z\", \"\")]},\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Select X and Other\n    panel.toggle_select()  # X (index 0)\n    panel.move_down()  # Y (index 1)\n    panel.move_down()  # Other (index 2)\n    panel.toggle_select()  # Other\n\n    # submit() returns False (Other needs text)\n    assert panel.submit() is False\n    panel.submit_other(\"custom\")\n\n    assert panel.get_answers()[\"Pick?\"] == \"X, custom\"\n\n    # Go back to Q1\n    panel.go_to(0)\n\n    # X should be in _multi_selected, and Other (index 2) too\n    assert 0 in panel._multi_selected  # X\n    assert 2 in panel._multi_selected  # Other (because \"custom\" didn't match any known label)\n\n\ndef test_single_select_answer_restoration():\n    \"\"\"Revisiting a submitted single-select question should restore cursor.\"\"\"\n    request = _make_request(\n        [\n            {\"question\": \"Q1?\", \"options\": [(\"A\", \"\"), (\"B\", \"\"), (\"C\", \"\")]},\n            {\"question\": \"Q2?\", \"options\": [(\"D\", \"\"), (\"E\", \"\")]},\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    # Select B (index 1) and submit Q1\n    panel.move_down()\n    panel.submit()\n\n    # Go back to Q1\n    panel.go_to(0)\n    assert panel._selected_index == 1  # B\n\n\n# ---------------------------------------------------------------------------\n# Multi-question advance logic\n# ---------------------------------------------------------------------------\n\n\ndef test_advance_skips_answered_questions():\n    \"\"\"After submitting Q1, advance should go to Q2, not Q3 if Q2 is unanswered.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    # Submit Q1 default (Rice) → should advance to Q2\n    panel.submit()\n    assert panel._current_question_index == 1\n    assert panel.current_question_text == \"Q2?\"\n\n    # Submit Q2 default (Tea) → should advance to Q3\n    panel.submit()\n    assert panel._current_question_index == 2\n    assert panel.current_question_text == \"Q3?\"\n\n\ndef test_advance_finds_first_unanswered():\n    \"\"\"After answering Q1 and Q3, submitting should cycle back to Q2.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    # Answer Q1\n    panel.submit()  # Rice → advance to Q2\n    assert panel._current_question_index == 1\n\n    # Skip Q2 by tabbing to Q3\n    panel.next_tab()\n    assert panel._current_question_index == 2\n\n    # Answer Q3 (multi-select: Cake)\n    panel.toggle_select()\n    panel.submit()\n\n    # Should advance to Q2 (the only unanswered)\n    assert panel._current_question_index == 1\n    assert panel.current_question_text == \"Q2?\"\n\n\n# ---------------------------------------------------------------------------\n# Render validation\n# ---------------------------------------------------------------------------\n\n\ndef test_render_does_not_crash():\n    \"\"\"render() should not raise for any state.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    # Render initial state\n    _render_to_str(panel)\n\n    # Render after some navigation\n    panel.move_down()\n    _render_to_str(panel)\n\n    # Render on multi-select question\n    panel.go_to(2)\n    panel.toggle_select()\n    _render_to_str(panel)\n\n    # Render after submission\n    panel.go_to(0)\n    panel.submit()\n    _render_to_str(panel)\n\n\ndef test_render_tab_bar_status():\n    \"\"\"Tab bar should show correct ●/✓/○ status indicators.\"\"\"\n    panel = _QuestionRequestPanel(_make_multi_question_request())\n\n    rendered = _render_to_str(panel)\n\n    # Q1 is active (●), Q2 and Q3 are unanswered (○)\n    assert \"\\u25cf\" in rendered  # ●\n    assert \"\\u25cb\" in rendered  # ○\n\n    # Submit Q1, check Q1 shows ✓\n    panel.submit()\n    rendered = _render_to_str(panel)\n    assert \"\\u2713\" in rendered  # ✓\n\n\n# ---------------------------------------------------------------------------\n# Edge cases\n# ---------------------------------------------------------------------------\n\n\ndef test_render_number_labels_in_single_select():\n    \"\"\"Single-select options should display [1], [2], etc. as literal text.\"\"\"\n    request = _make_request()\n    panel = _QuestionRequestPanel(request)\n    rendered = _render_to_str(panel)\n\n    # Number labels should appear as literal text, not be consumed as Rich markup\n    assert \"[1]\" in rendered\n    assert \"[2]\" in rendered\n    assert \"[3]\" in rendered\n\n\ndef test_single_question_no_tab_bar():\n    \"\"\"Single-question request should not render tab bar.\"\"\"\n    request = _make_request()\n    panel = _QuestionRequestPanel(request)\n    rendered = _render_to_str(panel)\n\n    # No tab indicators since there's only one question\n    assert \"\\u25cf\" not in rendered\n\n\ndef test_header_fallback():\n    \"\"\"Questions without headers should use Q1, Q2, etc.\"\"\"\n    request = _make_request(\n        [\n            {\"question\": \"First?\", \"options\": [(\"A\", \"\")]},\n            {\"question\": \"Second?\", \"options\": [(\"B\", \"\")]},\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n    rendered = _render_to_str(panel)\n    assert \"Q1\" in rendered\n    assert \"Q2\" in rendered\n\n\ndef test_submit_all_questions_returns_true():\n    \"\"\"Submitting the last unanswered question should return True.\"\"\"\n    request = _make_request(\n        [\n            {\"question\": \"Q1?\", \"options\": [(\"A\", \"\")]},\n            {\"question\": \"Q2?\", \"options\": [(\"B\", \"\")]},\n        ]\n    )\n    panel = _QuestionRequestPanel(request)\n\n    assert panel.submit() is False  # Q2 still pending\n    assert panel.submit() is True  # all done\n    assert panel.get_answers() == {\"Q1?\": \"A\", \"Q2?\": \"B\"}\n\n\ndef test_toggle_select_noop_in_single_select():\n    \"\"\"toggle_select should do nothing in single-select mode.\"\"\"\n    request = _make_request()\n    panel = _QuestionRequestPanel(request)\n\n    panel.toggle_select()  # should be no-op\n    assert panel._multi_selected == set()\n\n    all_done = panel.submit()\n    assert all_done is True\n    assert panel.get_answers() == {\"Pick one?\": \"A\"}\n"
  },
  {
    "path": "tests/ui_and_conv/test_replay.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\nfrom kosong.message import Message\n\nimport kimi_cli.ui.shell.replay as replay_module\nfrom kimi_cli.soul.message import system_reminder\nfrom kimi_cli.ui.shell.replay import (\n    _build_replay_turns_from_history,\n    _build_replay_turns_from_wire,\n    replay_recent_history,\n)\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.wire.file import WireFile\nfrom kimi_cli.wire.types import SteerInput, StepBegin, TextPart, TurnBegin\n\n\ndef test_build_replay_turns_from_history_ignores_system_reminders() -> None:\n    history = [\n        Message(role=\"user\", content=[TextPart(text=\"Original question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"First answer\")]),\n        Message(role=\"user\", content=[system_reminder(\"Do not create a new turn.\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Follow-up answer\")]),\n    ]\n\n    turns = _build_replay_turns_from_history(history)\n\n    assert len(turns) == 1\n    assert turns[0].user_message.extract_text(\" \") == \"Original question\"\n    assert turns[0].n_steps == 2\n\n\ndef test_build_replay_turns_from_history_keeps_plain_steer_as_user_turn() -> None:\n    history = [\n        Message(role=\"user\", content=[TextPart(text=\"Original question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"First answer\")]),\n        Message(role=\"user\", content=[TextPart(text=\"A steer follow-up\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Follow-up answer\")]),\n    ]\n\n    turns = _build_replay_turns_from_history(history)\n\n    assert len(turns) == 2\n    assert turns[0].user_message.extract_text(\" \") == \"Original question\"\n    assert turns[1].user_message.extract_text(\" \") == \"A steer follow-up\"\n\n\n@pytest.mark.asyncio\nasync def test_build_replay_turns_from_wire_keeps_steer_as_user_turn(tmp_path: Path) -> None:\n    wire_file = WireFile(tmp_path / \"wire.jsonl\")\n    await wire_file.append_message(TurnBegin(user_input=[TextPart(text=\"Original question\")]))\n    await wire_file.append_message(StepBegin(n=1))\n    await wire_file.append_message(TextPart(text=\"First answer\"))\n    await wire_file.append_message(SteerInput(user_input=[TextPart(text=\"A steer follow-up\")]))\n    await wire_file.append_message(StepBegin(n=2))\n    await wire_file.append_message(TextPart(text=\"Follow-up answer\"))\n\n    turns = await _build_replay_turns_from_wire(wire_file)\n\n    assert len(turns) == 2\n    assert turns[0].user_message.extract_text(\" \") == \"Original question\"\n    assert turns[0].n_steps == 1\n    assert turns[1].user_message.extract_text(\" \") == \"A steer follow-up\"\n    assert turns[1].n_steps == 2\n\n\n@pytest.mark.asyncio\nasync def test_replay_recent_history_falls_back_to_history_when_wire_misses_steer(\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    history = [\n        Message(role=\"user\", content=[TextPart(text=\"Original question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"First answer\")]),\n        Message(role=\"user\", content=[TextPart(text=\"A steer follow-up\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Follow-up answer\")]),\n    ]\n    wire_file = WireFile(tmp_path / \"wire.jsonl\")\n    await wire_file.append_message(TurnBegin(user_input=[TextPart(text=\"Original question\")]))\n    await wire_file.append_message(StepBegin(n=1))\n    await wire_file.append_message(TextPart(text=\"First answer\"))\n\n    printed: list[str] = []\n    monkeypatch.setattr(\n        replay_module.console,\n        \"print\",\n        lambda text: printed.append(getattr(text, \"plain\", str(text))),\n    )\n\n    async def fake_visualize(*_args, **_kwargs) -> None:\n        return None\n\n    monkeypatch.setattr(replay_module, \"visualize\", fake_visualize)\n\n    await replay_recent_history(history, wire_file=wire_file)\n\n    assert printed == [\"✨ Original question\", \"✨ A steer follow-up\"]\n\n\n@pytest.mark.asyncio\nasync def test_replay_recent_history_prefers_wire_when_turns_match(\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    history = [\n        Message(role=\"user\", content=[TextPart(text=\"Original question\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Only one assistant message in history\")]),\n    ]\n    wire_file = WireFile(tmp_path / \"wire.jsonl\")\n    await wire_file.append_message(TurnBegin(user_input=[TextPart(text=\"Original question\")]))\n    await wire_file.append_message(StepBegin(n=1))\n    await wire_file.append_message(TextPart(text=\"first replay step\"))\n    await wire_file.append_message(StepBegin(n=2))\n    await wire_file.append_message(TextPart(text=\"second replay step\"))\n\n    step_counts: list[int] = []\n    monkeypatch.setattr(replay_module.console, \"print\", lambda *_args, **_kwargs: None)\n\n    async def fake_visualize(wire_ui, *, initial_status) -> None:\n        steps = 0\n        while True:\n            try:\n                msg = await wire_ui.receive()\n            except QueueShutDown:\n                break\n            if isinstance(msg, StepBegin):\n                steps += 1\n        step_counts.append(steps)\n\n    monkeypatch.setattr(replay_module, \"visualize\", fake_visualize)\n\n    await replay_recent_history(history, wire_file=wire_file)\n\n    assert step_counts == [2]\n\n\n@pytest.mark.asyncio\nasync def test_replay_recent_history_falls_back_to_history_when_duplicate_text_steer_is_missing(\n    tmp_path: Path,\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    history = [\n        Message(role=\"user\", content=[TextPart(text=\"hi\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"first answer\")]),\n        Message(role=\"user\", content=[TextPart(text=\"hi\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"second answer\")]),\n    ]\n    wire_file = WireFile(tmp_path / \"wire.jsonl\")\n    await wire_file.append_message(TurnBegin(user_input=[TextPart(text=\"hi\")]))\n    await wire_file.append_message(StepBegin(n=1))\n    await wire_file.append_message(TextPart(text=\"first answer\"))\n\n    printed: list[str] = []\n    monkeypatch.setattr(\n        replay_module.console,\n        \"print\",\n        lambda text: printed.append(getattr(text, \"plain\", str(text))),\n    )\n\n    async def fake_visualize(*_args, **_kwargs) -> None:\n        return None\n\n    monkeypatch.setattr(replay_module, \"visualize\", fake_visualize)\n\n    await replay_recent_history(history, wire_file=wire_file)\n\n    assert printed == [\"✨ hi\", \"✨ hi\"]\n"
  },
  {
    "path": "tests/ui_and_conv/test_sanitize_surrogates.py",
    "content": "\"\"\"Tests for sanitize_surrogates function in prompt module.\"\"\"\n\nimport pytest\n\nfrom kimi_cli.ui.shell.prompt import sanitize_surrogates\n\n\nclass TestSanitizeSurrogates:\n    \"\"\"Test cases for UTF-16 surrogate sanitization.\"\"\"\n\n    def test_surrogate_pair_is_replaced(self) -> None:\n        \"\"\"Test that UTF-16 surrogate pairs are sanitized.\"\"\"\n        # \\ud83d\\udc3a is the UTF-16 surrogate pair for wolf emoji 🐺\n        input_text = \"Hello \\ud83d\\udc3a World\"\n\n        # Original should fail to encode\n        with pytest.raises(UnicodeEncodeError):\n            input_text.encode(\"utf-8\")\n\n        # Sanitized should encode successfully\n        result = sanitize_surrogates(input_text)\n        result.encode(\"utf-8\")  # Should not raise\n\n    def test_normal_emoji_preserved(self) -> None:\n        \"\"\"Test that normal emoji characters are preserved.\"\"\"\n        input_text = \"Hello 🐺 World 🎉\"\n        result = sanitize_surrogates(input_text)\n        assert result == input_text\n\n    def test_ascii_text_unchanged(self) -> None:\n        \"\"\"Test that plain ASCII text is unchanged.\"\"\"\n        input_text = \"Hello World\"\n        result = sanitize_surrogates(input_text)\n        assert result == input_text\n\n    def test_unicode_text_preserved(self) -> None:\n        \"\"\"Test that normal Unicode text is preserved.\"\"\"\n        input_text = \"你好世界 Привет мир\"\n        result = sanitize_surrogates(input_text)\n        assert result == input_text\n\n    def test_empty_string(self) -> None:\n        \"\"\"Test that empty string returns empty string.\"\"\"\n        result = sanitize_surrogates(\"\")\n        assert result == \"\"\n\n    def test_mixed_content_with_surrogates(self) -> None:\n        \"\"\"Test text with surrogates mixed with normal content.\"\"\"\n        # Simulating the exact issue from GitHub #420\n        input_text = \"CTL Implementation - Kimi Tasks\\nAssigned To: \\ud83d\\udc3a Kimi\"\n\n        # Should not raise\n        result = sanitize_surrogates(input_text)\n        result.encode(\"utf-8\")\n\n        # Should preserve the rest of the content\n        assert \"CTL Implementation\" in result\n        assert \"Assigned To:\" in result\n        assert \"Kimi\" in result\n"
  },
  {
    "path": "tests/ui_and_conv/test_shell_editor_slash.py",
    "content": "from __future__ import annotations\n\nfrom collections.abc import Awaitable\nfrom pathlib import Path\nfrom types import SimpleNamespace\nfrom typing import cast\nfrom unittest.mock import Mock\n\nfrom kosong.tooling.empty import EmptyToolset\n\nfrom kimi_cli.config import get_default_config\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell import Shell\nfrom kimi_cli.ui.shell import slash as shell_slash\n\n\ndef _make_shell_app(runtime: Runtime, tmp_path: Path) -> SimpleNamespace:\n    agent = Agent(\n        name=\"Test Agent\",\n        system_prompt=\"Test system prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    soul = KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n    return SimpleNamespace(soul=soul)\n\n\nasync def test_editor_persists_to_runtime_config_file(\n    runtime: Runtime, tmp_path: Path, monkeypatch\n) -> None:\n    config_path = (tmp_path / \"custom-config.toml\").resolve()\n    runtime.config.source_file = config_path\n    runtime.config.is_from_default_location = False\n    runtime.config.default_editor = \"\"\n\n    app = _make_shell_app(runtime, tmp_path)\n\n    config_for_save = get_default_config()\n    load_mock = Mock(return_value=config_for_save)\n    save_mock = Mock()\n    monkeypatch.setattr(shell_slash, \"load_config\", load_mock)\n    monkeypatch.setattr(shell_slash, \"save_config\", save_mock)\n    monkeypatch.setattr(\"shutil.which\", lambda _binary: \"/usr/bin/vim\")\n\n    ret = shell_slash.editor(cast(Shell, app), \"vim\")\n    if isinstance(ret, Awaitable):\n        await ret\n\n    load_mock.assert_called_once_with(config_path)\n    save_mock.assert_called_once_with(config_for_save, config_path)\n    assert config_for_save.default_editor == \"vim\"\n    assert runtime.config.default_editor == \"vim\"\n\n\nasync def test_editor_rejects_inline_config_without_source_file(\n    runtime: Runtime, tmp_path: Path, monkeypatch\n) -> None:\n    runtime.config.source_file = None\n    runtime.config.default_editor = \"\"\n    app = _make_shell_app(runtime, tmp_path)\n\n    load_mock = Mock()\n    save_mock = Mock()\n    print_mock = Mock()\n    monkeypatch.setattr(shell_slash, \"load_config\", load_mock)\n    monkeypatch.setattr(shell_slash, \"save_config\", save_mock)\n    monkeypatch.setattr(shell_slash.console, \"print\", print_mock)\n\n    ret = shell_slash.editor(cast(Shell, app), \"vim\")\n    if isinstance(ret, Awaitable):\n        await ret\n\n    load_mock.assert_not_called()\n    save_mock.assert_not_called()\n    assert runtime.config.default_editor == \"\"\n    assert print_mock.called\n    assert \"inline --config\" in str(print_mock.call_args.args[0])\n"
  },
  {
    "path": "tests/ui_and_conv/test_shell_export_import_commands.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, Mock\n\nfrom kosong.message import Message\n\nfrom kimi_cli.session import Session\nfrom kimi_cli.ui.shell import export_import as shell_export_import\nfrom kimi_cli.wire.types import TextPart, TurnBegin, TurnEnd\n\n\ndef _make_shell_app(work_dir: Path) -> Mock:\n    from kimi_cli.soul.kimisoul import KimiSoul\n\n    soul = Mock(spec=KimiSoul)\n    soul.runtime.session.work_dir = work_dir\n    soul.runtime.session.id = \"curr-session-id\"\n    soul.context.history = []\n    soul.context.token_count = 123\n    soul.context.append_message = AsyncMock()\n    soul.context.update_token_count = AsyncMock()\n    soul.wire_file.append_message = AsyncMock()\n\n    app = Mock()\n    app.soul = soul\n    return app\n\n\nasync def test_export_writes_markdown_file(tmp_path: Path) -> None:\n    app = _make_shell_app(tmp_path)\n    app.soul.context.history = [\n        Message(role=\"user\", content=[TextPart(text=\"Hello\")]),\n        Message(role=\"assistant\", content=[TextPart(text=\"Hi!\")]),\n    ]\n\n    output = tmp_path / \"session.md\"\n    await shell_export_import.export(app, str(output))  # type: ignore[reportGeneralTypeIssues]\n\n    assert output.exists()\n    content = output.read_text(encoding=\"utf-8\")\n    assert \"# Kimi Session Export\" in content\n    assert \"session_id: curr-session-id\" in content\n    assert \"Hello\" in content\n    assert \"Hi!\" in content\n\n\nasync def test_import_from_file_appends_message_and_wire_markers(tmp_path: Path) -> None:\n    app = _make_shell_app(tmp_path)\n    source_file = tmp_path / \"source.md\"\n    source_file.write_text(\"previous conversation context\", encoding=\"utf-8\")\n\n    await shell_export_import.import_context(app, str(source_file))  # type: ignore[reportGeneralTypeIssues]\n\n    assert app.soul.context.append_message.await_count == 1\n    imported_message = app.soul.context.append_message.await_args.args[0]\n    assert imported_message.role == \"user\"\n\n    imported_text = next(\n        p.text\n        for p in imported_message.content\n        if isinstance(p, TextPart) and \"<imported_context\" in p.text\n    )\n    assert \"source=\\\"file 'source.md'\\\"\" in imported_text\n    assert \"previous conversation context\" in imported_text\n\n    wire_calls = app.soul.wire_file.append_message.await_args_list\n    assert len(wire_calls) == 2\n    assert isinstance(wire_calls[0].args[0], TurnBegin)\n    assert wire_calls[0].args[0].user_input == \"[Imported context from file 'source.md']\"\n    assert isinstance(wire_calls[1].args[0], TurnEnd)\n\n\nasync def test_import_from_session_appends_message_and_wire_markers(\n    tmp_path: Path, monkeypatch\n) -> None:\n    app = _make_shell_app(tmp_path)\n\n    source_context_file = tmp_path / \"source_context.jsonl\"\n    source_message = Message(\n        role=\"user\",\n        content=[TextPart(text=\"Question from old session\")],\n    )\n    source_context_file.write_text(\n        source_message.model_dump_json(exclude_none=True) + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    async def fake_find(_work_dir: Path, _target: str) -> SimpleNamespace:\n        return SimpleNamespace(context_file=source_context_file)\n\n    monkeypatch.setattr(Session, \"find\", fake_find)\n\n    await shell_export_import.import_context(app, \"old-session-id\")  # type: ignore[reportGeneralTypeIssues]\n\n    assert app.soul.context.append_message.await_count == 1\n    imported_message = app.soul.context.append_message.await_args.args[0]\n    imported_text = next(\n        p.text\n        for p in imported_message.content\n        if isinstance(p, TextPart) and \"<imported_context\" in p.text\n    )\n    assert \"source=\\\"session 'old-session-id'\\\"\" in imported_text\n    assert \"[USER]\" in imported_text\n    assert \"Question from old session\" in imported_text\n\n    wire_calls = app.soul.wire_file.append_message.await_args_list\n    assert len(wire_calls) == 2\n    assert isinstance(wire_calls[0].args[0], TurnBegin)\n    assert wire_calls[0].args[0].user_input == \"[Imported context from session 'old-session-id']\"\n    assert isinstance(wire_calls[1].args[0], TurnEnd)\n\n\nasync def test_import_directory_path_prints_clear_error(tmp_path: Path, monkeypatch) -> None:\n    app = _make_shell_app(tmp_path)\n    target_dir = tmp_path / \"context-dir\"\n    target_dir.mkdir()\n\n    print_mock = Mock()\n    monkeypatch.setattr(shell_export_import.console, \"print\", print_mock)\n\n    await shell_export_import.import_context(app, str(target_dir))  # type: ignore[reportGeneralTypeIssues]\n\n    assert print_mock.called\n    rendered = \" \".join(str(arg) for args in print_mock.call_args_list for arg in args.args)\n    assert \"directory\" in rendered.lower()\n    assert \"provide a file\" in rendered.lower()\n    assert app.soul.context.append_message.await_count == 0\n    assert app.soul.wire_file.append_message.await_count == 0\n"
  },
  {
    "path": "tests/ui_and_conv/test_shell_prompt_echo.py",
    "content": "from kosong.message import Message\nfrom rich.text import Text\n\nimport kimi_cli.ui.shell as shell_module\nfrom kimi_cli.ui.shell import Shell\nfrom kimi_cli.ui.shell.echo import render_user_echo\nfrom kimi_cli.ui.shell.prompt import PromptMode, UserInput\nfrom kimi_cli.utils.slashcmd import SlashCommandCall\nfrom kimi_cli.wire.types import AudioURLPart, ImageURLPart, TextPart, VideoURLPart\n\n\ndef _make_user_input(command: str, *, mode: PromptMode = PromptMode.AGENT) -> UserInput:\n    return UserInput(\n        mode=mode,\n        command=command,\n        resolved_command=command,\n        content=[TextPart(text=command)],\n    )\n\n\ndef test_echo_agent_input_prints_stringified_user_message(monkeypatch) -> None:\n    printed: list[Text] = []\n    monkeypatch.setattr(shell_module.console, \"print\", lambda text: printed.append(text))\n\n    Shell._echo_agent_input(_make_user_input(\"hi\"))\n\n    assert [text.plain for text in printed] == [\"✨ hi\"]\n\n\ndef test_echo_agent_input_uses_display_command_for_placeholders(monkeypatch) -> None:\n    printed: list[Text] = []\n    monkeypatch.setattr(shell_module.console, \"print\", lambda text: printed.append(text))\n\n    user_input = UserInput(\n        mode=PromptMode.AGENT,\n        command=\"[Pasted text #1 +3 lines]\",\n        resolved_command=\"line1\\nline2\\nline3\",\n        content=[TextPart(text=\"line1\\nline2\\nline3\")],\n    )\n\n    Shell._echo_agent_input(user_input)\n\n    assert [text.plain for text in printed] == [\"✨ [Pasted text #1 +3 lines]\"]\n\n\ndef test_render_user_echo_preserves_literal_brackets() -> None:\n    rendered = render_user_echo(Message(role=\"user\", content=[TextPart(text=\"[brackets]\")]))\n\n    assert rendered.plain == \"✨ [brackets]\"\n\n\ndef test_render_user_echo_preserves_image_placeholder_literal() -> None:\n    rendered = render_user_echo(\n        Message(\n            role=\"user\",\n            content=[ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/img\"))],\n        )\n    )\n\n    assert rendered.plain == \"✨ [image]\"\n\n\ndef test_render_user_echo_preserves_audio_placeholder_literal() -> None:\n    rendered = render_user_echo(\n        Message(\n            role=\"user\",\n            content=[\n                AudioURLPart(\n                    audio_url=AudioURLPart.AudioURL(url=\"https://example.com/audio\", id=\"clip\")\n                )\n            ],\n        )\n    )\n\n    assert rendered.plain == \"✨ [audio:clip]\"\n\n\ndef test_render_user_echo_preserves_video_placeholder_literal() -> None:\n    rendered = render_user_echo(\n        Message(\n            role=\"user\",\n            content=[\n                VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video\"))\n            ],\n        )\n    )\n\n    assert rendered.plain == \"✨ [video]\"\n\n\ndef test_render_user_echo_preserves_mixed_content_order() -> None:\n    rendered = render_user_echo(\n        Message(\n            role=\"user\",\n            content=[\n                TextPart(text=\"look \"),\n                ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/img\")),\n                AudioURLPart(audio_url=AudioURLPart.AudioURL(url=\"https://example.com/audio\")),\n                VideoURLPart(video_url=VideoURLPart.VideoURL(url=\"https://example.com/video\")),\n            ],\n        )\n    )\n\n    assert rendered.plain == \"✨ look [image][audio][video]\"\n\n\ndef test_should_echo_agent_input_for_plain_agent_message() -> None:\n    assert Shell._should_echo_agent_input(_make_user_input(\"hi\")) is True\n\n\ndef test_should_not_echo_agent_input_for_exit_or_slash_commands() -> None:\n    assert Shell._should_echo_agent_input(_make_user_input(\"exit\")) is False\n    assert Shell._should_echo_agent_input(_make_user_input(\"/exit\")) is False\n    assert Shell._should_echo_agent_input(_make_user_input(\"/help\")) is False\n\n\ndef test_hidden_slash_in_placeholder_is_not_treated_as_local_command() -> None:\n    user_input = UserInput(\n        mode=PromptMode.AGENT,\n        command=\"[Pasted text #1 +3 lines]\",\n        resolved_command=\"/quit\\nnot really\",\n        content=[TextPart(text=\"/quit\\nnot really\")],\n    )\n\n    assert Shell._should_exit_input(user_input) is False\n    assert Shell._agent_slash_command_call(user_input) is None\n    assert Shell._should_echo_agent_input(user_input) is True\n\n\ndef test_should_exit_input_is_mode_independent_for_visible_exit_commands() -> None:\n    assert Shell._should_exit_input(_make_user_input(\"exit\")) is True\n    assert Shell._should_exit_input(_make_user_input(\"/quit\")) is True\n    assert Shell._should_exit_input(_make_user_input(\"exit\", mode=PromptMode.SHELL)) is True\n    assert Shell._should_exit_input(_make_user_input(\"/exit\", mode=PromptMode.SHELL)) is True\n\n\ndef test_visible_slash_command_keeps_expanded_placeholder_args() -> None:\n    user_input = UserInput(\n        mode=PromptMode.AGENT,\n        command=\"/echo [Pasted text #1 +3 lines]\",\n        resolved_command=\"/echo line1\\nline2\\nline3\",\n        content=[TextPart(text=\"line1\\nline2\\nline3\")],\n    )\n\n    assert Shell._agent_slash_command_call(user_input) == SlashCommandCall(\n        name=\"echo\",\n        args=\"line1\\nline2\\nline3\",\n        raw_input=\"/echo line1\\nline2\\nline3\",\n    )\n    assert Shell._should_echo_agent_input(user_input) is False\n\n\ndef test_should_not_echo_non_agent_input() -> None:\n    assert Shell._should_echo_agent_input(_make_user_input(\"ls\", mode=PromptMode.SHELL)) is False\n"
  },
  {
    "path": "tests/ui_and_conv/test_shell_prompt_router.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom collections import deque\nfrom types import SimpleNamespace\nfrom typing import Any, cast\n\nimport pytest\n\nimport kimi_cli.ui.shell as shell_module\nfrom kimi_cli.soul import Soul\nfrom kimi_cli.ui.shell.prompt import PromptMode, UserInput\nfrom kimi_cli.wire.types import TextPart\n\n\ndef _make_user_input(command: str, *, mode: PromptMode = PromptMode.AGENT) -> UserInput:\n    return UserInput(\n        mode=mode,\n        command=command,\n        resolved_command=command,\n        content=[TextPart(text=command)],\n    )\n\n\ndef _make_fake_soul():\n    return SimpleNamespace(\n        name=\"Test Soul\",\n        available_slash_commands=[],\n        model_capabilities=set(),\n        model_name=None,\n        thinking=False,\n        status=SimpleNamespace(context_usage=0.0, context_tokens=0, max_context_tokens=0),\n    )\n\n\nclass _FakePromptSession:\n    def __init__(self, responses: list[tuple[bool, UserInput | BaseException]]) -> None:\n        self._responses = deque(responses)\n        self.last_submission_was_running = False\n\n    async def prompt_next(self) -> UserInput:\n        if self._responses:\n            was_running, response = self._responses.popleft()\n            self.last_submission_was_running = was_running\n            if isinstance(response, BaseException):\n                raise response\n            return response\n        await asyncio.sleep(3600)\n        raise AssertionError(\"prompt_next should have been cancelled before retry\")\n\n\n@pytest.fixture\ndef _patched_prompt_router(monkeypatch):\n    monkeypatch.setattr(shell_module, \"ensure_tty_sane\", lambda: None)\n    monkeypatch.setattr(shell_module, \"ensure_new_line\", lambda: None)\n\n\n@pytest.mark.asyncio\nasync def test_route_prompt_events_routes_running_submission_directly_to_handler(\n    _patched_prompt_router,\n) -> None:\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    prompt_session = _FakePromptSession([(True, _make_user_input(\"follow-up\"))])\n    idle_events: asyncio.Queue[shell_module._PromptEvent] = asyncio.Queue()\n    resume_prompt = asyncio.Event()\n    resume_prompt.set()\n\n    received: list[UserInput] = []\n    shell._bind_running_input(lambda user_input: received.append(user_input), lambda: None)\n\n    task = asyncio.create_task(\n        shell._route_prompt_events(cast(Any, prompt_session), idle_events, resume_prompt)\n    )\n    try:\n        await asyncio.sleep(0.05)\n        assert [user_input.command for user_input in received] == [\"follow-up\"]\n        assert idle_events.empty()\n    finally:\n        task.cancel()\n        with pytest.raises(asyncio.CancelledError):\n            await task\n\n\n@pytest.mark.asyncio\nasync def test_route_prompt_events_converts_running_keyboard_interrupt_to_cancel_callback(\n    _patched_prompt_router,\n) -> None:\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    prompt_session = _FakePromptSession([(False, KeyboardInterrupt())])\n    idle_events: asyncio.Queue[shell_module._PromptEvent] = asyncio.Queue()\n    resume_prompt = asyncio.Event()\n    resume_prompt.set()\n\n    cancelled: list[bool] = []\n    shell._bind_running_input(lambda _user_input: None, lambda: cancelled.append(True))\n\n    task = asyncio.create_task(\n        shell._route_prompt_events(cast(Any, prompt_session), idle_events, resume_prompt)\n    )\n    try:\n        await asyncio.sleep(0.05)\n        assert cancelled == [True]\n        assert idle_events.empty()\n    finally:\n        task.cancel()\n        with pytest.raises(asyncio.CancelledError):\n            await task\n\n\n@pytest.mark.asyncio\nasync def test_route_prompt_events_marks_eof_during_run_and_stops_router(\n    _patched_prompt_router,\n) -> None:\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    prompt_session = _FakePromptSession([(False, EOFError())])\n    idle_events: asyncio.Queue[shell_module._PromptEvent] = asyncio.Queue()\n    resume_prompt = asyncio.Event()\n    resume_prompt.set()\n\n    cancelled: list[bool] = []\n    shell._bind_running_input(lambda _user_input: None, lambda: cancelled.append(True))\n\n    await shell._route_prompt_events(cast(Any, prompt_session), idle_events, resume_prompt)\n\n    assert cancelled == [True]\n    assert shell._exit_after_run is True\n    assert idle_events.empty()\n\n\n@pytest.mark.asyncio\nasync def test_empty_enter_during_run_does_not_freeze_prompt(\n    _patched_prompt_router,\n) -> None:\n    \"\"\"Regression: pressing Enter on an empty buffer during an agent run would\n    fall through to the idle path, clear resume_prompt, and freeze the prompt\n    for the rest of the run.\"\"\"\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n\n    empty_input = UserInput(mode=PromptMode.AGENT, command=\"\", resolved_command=\"\", content=[])\n    real_input = _make_user_input(\"follow-up\")\n\n    # First submission: empty Enter (running). Second: real input (running).\n    prompt_session = _FakePromptSession(\n        [\n            (True, empty_input),\n            (True, real_input),\n        ]\n    )\n    idle_events: asyncio.Queue[shell_module._PromptEvent] = asyncio.Queue()\n    resume_prompt = asyncio.Event()\n    resume_prompt.set()\n\n    received: list[UserInput] = []\n    shell._bind_running_input(lambda ui: received.append(ui), lambda: None)\n\n    task = asyncio.create_task(\n        shell._route_prompt_events(cast(Any, prompt_session), idle_events, resume_prompt)\n    )\n    try:\n        await asyncio.sleep(0.05)\n        # Empty enter should be ignored; real input should reach handler.\n        assert [ui.command for ui in received] == [\"follow-up\"]\n        assert idle_events.empty()\n        # resume_prompt must still be set (not cleared by the empty submission).\n        assert resume_prompt.is_set()\n    finally:\n        task.cancel()\n        with pytest.raises(asyncio.CancelledError):\n            await task\n\n\ndef test_unbind_running_input_clears_handlers() -> None:\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    shell._bind_running_input(lambda _user_input: None, lambda: None)\n\n    shell._unbind_running_input()\n\n    assert shell._running_input_handler is None\n    assert shell._running_interrupt_handler is None\n\n\n@pytest.mark.asyncio\nasync def test_ctrl_d_after_agent_run_posts_eof_not_swallowed_by_stale_handler(\n    _patched_prompt_router,\n) -> None:\n    \"\"\"Regression: if _unbind fails, handlers linger and Ctrl-D during idle is\n    misrouted as a running-state EOF, causing the main loop to hang forever.\"\"\"\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n\n    # Gate ensures the second prompt_next (EOFError) only fires after unbind.\n    gate = asyncio.Event()\n\n    class _GatedPromptSession:\n        def __init__(self) -> None:\n            self.call_count = 0\n            self.last_submission_was_running = False\n\n        async def prompt_next(self) -> UserInput:\n            self.call_count += 1\n            if self.call_count == 1:\n                self.last_submission_was_running = True\n                return _make_user_input(\"steer-msg\")\n            await gate.wait()\n            self.last_submission_was_running = False\n            raise EOFError()\n\n    prompt_session = _GatedPromptSession()\n    idle_events: asyncio.Queue[shell_module._PromptEvent] = asyncio.Queue()\n    resume_prompt = asyncio.Event()\n    resume_prompt.set()\n\n    received: list[UserInput] = []\n    shell._bind_running_input(lambda ui: received.append(ui), lambda: None)\n\n    # Let the router process the first (running) response.\n    task = asyncio.create_task(\n        shell._route_prompt_events(cast(Any, prompt_session), idle_events, resume_prompt)\n    )\n    await asyncio.sleep(0.05)\n    assert len(received) == 1\n\n    # Agent run ends — unbind handlers, just like visualize() does.\n    shell._unbind_running_input()\n    gate.set()\n\n    # Router continues, hits EOFError. With the bug, it would treat this as a\n    # running EOF (set _exit_after_run, return without posting event) because\n    # the handler was never cleared. After the fix, it should post \"eof\".\n    await asyncio.wait_for(task, timeout=2.0)\n\n    event = idle_events.get_nowait()\n    assert event.kind == \"eof\"\n    assert shell._exit_after_run is False\n"
  },
  {
    "path": "tests/ui_and_conv/test_shell_run_placeholders.py",
    "content": "from __future__ import annotations\n\nfrom collections import deque\nfrom types import SimpleNamespace\nfrom typing import cast\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nimport kimi_cli.ui.shell as shell_module\nfrom kimi_cli.soul import Soul\nfrom kimi_cli.ui.shell.prompt import PromptMode, UserInput\nfrom kimi_cli.wire.types import TextPart\n\n\nclass _FakePromptSession:\n    instances: list[_FakePromptSession] = []\n    responses: deque[UserInput | BaseException] = deque()\n\n    def __init__(self, *args, **kwargs) -> None:\n        self.prompt_calls = 0\n        self.last_submission_was_running = False\n        _FakePromptSession.instances.append(self)\n\n    def __enter__(self) -> _FakePromptSession:\n        return self\n\n    def __exit__(self, exc_type, exc, tb) -> bool:\n        return False\n\n    async def prompt_next(self) -> UserInput:\n        self.prompt_calls += 1\n        response = _FakePromptSession.responses.popleft()\n        if isinstance(response, BaseException):\n            raise response\n        return response\n\n    def attach_running_prompt(self, delegate) -> None:\n        return None\n\n    def detach_running_prompt(self, delegate) -> None:\n        return None\n\n\ndef _make_user_input(\n    command: str,\n    *,\n    mode: PromptMode = PromptMode.AGENT,\n    resolved_command: str | None = None,\n) -> UserInput:\n    return UserInput(\n        mode=mode,\n        command=command,\n        resolved_command=command if resolved_command is None else resolved_command,\n        content=[TextPart(text=command if resolved_command is None else resolved_command)],\n    )\n\n\ndef _make_fake_soul():\n    return SimpleNamespace(\n        name=\"Test Soul\",\n        available_slash_commands=[],\n        model_capabilities=set(),\n        model_name=None,\n        thinking=False,\n        status=SimpleNamespace(context_usage=0.0, context_tokens=0, max_context_tokens=0),\n    )\n\n\n@pytest.fixture\ndef _patched_shell_run(monkeypatch):\n    _FakePromptSession.instances = []\n    _FakePromptSession.responses = deque()\n    monkeypatch.setattr(shell_module, \"CustomPromptSession\", _FakePromptSession)\n    monkeypatch.setattr(shell_module, \"_print_welcome_info\", lambda *args, **kwargs: None)\n    monkeypatch.setattr(shell_module, \"get_env_bool\", lambda name: True)\n    monkeypatch.setattr(shell_module, \"ensure_tty_sane\", lambda: None)\n    monkeypatch.setattr(shell_module, \"ensure_new_line\", lambda: None)\n\n    printed: list[str] = []\n    monkeypatch.setattr(\n        shell_module.console,\n        \"print\",\n        lambda text=\"\": printed.append(getattr(text, \"plain\", str(text))),\n    )\n    return printed\n\n\n@pytest.mark.asyncio\nasync def test_shell_run_treats_hidden_slash_in_placeholder_as_regular_agent_input(\n    monkeypatch, _patched_shell_run\n) -> None:\n    printed = _patched_shell_run\n    _FakePromptSession.responses = deque(\n        [\n            UserInput(\n                mode=PromptMode.AGENT,\n                command=\"[Pasted text #1 +3 lines]\",\n                resolved_command=\"/quit\\nstill send this\",\n                content=[TextPart(text=\"/quit\\nstill send this\")],\n            ),\n            EOFError(),\n        ]\n    )\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    shell.run_soul_command = AsyncMock(return_value=True)\n    shell._run_slash_command = AsyncMock()\n\n    result = await shell.run()\n\n    assert result is True\n    assert _FakePromptSession.instances[0].prompt_calls == 2\n    shell.run_soul_command.assert_awaited_once_with([TextPart(text=\"/quit\\nstill send this\")])\n    shell._run_slash_command.assert_not_awaited()\n    assert printed == [\"✨ [Pasted text #1 +3 lines]\", \"\", \"Bye!\"]\n\n\n@pytest.mark.asyncio\nasync def test_shell_run_dispatches_visible_slash_with_expanded_placeholder_args(\n    monkeypatch, _patched_shell_run\n) -> None:\n    printed = _patched_shell_run\n    _FakePromptSession.responses = deque(\n        [\n            UserInput(\n                mode=PromptMode.AGENT,\n                command=\"/fakecmd [Pasted text #1 +3 lines]\",\n                resolved_command=\"/fakecmd line1\\nline2\\nline3\",\n                content=[TextPart(text=\"line1\\nline2\\nline3\")],\n            ),\n            EOFError(),\n        ]\n    )\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    shell.run_soul_command = AsyncMock(return_value=True)\n    shell._run_slash_command = AsyncMock()\n\n    result = await shell.run()\n\n    assert result is True\n    assert _FakePromptSession.instances[0].prompt_calls == 2\n    shell.run_soul_command.assert_not_awaited()\n    shell._run_slash_command.assert_awaited_once()\n    assert shell._run_slash_command.await_args is not None\n    command_call = shell._run_slash_command.await_args.args[0]\n    assert command_call.name == \"fakecmd\"\n    assert command_call.args == \"line1\\nline2\\nline3\"\n    assert command_call.raw_input == \"/fakecmd line1\\nline2\\nline3\"\n    assert printed == [\"Bye!\"]\n\n\n@pytest.mark.asyncio\nasync def test_shell_run_exits_immediately_for_visible_quit_command(\n    monkeypatch, _patched_shell_run\n) -> None:\n    printed = _patched_shell_run\n    _FakePromptSession.responses = deque([_make_user_input(\"/quit\")])\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    shell.run_soul_command = AsyncMock(return_value=True)\n    shell._run_slash_command = AsyncMock()\n\n    result = await shell.run()\n\n    assert result is True\n    assert _FakePromptSession.instances[0].prompt_calls == 1\n    shell.run_soul_command.assert_not_awaited()\n    shell._run_slash_command.assert_not_awaited()\n    assert printed == [\"Bye!\"]\n\n\n@pytest.mark.asyncio\nasync def test_shell_run_exits_immediately_for_visible_exit_command_in_shell_mode(\n    monkeypatch, _patched_shell_run\n) -> None:\n    printed = _patched_shell_run\n    _FakePromptSession.responses = deque([_make_user_input(\"exit\", mode=PromptMode.SHELL)])\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    shell.run_soul_command = AsyncMock(return_value=True)\n    shell._run_shell_command = AsyncMock()\n    shell._run_slash_command = AsyncMock()\n\n    result = await shell.run()\n\n    assert result is True\n    assert _FakePromptSession.instances[0].prompt_calls == 1\n    shell.run_soul_command.assert_not_awaited()\n    shell._run_shell_command.assert_not_awaited()\n    shell._run_slash_command.assert_not_awaited()\n    assert printed == [\"Bye!\"]\n\n\n@pytest.mark.asyncio\nasync def test_shell_run_exits_immediately_for_visible_slash_exit_command_in_shell_mode(\n    monkeypatch, _patched_shell_run\n) -> None:\n    printed = _patched_shell_run\n    _FakePromptSession.responses = deque([_make_user_input(\"/exit\", mode=PromptMode.SHELL)])\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    shell.run_soul_command = AsyncMock(return_value=True)\n    shell._run_shell_command = AsyncMock()\n    shell._run_slash_command = AsyncMock()\n\n    result = await shell.run()\n\n    assert result is True\n    assert _FakePromptSession.instances[0].prompt_calls == 1\n    shell.run_soul_command.assert_not_awaited()\n    shell._run_shell_command.assert_not_awaited()\n    shell._run_slash_command.assert_not_awaited()\n    assert printed == [\"Bye!\"]\n\n\n@pytest.mark.asyncio\nasync def test_shell_run_exits_immediately_for_visible_slash_quit_command_in_shell_mode(\n    monkeypatch, _patched_shell_run\n) -> None:\n    printed = _patched_shell_run\n    _FakePromptSession.responses = deque([_make_user_input(\"/quit\", mode=PromptMode.SHELL)])\n    shell = shell_module.Shell(cast(Soul, _make_fake_soul()))\n    shell.run_soul_command = AsyncMock(return_value=True)\n    shell._run_shell_command = AsyncMock()\n    shell._run_slash_command = AsyncMock()\n\n    result = await shell.run()\n\n    assert result is True\n    assert _FakePromptSession.instances[0].prompt_calls == 1\n    shell.run_soul_command.assert_not_awaited()\n    shell._run_shell_command.assert_not_awaited()\n    shell._run_slash_command.assert_not_awaited()\n    assert printed == [\"Bye!\"]\n"
  },
  {
    "path": "tests/ui_and_conv/test_shell_slash_commands.py",
    "content": "\"\"\"Tests for shell-level slash commands.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Awaitable\nfrom pathlib import Path\nfrom typing import Any\nfrom unittest.mock import Mock\n\nimport pytest\nfrom kaos.path import KaosPath\nfrom kosong.message import Message\n\nfrom kimi_cli.cli import Reload\nfrom kimi_cli.session import Session\nfrom kimi_cli.ui.shell.slash import ShellSlashCmdFunc, shell_mode_registry\nfrom kimi_cli.ui.shell.slash import registry as shell_slash_registry\nfrom kimi_cli.utils.slashcmd import SlashCommand\nfrom kimi_cli.wire.types import TextPart\n\n\nasync def _invoke_slash_command(command: SlashCommand[ShellSlashCmdFunc], shell: Any) -> None:\n    ret = command.func(shell, \"\")\n    if isinstance(ret, Awaitable):\n        await ret\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef isolated_share_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:\n    \"\"\"Provide an isolated share directory for metadata operations.\"\"\"\n    share_dir = tmp_path / \"share\"\n    share_dir.mkdir()\n\n    def _get_share_dir() -> Path:\n        share_dir.mkdir(parents=True, exist_ok=True)\n        return share_dir\n\n    monkeypatch.setattr(\"kimi_cli.share.get_share_dir\", _get_share_dir)\n    monkeypatch.setattr(\"kimi_cli.metadata.get_share_dir\", _get_share_dir)\n    return share_dir\n\n\n@pytest.fixture\ndef work_dir(tmp_path: Path) -> KaosPath:\n    path = tmp_path / \"work\"\n    path.mkdir()\n    return KaosPath.unsafe_from_local_path(path)\n\n\n@pytest.fixture\ndef mock_shell(work_dir: KaosPath) -> Mock:\n    \"\"\"Create a mock Shell whose soul passes the KimiSoul isinstance check.\n\n    The mock session is treated as non-empty so that /new does not attempt\n    to delete it (delete would fail on a plain Mock because it is not awaitable).\n    \"\"\"\n    from kimi_cli.soul.kimisoul import KimiSoul\n\n    mock_soul = Mock(spec=KimiSoul)\n    mock_soul.runtime.session.work_dir = work_dir\n    mock_soul.runtime.session.id = \"current-session-id\"\n    mock_soul.runtime.session.is_empty.return_value = False\n\n    shell = Mock()\n    shell.soul = mock_soul\n    return shell\n\n\n# ---------------------------------------------------------------------------\n# /new — registration\n# ---------------------------------------------------------------------------\n\n\nclass TestNewCommandRegistration:\n    \"\"\"Verify /new is registered in the correct registries.\"\"\"\n\n    def test_registered_in_shell_registry(self) -> None:\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n        assert cmd.name == \"new\"\n        assert cmd.description == \"Start a new session\"\n\n    def test_not_in_shell_mode_registry(self) -> None:\n        \"\"\"/new should NOT be available in shell mode (Ctrl-X toggle).\"\"\"\n        assert shell_mode_registry.find_command(\"new\") is None\n\n    def test_not_in_soul_registry(self) -> None:\n        \"\"\"/new should NOT appear in soul-level commands (Web UI visibility).\"\"\"\n        from kimi_cli.soul.slash import registry as soul_slash_registry\n\n        assert soul_slash_registry.find_command(\"new\") is None\n\n\n# ---------------------------------------------------------------------------\n# /new — behaviour\n# ---------------------------------------------------------------------------\n\n\nclass TestNewCommandBehavior:\n    \"\"\"Verify /new creates a new session and raises Reload.\"\"\"\n\n    async def test_raises_reload_with_new_session_id(\n        self, isolated_share_dir: Path, mock_shell: Mock\n    ) -> None:\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n\n        with pytest.raises(Reload) as exc_info:\n            await _invoke_slash_command(cmd, mock_shell)\n\n        session_id = exc_info.value.session_id\n        assert session_id is not None\n        assert session_id != \"current-session-id\"\n\n    async def test_new_session_persisted_on_disk(\n        self, isolated_share_dir: Path, work_dir: KaosPath, mock_shell: Mock\n    ) -> None:\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n\n        with pytest.raises(Reload) as exc_info:\n            await _invoke_slash_command(cmd, mock_shell)\n\n        session_id = exc_info.value.session_id\n        assert session_id is not None\n        new_session = await Session.find(work_dir, session_id)\n        assert new_session is not None\n        assert new_session.context_file.exists()\n        assert new_session.context_file.stat().st_size == 0  # empty context\n\n    async def test_consecutive_calls_produce_unique_ids(\n        self, isolated_share_dir: Path, mock_shell: Mock\n    ) -> None:\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n\n        ids: list[str] = []\n        for _ in range(3):\n            with pytest.raises(Reload) as exc_info:\n                await _invoke_slash_command(cmd, mock_shell)\n            session_id = exc_info.value.session_id\n            assert session_id is not None\n            ids.append(session_id)\n\n        assert len(set(ids)) == 3\n\n    async def test_returns_early_without_kimi_soul(self) -> None:\n        \"\"\"When soul is not a KimiSoul, the command should silently return.\"\"\"\n        shell = Mock()\n        shell.soul = Mock()  # plain Mock, not spec=KimiSoul\n\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n\n        # Should return without raising Reload\n        await _invoke_slash_command(cmd, shell)\n\n\n# ---------------------------------------------------------------------------\n# /new — empty-session cleanup\n# ---------------------------------------------------------------------------\n\n\ndef _write_context_message(context_file: Path, text: str) -> None:\n    \"\"\"Write a user message to a context file to make the session non-empty.\"\"\"\n    context_file.parent.mkdir(parents=True, exist_ok=True)\n    message = Message(role=\"user\", content=[TextPart(text=text)])\n    context_file.write_text(message.model_dump_json(exclude_none=True) + \"\\n\", encoding=\"utf-8\")\n\n\nclass TestNewCommandSessionCleanup:\n    \"\"\"Verify /new cleans up the current session when it is empty.\"\"\"\n\n    async def test_deletes_empty_current_session(\n        self, isolated_share_dir: Path, work_dir: KaosPath\n    ) -> None:\n        \"\"\"An empty current session should be removed to avoid orphan directories.\"\"\"\n        from kimi_cli.soul.kimisoul import KimiSoul\n\n        empty_session = await Session.create(work_dir)\n        assert empty_session.is_empty()\n        session_dir = empty_session.work_dir_meta.sessions_dir / empty_session.id\n        assert session_dir.exists()\n\n        mock_soul = Mock(spec=KimiSoul)\n        mock_soul.runtime.session = empty_session\n        shell = Mock()\n        shell.soul = mock_soul\n\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n        with pytest.raises(Reload):\n            await _invoke_slash_command(cmd, shell)\n\n        # The empty session directory should have been cleaned up\n        assert not session_dir.exists()\n\n    async def test_preserves_non_empty_current_session(\n        self, isolated_share_dir: Path, work_dir: KaosPath\n    ) -> None:\n        \"\"\"A session that already has content must NOT be deleted.\"\"\"\n        from kimi_cli.soul.kimisoul import KimiSoul\n\n        session_with_content = await Session.create(work_dir)\n        _write_context_message(session_with_content.context_file, \"hello world\")\n        assert not session_with_content.is_empty()\n        session_dir = session_with_content.work_dir_meta.sessions_dir / session_with_content.id\n\n        mock_soul = Mock(spec=KimiSoul)\n        mock_soul.runtime.session = session_with_content\n        shell = Mock()\n        shell.soul = mock_soul\n\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n        with pytest.raises(Reload):\n            await _invoke_slash_command(cmd, shell)\n\n        # The non-empty session directory must still exist\n        assert session_dir.exists()\n\n    async def test_chained_new_does_not_accumulate_empty_sessions(\n        self, isolated_share_dir: Path, work_dir: KaosPath\n    ) -> None:\n        \"\"\"Calling /new repeatedly should not leave orphan empty sessions.\"\"\"\n        from kimi_cli.soul.kimisoul import KimiSoul\n\n        cmd = shell_slash_registry.find_command(\"new\")\n        assert cmd is not None\n\n        # Simulate: session A (empty) → /new → session B (empty) → /new → session C\n        session_a = await Session.create(work_dir)\n        dir_a = session_a.work_dir_meta.sessions_dir / session_a.id\n\n        mock_soul = Mock(spec=KimiSoul)\n        mock_soul.runtime.session = session_a\n        shell = Mock()\n        shell.soul = mock_soul\n\n        # First /new: A is empty → cleaned up, B created\n        with pytest.raises(Reload) as exc_info:\n            await _invoke_slash_command(cmd, shell)\n        session_b_id = exc_info.value.session_id\n        assert session_b_id is not None\n        session_b = await Session.find(work_dir, session_b_id)\n        assert session_b is not None\n        dir_b = session_b.work_dir_meta.sessions_dir / session_b.id\n\n        assert not dir_a.exists()  # A cleaned up\n        assert dir_b.exists()  # B exists\n\n        # Second /new: B is empty → cleaned up, C created\n        mock_soul.runtime.session = session_b\n        with pytest.raises(Reload) as exc_info:\n            await _invoke_slash_command(cmd, shell)\n        session_c_id = exc_info.value.session_id\n        assert session_c_id is not None\n\n        assert not dir_b.exists()  # B cleaned up\n        session_c = await Session.find(work_dir, session_c_id)\n        assert session_c is not None\n"
  },
  {
    "path": "tests/ui_and_conv/test_shell_task_slash.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom pathlib import Path\nfrom types import SimpleNamespace\nfrom unittest.mock import Mock\n\nimport pytest\nfrom kosong.tooling.empty import EmptyToolset\n\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell import Shell\nfrom kimi_cli.ui.shell import slash as shell_slash\n\n\ndef _make_shell_app(runtime: Runtime, tmp_path: Path) -> SimpleNamespace:\n    agent = Agent(\n        name=\"Test Agent\",\n        system_prompt=\"Test system prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    soul = KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n    return SimpleNamespace(soul=soul)\n\n\ndef test_task_command_registered_in_shell_registries() -> None:\n    assert shell_slash.registry.find_command(\"task\") is not None\n    assert shell_slash.shell_mode_registry.find_command(\"task\") is not None\n\n\n@pytest.mark.asyncio\nasync def test_task_command_rejects_args(runtime: Runtime, tmp_path: Path, monkeypatch) -> None:\n    app = _make_shell_app(runtime, tmp_path)\n    print_mock = Mock()\n    monkeypatch.setattr(shell_slash.console, \"print\", print_mock)\n\n    await shell_slash.task(app, \"unexpected\")  # type: ignore[arg-type]\n\n    print_mock.assert_called_once()\n    assert 'Usage: \"/task\"' in str(print_mock.call_args.args[0])\n\n\n@pytest.mark.asyncio\nasync def test_task_command_requires_root_role(\n    runtime: Runtime, tmp_path: Path, monkeypatch\n) -> None:\n    runtime.role = \"fixed_subagent\"\n    app = _make_shell_app(runtime, tmp_path)\n    print_mock = Mock()\n    monkeypatch.setattr(shell_slash.console, \"print\", print_mock)\n\n    await shell_slash.task(app, \"\")  # type: ignore[arg-type]\n\n    print_mock.assert_called_once()\n    assert \"root agent\" in str(print_mock.call_args.args[0])\n\n\n@pytest.mark.asyncio\nasync def test_task_command_launches_browser(runtime: Runtime, tmp_path: Path, monkeypatch) -> None:\n    app = _make_shell_app(runtime, tmp_path)\n    run_mock = Mock()\n\n    class _FakeTaskBrowserApp:\n        def __init__(self, soul: KimiSoul):\n            assert soul is app.soul\n\n        async def run(self) -> None:\n            run_mock()\n\n    monkeypatch.setattr(shell_slash, \"TaskBrowserApp\", _FakeTaskBrowserApp)\n\n    await shell_slash.task(app, \"\")  # type: ignore[arg-type]\n\n    run_mock.assert_called_once()\n\n\nclass TestShellBackgroundTaskCleanup:\n    \"\"\"Verify that Shell cancels background tasks (notification watcher, etc.) on exit.\"\"\"\n\n    def _make_shell(self, runtime: Runtime, tmp_path: Path) -> Shell:\n        agent = Agent(\n            name=\"Test Agent\",\n            system_prompt=\"Test system prompt.\",\n            toolset=EmptyToolset(),\n            runtime=runtime,\n        )\n        soul = KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n        return Shell(soul)\n\n    @pytest.mark.asyncio\n    async def test_cancel_background_tasks_cancels_all_tasks(\n        self, runtime: Runtime, tmp_path: Path\n    ) -> None:\n        shell = self._make_shell(runtime, tmp_path)\n\n        async def _forever() -> None:\n            await asyncio.Event().wait()\n\n        task1 = shell._start_background_task(_forever())\n        task2 = shell._start_background_task(_forever())\n        assert not task1.done()\n        assert not task2.done()\n\n        shell._cancel_background_tasks()\n\n        # Yield control so cancellation propagates\n        await asyncio.sleep(0)\n\n        assert task1.cancelled()\n        assert task2.cancelled()\n        assert len(shell._background_tasks) == 0\n\n    @pytest.mark.asyncio\n    async def test_cancel_background_tasks_is_idempotent(\n        self, runtime: Runtime, tmp_path: Path\n    ) -> None:\n        shell = self._make_shell(runtime, tmp_path)\n\n        async def _forever() -> None:\n            await asyncio.Event().wait()\n\n        shell._start_background_task(_forever())\n        shell._cancel_background_tasks()\n        await asyncio.sleep(0)\n        shell._cancel_background_tasks()  # second call should not raise\n\n        assert len(shell._background_tasks) == 0\n"
  },
  {
    "path": "tests/ui_and_conv/test_slash_completer.py",
    "content": "\"\"\"Tests for slash command completer behavior.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Iterable\nfrom types import SimpleNamespace\n\nfrom prompt_toolkit.completion import CompleteEvent, Completion\nfrom prompt_toolkit.document import Document\nfrom prompt_toolkit.layout.containers import ConditionalContainer, FloatContainer, HSplit, Window\nfrom prompt_toolkit.utils import get_cwidth\n\nimport kimi_cli.ui.shell.prompt as prompt_mod\nfrom kimi_cli.ui.shell.prompt import (\n    SlashCommandCompleter,\n    SlashCommandMenuControl,\n    _find_prompt_float_container,\n    _wrap_to_width,\n)\nfrom kimi_cli.utils.slashcmd import SlashCommand\n\n\ndef _noop(app: object, args: str) -> None:\n    pass\n\n\ndef _make_command(\n    name: str, *, aliases: Iterable[str] = ()\n) -> SlashCommand[Callable[[object, str], None]]:\n    return SlashCommand(\n        name=name,\n        description=f\"{name} command\",\n        func=_noop,\n        aliases=list(aliases),\n    )\n\n\ndef _completion_texts(completer: SlashCommandCompleter, text: str) -> list[str]:\n    document = Document(text=text, cursor_position=len(text))\n    event = CompleteEvent(completion_requested=True)\n    return [completion.text for completion in completer.get_completions(document, event)]\n\n\ndef _completions(completer: SlashCommandCompleter, text: str):\n    document = Document(text=text, cursor_position=len(text))\n    event = CompleteEvent(completion_requested=True)\n    return list(completer.get_completions(document, event))\n\n\ndef test_exact_command_match_hides_completions():\n    \"\"\"Exact matches should not show completions.\"\"\"\n    completer = SlashCommandCompleter(\n        [\n            _make_command(\"mcp\"),\n            _make_command(\"mcp-server\"),\n            _make_command(\"help\", aliases=[\"h\"]),\n        ]\n    )\n\n    texts = _completion_texts(completer, \"/mcp\")\n\n    assert not texts\n\n\ndef test_exact_alias_match_hides_completions():\n    \"\"\"Exact alias matches should not show completions.\"\"\"\n    completer = SlashCommandCompleter(\n        [\n            _make_command(\"help\", aliases=[\"h\"]),\n            _make_command(\"history\"),\n        ]\n    )\n\n    texts = _completion_texts(completer, \"/h\")\n\n    assert not texts\n\n\ndef test_should_complete_only_for_root_slash_token():\n    assert SlashCommandCompleter.should_complete(Document(text=\"/\", cursor_position=1))\n    assert SlashCommandCompleter.should_complete(Document(text=\"  /he\", cursor_position=5))\n    assert not SlashCommandCompleter.should_complete(Document(text=\"test /he\", cursor_position=8))\n    assert not SlashCommandCompleter.should_complete(Document(text=\"@src\", cursor_position=4))\n    assert not SlashCommandCompleter.should_complete(Document(text=\"/he next\", cursor_position=8))\n\n\ndef test_completion_display_uses_canonical_command_name():\n    completer = SlashCommandCompleter(\n        [\n            _make_command(\"help\", aliases=[\"h\", \"?\"]),\n            _make_command(\"history\"),\n        ]\n    )\n\n    completions = _completions(completer, \"/he\")\n\n    assert len(completions) == 1\n    assert completions[0].text == \"/help\"\n    assert completions[0].display_text == \"/help\"\n    assert completions[0].display_meta_text == \"help command\"\n\n\ndef test_wrap_to_width_respects_width():\n    lines = _wrap_to_width(\n        \"Help address review issue comments on the open GitHub PR\",\n        18,\n    )\n\n    assert len(lines) > 1\n    assert all(get_cwidth(line) <= 18 for line in lines)\n\n\ndef test_wrap_to_width_respects_max_lines():\n    lines = _wrap_to_width(\n        \"Help address review issue comments on the open GitHub PR for the current branch\",\n        20,\n        max_lines=2,\n    )\n\n    assert len(lines) == 2\n    assert all(get_cwidth(line) <= 20 for line in lines)\n    assert lines[-1].endswith(\"...\")\n\n\ndef test_slash_menu_preserves_unselected_state(monkeypatch):\n    completions = [\n        Completion(\n            text=\"/editor\",\n            start_position=0,\n            display=\"/editor\",\n            display_meta=\"Set default external editor for Ctrl-O\",\n        ),\n        Completion(\n            text=\"/exit\",\n            start_position=0,\n            display=\"/exit\",\n            display_meta=\"Exit the application\",\n        ),\n    ]\n    complete_state = SimpleNamespace(completions=completions, complete_index=None)\n    app = SimpleNamespace(current_buffer=SimpleNamespace(complete_state=complete_state))\n    monkeypatch.setattr(prompt_mod, \"get_app_or_none\", lambda: app)\n\n    control = SlashCommandMenuControl(left_padding=lambda: 0)\n    content = control.create_content(width=80, height=6)\n\n    rendered_lines = [\n        \"\".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count)\n    ]\n\n    assert content.line_count == 1 + len(completions)\n    assert content.cursor_position.y == 0\n    assert \"›\" not in rendered_lines[1]\n    assert \"›\" not in rendered_lines[2]\n    assert \"Ctrl-O\" in rendered_lines[1]\n    assert rendered_lines[1].count(\"/editor\") == 1\n\n\ndef test_find_prompt_float_container_supports_conditional_container_shape():\n    float_container = FloatContainer(content=Window(), floats=[])\n    root = HSplit(\n        [\n            ConditionalContainer(\n                content=Window(),\n                filter=True,\n                alternative_content=float_container,\n            )\n        ]\n    )\n\n    assert _find_prompt_float_container(root) is float_container\n\n\ndef test_find_prompt_float_container_supports_direct_float_container_shape():\n    float_container = FloatContainer(content=Window(), floats=[])\n    root = HSplit([float_container])\n\n    assert _find_prompt_float_container(root) is float_container\n"
  },
  {
    "path": "tests/ui_and_conv/test_status_block.py",
    "content": "\"\"\"Tests for _StatusBlock partial-update logic.\"\"\"\n\nfrom kimi_cli.ui.shell.visualize import _StatusBlock\nfrom kimi_cli.wire.types import StatusUpdate\n\n\ndef test_full_initial_status():\n    \"\"\"All three fields provided — should display percentage and token counts.\"\"\"\n    block = _StatusBlock(\n        StatusUpdate(\n            context_usage=0.42,\n            context_tokens=4200,\n            max_context_tokens=10000,\n        )\n    )\n    assert \"42.0%\" in block.text.plain\n    assert \"4.2k\" in block.text.plain\n    assert \"10k\" in block.text.plain\n\n\ndef test_partial_update_preserves_tokens():\n    \"\"\"Updating only context_usage should keep previous token values.\"\"\"\n    block = _StatusBlock(\n        StatusUpdate(\n            context_usage=0.30,\n            context_tokens=3000,\n            max_context_tokens=10000,\n        )\n    )\n    # Partial update: only context_usage changes\n    block.update(StatusUpdate(context_usage=0.50))\n    assert \"50.0%\" in block.text.plain\n    # Token values should be preserved, not reset to 0\n    assert \"3k\" in block.text.plain\n    assert \"10k\" in block.text.plain\n\n\ndef test_update_tokens_only_does_not_rerender():\n    \"\"\"Updating only tokens (without context_usage) should not change display.\"\"\"\n    block = _StatusBlock(\n        StatusUpdate(\n            context_usage=0.30,\n            context_tokens=3000,\n            max_context_tokens=10000,\n        )\n    )\n    old_text = block.text.plain\n    # Update only tokens — no context_usage, so no re-render\n    block.update(StatusUpdate(context_tokens=5000))\n    assert block.text.plain == old_text\n\n\ndef test_all_none_update_is_noop():\n    \"\"\"An empty StatusUpdate should change nothing.\"\"\"\n    block = _StatusBlock(\n        StatusUpdate(\n            context_usage=0.30,\n            context_tokens=3000,\n            max_context_tokens=10000,\n        )\n    )\n    old_text = block.text.plain\n    block.update(StatusUpdate())\n    assert block.text.plain == old_text\n\n\ndef test_initial_all_none():\n    \"\"\"Initial status with all None fields — text should remain empty.\"\"\"\n    block = _StatusBlock(StatusUpdate())\n    assert block.text.plain == \"\"\n"
  },
  {
    "path": "tests/ui_and_conv/test_task_browser.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport time\nfrom pathlib import Path\n\nfrom kosong.tooling.empty import EmptyToolset\n\nfrom kimi_cli.background import TaskRuntime, TaskSpec, TaskStatus\nfrom kimi_cli.soul.agent import Agent, Runtime\nfrom kimi_cli.soul.context import Context\nfrom kimi_cli.soul.kimisoul import KimiSoul\nfrom kimi_cli.ui.shell import task_browser as task_browser_module\nfrom kimi_cli.ui.shell.task_browser import TaskBrowserApp, TaskBrowserModel\n\n\ndef _make_soul(runtime: Runtime, tmp_path: Path) -> KimiSoul:\n    agent = Agent(\n        name=\"Test Agent\",\n        system_prompt=\"Test system prompt.\",\n        toolset=EmptyToolset(),\n        runtime=runtime,\n    )\n    return KimiSoul(agent, context=Context(file_backend=tmp_path / \"history.jsonl\"))\n\n\ndef _write_task(\n    runtime: Runtime,\n    task_id: str,\n    *,\n    status: TaskStatus,\n    description: str,\n    output: str = \"\",\n    created_at: float | None = None,\n    updated_at: float | None = None,\n) -> TaskSpec:\n    created = created_at if created_at is not None else time.time()\n    spec = TaskSpec(\n        id=task_id,\n        kind=\"bash\",\n        session_id=runtime.session.id,\n        description=description,\n        tool_call_id=\"tool-task\",\n        command=\"make build\",\n        shell_name=\"bash\",\n        shell_path=\"/bin/bash\",\n        cwd=str(runtime.session.work_dir),\n        timeout_s=60,\n        created_at=created,\n    )\n    store = runtime.background_tasks.store\n    store.create_task(spec)\n    store.output_path(task_id).write_text(output, encoding=\"utf-8\")\n    store.write_runtime(\n        task_id,\n        TaskRuntime(\n            status=status,\n            started_at=time.time() - 10,\n            updated_at=updated_at if updated_at is not None else time.time(),\n            finished_at=time.time()\n            if status in {\"completed\", \"failed\", \"killed\", \"lost\"}\n            else None,\n        ),\n    )\n    return spec\n\n\ndef test_task_browser_model_filters_active_tasks(runtime: Runtime, tmp_path: Path) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    running = _write_task(runtime, \"b1234567\", status=\"running\", description=\"build\", output=\"ok\\n\")\n    _write_task(runtime, \"b1234568\", status=\"completed\", description=\"done\", output=\"done\\n\")\n\n    model = TaskBrowserModel(soul=soul, filter_mode=\"active\")\n    values, selected = model.refresh()\n\n    assert [task_id for task_id, _label in values] == [running.id]\n    assert selected == running.id\n\n\ndef test_task_browser_model_preview_is_shorter_than_full_output(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    spec = _write_task(\n        runtime,\n        \"b1234567\",\n        status=\"running\",\n        description=\"build\",\n        output=\"\\n\".join(f\"line {i}\" for i in range(1, 21)),\n    )\n\n    model = TaskBrowserModel(soul=soul)\n    model.refresh(spec.id)\n\n    preview = model.preview_text(spec.id)\n    full_output = model.full_output(spec.id)\n\n    assert \"line 1\" not in preview.splitlines()\n    assert \"line 20\" in preview.splitlines()\n    assert \"line 1\" in full_output.splitlines()\n    assert \"line 20\" in full_output.splitlines()\n\n\ndef test_task_browser_model_summary_counts_all_tasks(runtime: Runtime, tmp_path: Path) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    _write_task(runtime, \"b1234567\", status=\"running\", description=\"build\")\n    _write_task(runtime, \"b1234568\", status=\"failed\", description=\"lint\")\n    _write_task(runtime, \"b1234569\", status=\"completed\", description=\"docs\")\n\n    model = TaskBrowserModel(soul=soul)\n    model.refresh()\n\n    header = \"\".join(t[1] for t in model.summary_fragments())\n\n    assert \"1 running\" in header\n    assert \"1 failed\" in header\n    assert \"1 completed\" in header\n    assert \"3 total\" in header\n\n\ndef test_task_browser_model_keeps_running_tasks_in_stable_creation_order(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    now = time.time()\n    _write_task(\n        runtime,\n        \"b1234567\",\n        status=\"running\",\n        description=\"oldest\",\n        created_at=now - 30,\n        updated_at=now - 1,\n    )\n    _write_task(\n        runtime,\n        \"b1234568\",\n        status=\"running\",\n        description=\"middle\",\n        created_at=now - 20,\n        updated_at=now - 10,\n    )\n    _write_task(\n        runtime,\n        \"b1234569\",\n        status=\"running\",\n        description=\"newest\",\n        created_at=now - 10,\n        updated_at=now - 20,\n    )\n\n    model = TaskBrowserModel(soul=soul)\n    values, _selected = model.refresh()\n\n    assert [task_id for task_id, _label in values] == [\"b1234567\", \"b1234568\", \"b1234569\"]\n\n\ndef test_task_browser_footer_keeps_hotkeys_visible_after_flash_message(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    model = TaskBrowserModel(soul=soul)\n    model.set_message(\"Stop cancelled.\")\n\n    footer = \"\".join(t[1] for t in model.footer_fragments(None))\n\n    assert \"Enter\" in footer\n    assert \"S\" in footer\n    assert \"Stop cancelled.\" in footer\n\n\ndef test_task_browser_app_construction_smoke(runtime: Runtime, tmp_path: Path) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    app = TaskBrowserApp(soul)\n\n    assert app._app.full_screen is True\n    assert app._app.erase_when_done is True\n    assert app._app.refresh_interval == 1.0\n\n    kb = app._app.key_bindings\n    assert kb is not None\n    shortcuts = {\n        tuple(getattr(key, \"value\", key) for key in binding.keys) for binding in kb.bindings\n    }\n\n    assert (\"c-m\",) in shortcuts\n    assert (\"o\",) in shortcuts\n\n\nasync def test_task_browser_output_uses_coroutine_wrapper(\n    runtime: Runtime, tmp_path: Path, monkeypatch\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    _write_task(runtime, \"b1234567\", status=\"running\", description=\"build\", output=\"line\\n\")\n    app = TaskBrowserApp(soul)\n\n    scheduled: list[object] = []\n    run_calls: list[object] = []\n\n    class _DummyApp:\n        def create_background_task(self, coro):\n            scheduled.append(coro)\n            return coro\n\n    async def fake_run_in_terminal(func, in_executor=False):\n        run_calls.append(func)\n        return None\n\n    monkeypatch.setattr(task_browser_module, \"run_in_terminal\", fake_run_in_terminal)\n\n    app._open_output(_DummyApp(), \"b1234567\")  # type: ignore[arg-type]\n\n    assert len(scheduled) == 1\n    assert asyncio.iscoroutine(scheduled[0])\n\n    await scheduled[0]\n\n    assert len(run_calls) == 1\n\n\ndef test_task_browser_toggle_filter_rebuilds_visible_task_list(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    _write_task(runtime, \"b1234567\", status=\"running\", description=\"build\")\n    _write_task(runtime, \"b1234568\", status=\"completed\", description=\"done\")\n    app = TaskBrowserApp(soul)\n\n    assert [task_id for task_id, _label in app._task_list.values] == [\"b1234567\", \"b1234568\"]\n\n    app._toggle_filter()\n\n    assert app._model.filter_mode == \"active\"\n    assert [task_id for task_id, _label in app._task_list.values] == [\"b1234567\"]\n    assert app._task_list.current_value == \"b1234567\"\n\n\ndef test_task_browser_stop_flow_sets_pending_and_can_cancel(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    spec = _write_task(runtime, \"b1234567\", status=\"running\", description=\"watch\")\n    app = TaskBrowserApp(soul)\n    app._task_list.current_value = spec.id\n\n    app._request_stop_for_selected_task()\n\n    assert app._model.pending_stop_task_id == spec.id\n    assert app._model.message == \"\"\n\n    app._cancel_stop_request()\n\n    assert app._model.pending_stop_task_id is None\n    assert app._model.current_message() == \"Stop cancelled.\"\n\n\ndef test_task_browser_confirm_stop_writes_control_for_selected_task(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    spec = _write_task(runtime, \"b1234567\", status=\"running\", description=\"watch\")\n    app = TaskBrowserApp(soul)\n    app._task_list.current_value = spec.id\n    app._request_stop_for_selected_task()\n\n    app._confirm_stop_request()\n\n    control = runtime.background_tasks.store.read_control(spec.id)\n    assert control.kill_requested_at is not None\n    assert app._model.pending_stop_task_id is None\n    assert app._model.current_message() == f\"Stop requested for task {spec.id}.\"\n\n\ndef test_task_browser_stop_on_terminal_task_surfaces_message(\n    runtime: Runtime, tmp_path: Path\n) -> None:\n    soul = _make_soul(runtime, tmp_path)\n    spec = _write_task(runtime, \"b1234567\", status=\"completed\", description=\"done\")\n    app = TaskBrowserApp(soul)\n    app._task_list.current_value = spec.id\n\n    app._request_stop_for_selected_task()\n\n    assert app._model.pending_stop_task_id is None\n    assert app._model.current_message() == f\"Task {spec.id} is already completed.\"\n"
  },
  {
    "path": "tests/ui_and_conv/test_tool_call_block.py",
    "content": "from __future__ import annotations\n\nfrom kimi_cli.ui.shell.visualize import _ToolCallBlock\n\n\nclass TestExtractFullUrl:\n    \"\"\"Tests for _ToolCallBlock._extract_full_url static method.\"\"\"\n\n    def test_fetchurl_normal_url(self):\n        url = _ToolCallBlock._extract_full_url(\n            '{\"url\": \"https://example.com/very/long/path\"}', \"FetchURL\"\n        )\n        assert url == \"https://example.com/very/long/path\"\n\n    def test_fetchurl_short_url(self):\n        url = _ToolCallBlock._extract_full_url('{\"url\": \"https://x.co\"}', \"FetchURL\")\n        assert url == \"https://x.co\"\n\n    def test_non_fetchurl_tool(self):\n        url = _ToolCallBlock._extract_full_url('{\"url\": \"https://example.com\"}', \"ReadFile\")\n        assert url is None\n\n    def test_arguments_none(self):\n        url = _ToolCallBlock._extract_full_url(None, \"FetchURL\")\n        assert url is None\n\n    def test_invalid_json(self):\n        url = _ToolCallBlock._extract_full_url(\"not json\", \"FetchURL\")\n        assert url is None\n\n    def test_missing_url_field(self):\n        url = _ToolCallBlock._extract_full_url('{\"query\": \"hello\"}', \"FetchURL\")\n        assert url is None\n\n    def test_empty_string(self):\n        url = _ToolCallBlock._extract_full_url(\"\", \"FetchURL\")\n        assert url is None\n"
  },
  {
    "path": "tests/ui_and_conv/test_visualize_running_prompt.py",
    "content": "import asyncio\nimport importlib\nfrom collections import deque\nfrom typing import Any, cast\n\nimport pytest\nfrom prompt_toolkit.buffer import Buffer\nfrom prompt_toolkit.document import Document\nfrom rich.text import Text\n\nfrom kimi_cli.ui.shell.prompt import PromptMode, UserInput\nfrom kimi_cli.wire.types import StatusUpdate, SteerInput, TextPart\n\nshell_visualize = importlib.import_module(\"kimi_cli.ui.shell.visualize\")\n_LiveView = shell_visualize._LiveView\n_PromptLiveView = shell_visualize._PromptLiveView\n\n\n@pytest.mark.asyncio\nasync def test_visualize_uses_prompt_live_view_when_prompt_session_and_steer_are_provided(\n    monkeypatch,\n) -> None:\n    called: list[tuple[str, object, object]] = []\n    bound: list[tuple[object, object]] = []\n    unbound: list[object] = []\n\n    class _PromptSession:\n        def attach_running_prompt(self, delegate) -> None:\n            called.append((\"attach\", delegate, None))\n\n        def detach_running_prompt(self, delegate) -> None:\n            called.append((\"detach\", delegate, None))\n\n    class _DummyPromptLiveView:\n        def __init__(self, initial_status, *, prompt_session, steer, cancel_event):\n            called.append((\"init\", initial_status, cancel_event))\n            assert prompt_session is not None\n            assert steer is not None\n            self.handle_local_input = lambda user_input: None\n\n        async def visualize_loop(self, wire) -> None:\n            called.append((\"loop\", wire, None))\n\n    def _unexpected_live_view(*args, **kwargs):\n        raise AssertionError(\"_LiveView should not be used\")\n\n    monkeypatch.setattr(shell_visualize, \"_PromptLiveView\", _DummyPromptLiveView)\n    monkeypatch.setattr(shell_visualize, \"_LiveView\", _unexpected_live_view)\n\n    status = StatusUpdate(context_usage=0.1)\n    wire = cast(Any, object())\n\n    await shell_visualize.visualize(\n        wire,\n        initial_status=status,\n        cancel_event=asyncio.Event(),\n        prompt_session=cast(Any, _PromptSession()),\n        steer=lambda _: None,\n        bind_running_input=lambda on_input, on_interrupt: bound.append((on_input, on_interrupt)),\n        unbind_running_input=lambda: unbound.append(True),\n    )\n\n    assert [entry[0] for entry in called] == [\"init\", \"attach\", \"loop\", \"detach\"]\n    assert called[2] == (\"loop\", wire, None)\n    assert len(bound) == 1\n    assert unbound == [True]\n\n\ndef test_render_running_prompt_body_omits_internal_status_block() -> None:\n    view = object.__new__(_PromptLiveView)\n    view._awaiting_question_other_input = False\n    view._turn_ended = False\n\n    calls: list[bool] = []\n\n    def fake_compose(*, include_status: bool = True):\n        calls.append(include_status)\n        return Text(\"body\")\n\n    view.compose = fake_compose\n\n    rendered = view.render_running_prompt_body(80)\n\n    assert calls == [False]\n    assert \"body\" in rendered.value\n\n\ndef test_running_prompt_hides_placeholder() -> None:\n    view = object.__new__(_PromptLiveView)\n    view._awaiting_question_other_input = False\n    view._turn_ended = False\n\n    assert view.running_prompt_placeholder() is None\n\n\ndef test_live_view_renders_steer_input_as_user_echo(monkeypatch) -> None:\n    view = _LiveView(StatusUpdate())\n    cleaned: list[bool] = []\n    printed: list[str] = []\n\n    monkeypatch.setattr(view, \"cleanup\", lambda *, is_interrupt: cleaned.append(is_interrupt))\n    monkeypatch.setattr(\n        shell_visualize.console,\n        \"print\",\n        lambda text: printed.append(getattr(text, \"plain\", str(text))),\n    )\n\n    view.dispatch_wire_message(SteerInput(user_input=[TextPart(text=\"A steer follow-up\")]))\n\n    assert cleaned == [False]\n    assert printed == [\"✨ A steer follow-up\"]\n\n\ndef test_live_view_flushes_current_output_before_printing_steer_input(monkeypatch) -> None:\n    view = _LiveView(StatusUpdate())\n    order: list[object] = []\n\n    monkeypatch.setattr(view, \"flush_content\", lambda: order.append(\"flush_content\"))\n    monkeypatch.setattr(view, \"flush_finished_tool_calls\", lambda: order.append(\"flush_tools\"))\n    monkeypatch.setattr(\n        shell_visualize.console,\n        \"print\",\n        lambda text: order.append((\"print\", getattr(text, \"plain\", str(text)))),\n    )\n\n    view.dispatch_wire_message(SteerInput(user_input=[TextPart(text=\"A steer follow-up\")]))\n\n    assert order[:2] == [\"flush_content\", \"flush_tools\"]\n    assert order[-1] == (\"print\", \"✨ A steer follow-up\")\n\n\ndef test_running_prompt_suppresses_duplicate_steer_echo_from_wire(monkeypatch) -> None:\n    view = object.__new__(_PromptLiveView)\n    view._pending_local_steers = deque([[TextPart(text=\"A steer follow-up\")]])\n\n    forwarded: list[object] = []\n    monkeypatch.setattr(\n        _LiveView,\n        \"dispatch_wire_message\",\n        lambda self, msg: forwarded.append(msg),\n    )\n    view.dispatch_wire_message(SteerInput(user_input=[TextPart(text=\"A steer follow-up\")]))\n\n    assert list(view._pending_local_steers) == []\n    assert forwarded == []\n\n\ndef test_running_prompt_forwards_non_matching_steer_echo_from_wire(monkeypatch) -> None:\n    view = object.__new__(_PromptLiveView)\n    view._pending_local_steers = deque([[TextPart(text=\"local steer\")]])\n\n    forwarded: list[object] = []\n    monkeypatch.setattr(\n        _LiveView,\n        \"dispatch_wire_message\",\n        lambda self, msg: forwarded.append(msg),\n    )\n    wire_msg = SteerInput(user_input=[TextPart(text=\"remote steer\")])\n    view.dispatch_wire_message(wire_msg)\n\n    assert list(view._pending_local_steers) == [[TextPart(text=\"local steer\")]]\n    assert forwarded == [wire_msg]\n\n\ndef test_handle_local_input_echoes_placeholder_display_text_but_steers_expanded_content(\n    monkeypatch,\n) -> None:\n    view = object.__new__(_PromptLiveView)\n    view._turn_ended = False\n    view._pending_local_steers = deque()\n    steered: list[list[TextPart]] = []\n    view._steer = lambda content: steered.append(list(content))\n    view._flush_prompt_refresh = lambda: None\n\n    printed: list[str] = []\n    monkeypatch.setattr(\n        shell_visualize.console,\n        \"print\",\n        lambda text: printed.append(getattr(text, \"plain\", str(text))),\n    )\n\n    view.handle_local_input(\n        UserInput(\n            mode=PromptMode.AGENT,\n            command=\"[Pasted text #1 +3 lines]\",\n            resolved_command=\"line1\\nline2\\nline3\",\n            content=[TextPart(text=\"line1\\nline2\\nline3\")],\n        )\n    )\n\n    assert printed == [\"✨ [Pasted text #1 +3 lines]\"]\n    assert steered == [[TextPart(text=\"line1\\nline2\\nline3\")]]\n    assert list(view._pending_local_steers) == [[TextPart(text=\"line1\\nline2\\nline3\")]]\n\n\ndef test_handle_local_input_ignores_finished_turn(monkeypatch) -> None:\n    view = object.__new__(_PromptLiveView)\n    view._turn_ended = True\n    view._pending_local_steers = deque()\n    view._steer = lambda _content: (_ for _ in ()).throw(AssertionError(\"should not steer\"))\n    view._flush_prompt_refresh = lambda: None\n\n    printed: list[str] = []\n    monkeypatch.setattr(\n        shell_visualize.console,\n        \"print\",\n        lambda text: printed.append(getattr(text, \"plain\", str(text))),\n    )\n\n    view.handle_local_input(\n        UserInput(\n            mode=PromptMode.AGENT,\n            command=\"ignored\",\n            resolved_command=\"ignored\",\n            content=[TextPart(text=\"ignored\")],\n        )\n    )\n\n    assert printed == []\n    assert list(view._pending_local_steers) == []\n\n\ndef test_should_prompt_question_other_for_key_shared_helper() -> None:\n    view = object.__new__(_PromptLiveView)\n    view._current_question_panel = type(\n        \"_Panel\",\n        (),\n        {\n            \"is_multi_select\": False,\n            \"should_prompt_other_input\": staticmethod(lambda: True),\n        },\n    )()\n\n    assert view._should_prompt_question_other_for_key(shell_visualize.KeyEvent.ENTER) is True\n    assert view._should_prompt_question_other_for_key(shell_visualize.KeyEvent.SPACE) is True\n\n    view._current_question_panel = type(\n        \"_Panel\",\n        (),\n        {\n            \"is_multi_select\": True,\n            \"should_prompt_other_input\": staticmethod(lambda: True),\n        },\n    )()\n\n    assert view._should_prompt_question_other_for_key(shell_visualize.KeyEvent.SPACE) is False\n\n\ndef test_submit_question_other_text_resolves_request_when_done() -> None:\n    resolved: list[object] = []\n    calls: list[str] = []\n\n    class _Request:\n        def resolve(self, answers) -> None:\n            resolved.append(answers)\n\n    class _Panel:\n        request = _Request()\n\n        @staticmethod\n        def submit_other(text: str) -> bool:\n            calls.append(text)\n            return True\n\n        @staticmethod\n        def get_answers() -> dict[str, str]:\n            return {\"q\": \"custom\"}\n\n    view = object.__new__(_PromptLiveView)\n    view._current_question_panel = _Panel()\n    view.show_next_question_request = lambda: calls.append(\"next\")\n    view.refresh_soon = lambda: calls.append(\"refresh\")\n\n    view._submit_question_other_text(\"custom\")\n\n    assert calls == [\"custom\", \"next\", \"refresh\"]\n    assert resolved == [{\"q\": \"custom\"}]\n\n\ndef test_handle_running_prompt_key_clears_buffer_for_question_panel_actions() -> None:\n    view = object.__new__(_PromptLiveView)\n    view._awaiting_question_other_input = False\n    view._turn_ended = False\n    view._current_question_panel = type(\n        \"_Panel\",\n        (),\n        {\n            \"is_multi_select\": False,\n            \"should_prompt_other_input\": staticmethod(lambda: False),\n        },\n    )()\n    view._current_approval_request_panel = None\n\n    dispatched: list[object] = []\n    view.dispatch_keyboard_event = lambda event: dispatched.append(event)\n    view._flush_prompt_refresh = lambda: None\n\n    buffer = Buffer(document=Document(text=\"draft\", cursor_position=5))\n    event = type(\"_Event\", (), {\"current_buffer\": buffer})()\n\n    view.handle_running_prompt_key(\"enter\", event)\n\n    assert buffer.text == \"\"\n    assert dispatched == [shell_visualize.KeyEvent.ENTER]\n\n\ndef test_running_prompt_handles_approval_panel_keys_and_clears_buffer() -> None:\n    view = object.__new__(_PromptLiveView)\n    view._awaiting_question_other_input = False\n    view._turn_ended = False\n    view._current_question_panel = None\n    view._current_approval_request_panel = object()\n\n    dispatched: list[object] = []\n    view.dispatch_keyboard_event = lambda event: dispatched.append(event)\n    view._flush_prompt_refresh = lambda: None\n\n    buffer = Buffer(document=Document(text=\"draft\", cursor_position=5))\n    event = type(\"_Event\", (), {\"current_buffer\": buffer})()\n\n    assert view.should_handle_running_prompt_key(\"1\") is True\n\n    view.handle_running_prompt_key(\"down\", event)\n\n    assert buffer.text == \"\"\n    assert dispatched == [shell_visualize.KeyEvent.DOWN]\n\n\ndef test_handle_running_prompt_key_clears_buffer_when_exiting_other_input_mode() -> None:\n    view = object.__new__(_PromptLiveView)\n    view._awaiting_question_other_input = True\n    view._turn_ended = False\n    view.refresh_soon = lambda: None\n    view._flush_prompt_refresh = lambda: None\n\n    buffer = Buffer(document=Document(text=\"draft\", cursor_position=5))\n    event = type(\"_Event\", (), {\"current_buffer\": buffer})()\n\n    view.handle_running_prompt_key(\"escape\", event)\n\n    assert view._awaiting_question_other_input is False\n    assert buffer.text == \"\"\n"
  },
  {
    "path": "tests/utils/test_atomic_json_write.py",
    "content": "\"\"\"Tests for atomic_json_write utility.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom pathlib import Path\n\nimport pytest\n\nfrom kimi_cli.utils.io import atomic_json_write\n\n\nclass TestAtomicJsonWrite:\n    def test_basic_write(self, tmp_path: Path):\n        target = tmp_path / \"data.json\"\n        atomic_json_write({\"key\": \"value\"}, target)\n\n        data = json.loads(target.read_text(encoding=\"utf-8\"))\n        assert data == {\"key\": \"value\"}\n\n    def test_overwrite_existing(self, tmp_path: Path):\n        target = tmp_path / \"data.json\"\n        atomic_json_write({\"old\": True}, target)\n        atomic_json_write({\"new\": True}, target)\n\n        data = json.loads(target.read_text(encoding=\"utf-8\"))\n        assert data == {\"new\": True}\n\n    def test_unicode_content(self, tmp_path: Path):\n        target = tmp_path / \"data.json\"\n        atomic_json_write({\"emoji\": \"\\U0001f680\", \"cjk\": \"你好世界\"}, target)\n\n        data = json.loads(target.read_text(encoding=\"utf-8\"))\n        assert data[\"emoji\"] == \"\\U0001f680\"\n        assert data[\"cjk\"] == \"你好世界\"\n        # ensure_ascii=False means raw unicode in file, not \\uXXXX escapes\n        raw = target.read_text(encoding=\"utf-8\")\n        assert \"\\\\u\" not in raw\n\n    def test_no_leftover_tmp_on_success(self, tmp_path: Path):\n        target = tmp_path / \"data.json\"\n        atomic_json_write({\"a\": 1}, target)\n\n        tmp_files = list(tmp_path.glob(\"*.tmp\"))\n        assert tmp_files == []\n\n    def test_preserves_old_file_on_error(self, tmp_path: Path, monkeypatch):\n        target = tmp_path / \"data.json\"\n        atomic_json_write({\"original\": True}, target)\n\n        original_dump = json.dump\n\n        def bad_dump(*args, **kwargs):\n            original_dump(*args, **kwargs)\n            raise OSError(\"disk full\")\n\n        monkeypatch.setattr(json, \"dump\", bad_dump)\n\n        with pytest.raises(OSError, match=\"disk full\"):\n            atomic_json_write({\"replacement\": True}, target)\n\n        monkeypatch.undo()\n        data = json.loads(target.read_text(encoding=\"utf-8\"))\n        assert data == {\"original\": True}\n\n    def test_cleans_tmp_on_error(self, tmp_path: Path, monkeypatch):\n        target = tmp_path / \"data.json\"\n\n        def bad_dump(*args, **kwargs):\n            raise OSError(\"disk full\")\n\n        monkeypatch.setattr(json, \"dump\", bad_dump)\n\n        with pytest.raises(OSError, match=\"disk full\"):\n            atomic_json_write({\"data\": 1}, target)\n\n        tmp_files = list(tmp_path.glob(\"*.tmp\"))\n        assert tmp_files == []\n        # Target should not have been created\n        assert not target.exists()\n\n    def test_write_to_nonexistent_parent_raises(self, tmp_path: Path):\n        target = tmp_path / \"nonexistent\" / \"data.json\"\n\n        with pytest.raises(FileNotFoundError):\n            atomic_json_write({\"data\": 1}, target)\n\n    def test_indent_formatting(self, tmp_path: Path):\n        target = tmp_path / \"data.json\"\n        atomic_json_write({\"a\": 1, \"b\": [2, 3]}, target)\n\n        raw = target.read_text(encoding=\"utf-8\")\n        # indent=2 means the JSON should be pretty-printed\n        assert \"\\n\" in raw\n        assert '  \"a\": 1' in raw\n\n    def test_written_file_is_valid_on_disk(self, tmp_path: Path):\n        \"\"\"Verify that the file can be re-read immediately (data flushed, not just buffered).\"\"\"\n        target = tmp_path / \"data.json\"\n        atomic_json_write({\"key\": \"value\"}, target)\n\n        # Open with a fresh fd to bypass any OS-level caching\n        fd = os.open(str(target), os.O_RDONLY)\n        try:\n            content = os.read(fd, 4096)\n        finally:\n            os.close(fd)\n        data = json.loads(content.decode(\"utf-8\"))\n        assert data == {\"key\": \"value\"}\n"
  },
  {
    "path": "tests/utils/test_broadcast_queue.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom kimi_cli.utils.aioqueue import QueueShutDown\nfrom kimi_cli.utils.broadcast import BroadcastQueue\n\n\nasync def test_basic_publish_subscribe():\n    \"\"\"Test basic publish/subscribe functionality.\"\"\"\n    broadcast = BroadcastQueue()\n    queue1 = broadcast.subscribe()\n    queue2 = broadcast.subscribe()\n\n    await broadcast.publish(\"test_message\")\n\n    assert await queue1.get() == \"test_message\"\n    assert await queue2.get() == \"test_message\"\n\n\nasync def test_publish_nowait():\n    \"\"\"Test publish_nowait publishes immediately without blocking.\"\"\"\n    broadcast = BroadcastQueue()\n    queue = broadcast.subscribe()\n\n    broadcast.publish_nowait(\"fast_message\")\n\n    assert await queue.get() == \"fast_message\"\n\n\nasync def test_unsubscribe():\n    \"\"\"Test that unsubscribed queues don't receive messages.\"\"\"\n    broadcast = BroadcastQueue()\n    queue1 = broadcast.subscribe()\n    queue2 = broadcast.subscribe()\n\n    broadcast.unsubscribe(queue2)\n    await broadcast.publish(\"only_for_queue1\")\n\n    assert await queue1.get() == \"only_for_queue1\"\n    assert queue2.qsize() == 0\n\n\nasync def test_multiple_subscribers_receive_same_message():\n    \"\"\"Test all subscribers receive the same message.\"\"\"\n    broadcast = BroadcastQueue()\n    queues = [broadcast.subscribe() for _ in range(5)]\n\n    test_msg = {\"type\": \"test\", \"data\": [1, 2, 3]}\n    await broadcast.publish(test_msg)\n\n    results = await asyncio.gather(*(q.get() for q in queues))\n    assert all(result == test_msg for result in results)\n\n\nasync def test_shutdown():\n    \"\"\"Test shutdown closes all queues.\"\"\"\n    broadcast = BroadcastQueue()\n    queue1 = broadcast.subscribe()\n    queue2 = broadcast.subscribe()\n\n    broadcast.shutdown()\n\n    with pytest.raises(QueueShutDown):\n        queue1.get_nowait()\n    with pytest.raises(QueueShutDown):\n        queue2.get_nowait()\n    assert len(broadcast._queues) == 0\n\n\nasync def test_publish_to_empty_queue():\n    \"\"\"Test publishing when no subscribers doesn't throw error.\"\"\"\n    broadcast = BroadcastQueue()\n\n    # Should not raise any exception\n    await broadcast.publish(\"no_subscribers\")\n    broadcast.publish_nowait(\"no_subscribers\")\n"
  },
  {
    "path": "tests/utils/test_changelog.py",
    "content": "from __future__ import annotations\n\nfrom kimi_cli.utils.changelog import ReleaseEntry, parse_changelog\n\n\ndef test_changelog_parser():\n    changelog = \"\"\"\n# Changelog\n\n<!--\nRelease notes will be parsed and available as /release-notes\nThe parser extracts for each version:\n  - a short description (first paragraph after the version header)\n  - bullet entries beginning with \"- \" under that version (across any subsections)\nInternal builds may append content to the Unreleased section.\nOnly write entries that are worth mentioning to users.\n-->\n\n## Unreleased\n\n### Added\n- Added /release-notes command\n\n### Fixed\n- Fixed a bug\n\n## 0.10.1 (2025-09-18)\n\nWe now have release notes!\n- Made slash commands look slightly better\n    \"\"\"\n    assert {\n        \"Unreleased\": ReleaseEntry(\n            description=\"\", entries=[\"Added /release-notes command\", \"Fixed a bug\"]\n        ),\n        \"0.10.1\": ReleaseEntry(\n            description=\"We now have release notes!\",\n            entries=[\"Made slash commands look slightly better\"],\n        ),\n    } == parse_changelog(changelog)\n"
  },
  {
    "path": "tests/utils/test_diff_utils.py",
    "content": "from __future__ import annotations\n\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.utils.diff import build_diff_blocks, format_unified_diff\nfrom kimi_cli.wire.types import DiffDisplayBlock\n\n\ndef test_build_diff_blocks_simple_change() -> None:\n    old_text = \"\"\"\nLine one\nLine two\nLine three\nLine four\nLine five\n\"\"\".strip()\n    new_text = \"\"\"\nLine one 123\nLine two\nLine three\nLine four\nLine five modified\nLine six added\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/simple.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/simple.txt\",\n                old_text=\"\"\"\\\nLine one\nLine two\nLine three\nLine four\nLine five\\\n\"\"\",\n                new_text=\"\"\"\\\nLine one 123\nLine two\nLine three\nLine four\nLine five modified\nLine six added\\\n\"\"\",\n            ),\n        ]\n    )\n\n\ndef test_build_diff_blocks_insert_only() -> None:\n    old_text = \"\"\"\nLine one\nLine two\n\"\"\".strip()\n    new_text = \"\"\"\nLine one\nLine two\nLine three\nLine four\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/insert.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/insert.txt\",\n                old_text=\"\"\"\\\nLine one\nLine two\\\n\"\"\",\n                new_text=\"\"\"\\\nLine one\nLine two\nLine three\nLine four\\\n\"\"\",\n            )\n        ]\n    )\n\n\ndef test_build_diff_blocks_delete_only() -> None:\n    old_text = \"\"\"\nLine one\nLine two\nLine three\nLine four\n\"\"\".strip()\n    new_text = \"\"\"\nLine one\nLine four\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/delete.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/delete.txt\",\n                old_text=\"\"\"\\\nLine one\nLine two\nLine three\nLine four\\\n\"\"\",\n                new_text=\"\"\"\\\nLine one\nLine four\\\n\"\"\",\n            )\n        ]\n    )\n\n\ndef test_build_diff_blocks_multiline_replace() -> None:\n    old_text = \"\"\"\nAlpha\nBravo\nCharlie\nDelta\nEcho\n\"\"\".strip()\n    new_text = \"\"\"\nAlpha\nXray\nYankee\nDelta\nEcho\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/replace.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/replace.txt\",\n                old_text=\"\"\"\\\nAlpha\nBravo\nCharlie\nDelta\nEcho\\\n\"\"\",\n                new_text=\"\"\"\\\nAlpha\nXray\nYankee\nDelta\nEcho\\\n\"\"\",\n            )\n        ]\n    )\n\n\ndef test_build_diff_blocks_complex_change() -> None:\n    old_text = \"\"\"\nLine one\nLine two\nLine three\nLine four\nLine five\nLine six\nLine seven\nLine eight\nLine nine\nLine ten\n\"\"\".strip()\n    new_text = \"\"\"\nLine one\nLine two updated\nLine three\nLine five\nLine six\nLine seven\nLine eight inserted A\nLine eight inserted B\nLine eight\nLine nine updated\nLine ten\nLine eleven\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/complex.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/complex.txt\",\n                old_text=\"\"\"\\\nLine one\nLine two\nLine three\nLine four\nLine five\nLine six\nLine seven\nLine eight\nLine nine\nLine ten\\\n\"\"\",\n                new_text=\"\"\"\\\nLine one\nLine two updated\nLine three\nLine five\nLine six\nLine seven\nLine eight inserted A\nLine eight inserted B\nLine eight\nLine nine updated\nLine ten\nLine eleven\\\n\"\"\",\n            ),\n        ]\n    )\n\n\ndef test_build_diff_blocks_split_by_context_window() -> None:\n    old_text = \"\"\"\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\nLine 11\nLine 12\nLine 13\nLine 14\nLine 15\nLine 16\n\"\"\".strip()\n    new_text = \"\"\"\nLine 1\nLine 2 updated\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\nLine 11\nLine 12\nLine 13\nLine 14 updated\nLine 15\nLine 16\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/context.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/context.txt\",\n                old_text=\"\"\"\\\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\\\n\"\"\",\n                new_text=\"\"\"\\\nLine 1\nLine 2 updated\nLine 3\nLine 4\nLine 5\\\n\"\"\",\n            ),\n            DiffDisplayBlock(\n                path=\"/tmp/context.txt\",\n                old_text=\"\"\"\\\nLine 11\nLine 12\nLine 13\nLine 14\nLine 15\nLine 16\\\n\"\"\",\n                new_text=\"\"\"\\\nLine 11\nLine 12\nLine 13\nLine 14 updated\nLine 15\nLine 16\\\n\"\"\",\n            ),\n        ]\n    )\n\n\ndef test_build_diff_blocks_old_empty() -> None:\n    old_text = \"\"\n    new_text = \"\"\"\nLine 1\nLine 2\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/old-empty.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/old-empty.txt\",\n                old_text=\"\",\n                new_text=\"\"\"\\\nLine 1\nLine 2\\\n\"\"\",\n            )\n        ]\n    )\n\n\ndef test_build_diff_blocks_new_empty() -> None:\n    old_text = \"\"\"\nLine 1\nLine 2\n\"\"\".strip()\n    new_text = \"\"\n\n    blocks = build_diff_blocks(\"/tmp/new-empty.txt\", old_text, new_text)\n\n    assert blocks == snapshot(\n        [\n            DiffDisplayBlock(\n                path=\"/tmp/new-empty.txt\",\n                old_text=\"\"\"\\\nLine 1\nLine 2\\\n\"\"\",\n                new_text=\"\",\n            )\n        ]\n    )\n\n\ndef test_build_diff_blocks_both_empty() -> None:\n    blocks = build_diff_blocks(\"/tmp/both-empty.txt\", \"\", \"\")\n\n    assert blocks == snapshot([])\n\n\ndef test_build_diff_blocks_equal_text() -> None:\n    text = \"\"\"\nLine 1\nLine 2\n\"\"\".strip()\n\n    blocks = build_diff_blocks(\"/tmp/equal.txt\", text, text)\n\n    assert blocks == snapshot([])\n\n\ndef test_format_unified_diff_with_path() -> None:\n    old_text = \"alpha\\nbeta\\n\"\n    new_text = \"alpha\\nbravo\\n\"\n\n    diff_text = format_unified_diff(old_text, new_text, \"demo.txt\")\n\n    assert diff_text == snapshot(\n        \"--- a/demo.txt\\n+++ b/demo.txt\\n@@ -1,2 +1,2 @@\\n alpha\\n-beta\\n+bravo\\n\"\n    )\n\n\ndef test_format_unified_diff_without_path() -> None:\n    old_text = \"alpha\\nbeta\\n\"\n    new_text = \"alpha\\nbravo\\n\"\n\n    diff_text = format_unified_diff(old_text, new_text)\n\n    assert diff_text == snapshot(\"--- a/file\\n+++ b/file\\n@@ -1,2 +1,2 @@\\n alpha\\n-beta\\n+bravo\\n\")\n\n\ndef test_format_unified_diff_without_header() -> None:\n    old_text = \"alpha\\nbeta\\n\"\n    new_text = \"alpha\\nbravo\\n\"\n\n    diff_text = format_unified_diff(\n        old_text,\n        new_text,\n        \"demo.txt\",\n        include_file_header=False,\n    )\n\n    assert diff_text == snapshot(\"@@ -1,2 +1,2 @@\\n alpha\\n-beta\\n+bravo\\n\")\n"
  },
  {
    "path": "tests/utils/test_editor.py",
    "content": "\"\"\"Tests for the external editor utilities.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport stat\nimport subprocess\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom kimi_cli.utils.editor import (\n    edit_text_in_editor,\n    get_editor_command,\n)\n\n# ---------------------------------------------------------------------------\n# get_editor_command\n# ---------------------------------------------------------------------------\n\n\nclass TestGetEditorCommand:\n    \"\"\"Tests for get_editor_command().\"\"\"\n\n    def test_configured_takes_highest_priority(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Configured editor should override $VISUAL, $EDITOR, and auto-detect.\"\"\"\n        monkeypatch.setenv(\"VISUAL\", \"emacs\")\n        monkeypatch.setenv(\"EDITOR\", \"nano\")\n        assert get_editor_command(\"vim\") == [\"vim\"]\n\n    def test_configured_with_args(self):\n        \"\"\"Configured editor string with arguments should be split correctly.\"\"\"\n        assert get_editor_command(\"code --wait\") == [\"code\", \"--wait\"]\n        assert get_editor_command(\"/usr/local/bin/vim -u NONE\") == [\n            \"/usr/local/bin/vim\",\n            \"-u\",\n            \"NONE\",\n        ]\n\n    def test_configured_invalid_shlex(self):\n        \"\"\"Invalid shlex input should fall through to env vars.\"\"\"\n        # Unterminated quote is invalid for shlex.split\n        with patch.dict(os.environ, {\"VISUAL\": \"vim\"}, clear=False):\n            result = get_editor_command(\"vim 'unterminated\")\n            assert result == [\"vim\"]  # Falls through to $VISUAL\n\n    def test_visual_env_var(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"$VISUAL should take priority over $EDITOR.\"\"\"\n        monkeypatch.setenv(\"VISUAL\", \"code --wait\")\n        monkeypatch.setenv(\"EDITOR\", \"nano\")\n        assert get_editor_command() == [\"code\", \"--wait\"]\n\n    def test_editor_env_var(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"$EDITOR should be used when $VISUAL is not set.\"\"\"\n        monkeypatch.delenv(\"VISUAL\", raising=False)\n        monkeypatch.setenv(\"EDITOR\", \"vim\")\n        assert get_editor_command() == [\"vim\"]\n\n    def test_invalid_visual_falls_through_to_editor(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Invalid $VISUAL should fall through to $EDITOR.\"\"\"\n        monkeypatch.setenv(\"VISUAL\", \"vim 'unterminated\")\n        monkeypatch.setenv(\"EDITOR\", \"nano\")\n        assert get_editor_command() == [\"nano\"]\n\n    def test_auto_detect_order(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Auto-detect should try candidates in order: code, vim, vi, nano.\"\"\"\n        monkeypatch.delenv(\"VISUAL\", raising=False)\n        monkeypatch.delenv(\"EDITOR\", raising=False)\n\n        # Only vim is available\n        def fake_which(binary: str) -> str | None:\n            return \"/usr/bin/vim\" if binary == \"vim\" else None\n\n        with patch(\"kimi_cli.utils.editor.shutil.which\", side_effect=fake_which):\n            assert get_editor_command() == [\"vim\"]\n\n    def test_auto_detect_prefers_code(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Auto-detect should prefer 'code --wait' when available.\"\"\"\n        monkeypatch.delenv(\"VISUAL\", raising=False)\n        monkeypatch.delenv(\"EDITOR\", raising=False)\n\n        def fake_which(binary: str) -> str | None:\n            return f\"/usr/bin/{binary}\" if binary in (\"code\", \"vim\") else None\n\n        with patch(\"kimi_cli.utils.editor.shutil.which\", side_effect=fake_which):\n            assert get_editor_command() == [\"code\", \"--wait\"]\n\n    def test_returns_none_when_nothing_available(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Should return None when no editor is found.\"\"\"\n        monkeypatch.delenv(\"VISUAL\", raising=False)\n        monkeypatch.delenv(\"EDITOR\", raising=False)\n\n        with patch(\"kimi_cli.utils.editor.shutil.which\", return_value=None):\n            assert get_editor_command() is None\n\n    def test_empty_configured_is_ignored(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Empty configured string should be treated as not configured.\"\"\"\n        monkeypatch.setenv(\"EDITOR\", \"nano\")\n        assert get_editor_command(\"\") == [\"nano\"]\n\n    def test_empty_env_vars_are_ignored(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Empty $VISUAL/$EDITOR should fall through to auto-detect.\"\"\"\n        monkeypatch.setenv(\"VISUAL\", \"\")\n        monkeypatch.setenv(\"EDITOR\", \"\")\n\n        with patch(\"kimi_cli.utils.editor.shutil.which\", return_value=None):\n            assert get_editor_command() is None\n\n\n# ---------------------------------------------------------------------------\n# edit_text_in_editor\n# ---------------------------------------------------------------------------\n\n\nclass TestEditTextInEditor:\n    \"\"\"Tests for edit_text_in_editor().\"\"\"\n\n    def _make_fake_editor(self, tmp_path: Path, *, modify: bool = True) -> str:\n        \"\"\"Create a shell script that acts as a fake editor.\n\n        If *modify* is True, the script appends text to the file and bumps the\n        mtime to a well-known future timestamp so the mtime check in\n        ``edit_text_in_editor`` reliably detects the change — even on\n        filesystems with only 1-second mtime resolution (e.g. tmpfs in CI).\n        If False, it does nothing (simulating :q! without saving).\n        \"\"\"\n        script = tmp_path / \"fake-editor.sh\"\n        if modify:\n            # touch -t YYYYMMDDhhmm is POSIX; guarantees mtime differs.\n            script.write_text('#!/bin/sh\\necho \"edited line\" >> \"$1\"\\ntouch -t 209901010000 \"$1\"\\n')\n        else:\n            script.write_text(\"#!/bin/sh\\nexit 0\\n\")\n        script.chmod(script.stat().st_mode | stat.S_IEXEC)\n        return str(script)\n\n    def test_basic_edit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Editor modifies the file — should return edited content.\"\"\"\n        editor = self._make_fake_editor(tmp_path, modify=True)\n        result = edit_text_in_editor(\"original text\", configured=editor)\n        assert result is not None\n        assert \"original text\" in result\n        assert \"edited line\" in result\n\n    def test_no_save_returns_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"Editor exits without modifying — should return None.\"\"\"\n        editor = self._make_fake_editor(tmp_path, modify=False)\n        result = edit_text_in_editor(\"original text\", configured=editor)\n        assert result is None\n\n    def test_editor_nonzero_exit_returns_none(self, tmp_path: Path):\n        \"\"\"Editor exiting with non-zero code — should return None.\"\"\"\n        script = tmp_path / \"failing-editor.sh\"\n        script.write_text(\"#!/bin/sh\\nexit 1\\n\")\n        script.chmod(script.stat().st_mode | stat.S_IEXEC)\n\n        result = edit_text_in_editor(\"text\", configured=str(script))\n        assert result is None\n\n    def test_editor_not_found_returns_none(self):\n        \"\"\"Non-existent editor binary — should return None.\"\"\"\n        result = edit_text_in_editor(\"text\", configured=\"/nonexistent/editor\")\n        assert result is None\n\n    def test_no_editor_available_returns_none(self, monkeypatch: pytest.MonkeyPatch):\n        \"\"\"No editor available at all — should return None.\"\"\"\n        monkeypatch.delenv(\"VISUAL\", raising=False)\n        monkeypatch.delenv(\"EDITOR\", raising=False)\n        with patch(\"kimi_cli.utils.editor.shutil.which\", return_value=None):\n            result = edit_text_in_editor(\"text\")\n            assert result is None\n\n    def test_trailing_newline_stripped(self, tmp_path: Path):\n        \"\"\"Editors typically add a trailing newline — it should be stripped.\"\"\"\n        script = tmp_path / \"newline-editor.sh\"\n        script.write_text('#!/bin/sh\\nprintf \"hello world\\\\n\" > \"$1\"\\ntouch -t 209901010000 \"$1\"\\n')\n        script.chmod(script.stat().st_mode | stat.S_IEXEC)\n\n        result = edit_text_in_editor(\"\", configured=str(script))\n        assert result == \"hello world\"\n\n    def test_multiple_trailing_newlines_only_one_stripped(self, tmp_path: Path):\n        \"\"\"Only one trailing newline should be stripped.\"\"\"\n        script = tmp_path / \"multi-nl-editor.sh\"\n        script.write_text(\n            '#!/bin/sh\\nprintf \"line1\\\\nline2\\\\n\\\\n\" > \"$1\"\\ntouch -t 209901010000 \"$1\"\\n'\n        )\n        script.chmod(script.stat().st_mode | stat.S_IEXEC)\n\n        result = edit_text_in_editor(\"\", configured=str(script))\n        assert result == \"line1\\nline2\\n\"\n\n    def test_temp_file_cleaned_up(self, tmp_path: Path):\n        \"\"\"Temporary file should be removed after editing.\"\"\"\n        editor = self._make_fake_editor(tmp_path, modify=True)\n        created_files_before = set(Path(tempfile.gettempdir()).glob(\"kimi-edit-*\"))\n\n        edit_text_in_editor(\"text\", configured=editor)\n\n        created_files_after = set(Path(tempfile.gettempdir()).glob(\"kimi-edit-*\"))\n        # No new kimi-edit temp files should remain\n        assert created_files_after == created_files_before\n\n    def test_temp_file_cleaned_up_on_error(self, tmp_path: Path):\n        \"\"\"Temporary file should be cleaned up even when editor fails.\"\"\"\n        created_files_before = set(Path(tempfile.gettempdir()).glob(\"kimi-edit-*\"))\n\n        edit_text_in_editor(\"text\", configured=\"/nonexistent/editor\")\n\n        created_files_after = set(Path(tempfile.gettempdir()).glob(\"kimi-edit-*\"))\n        assert created_files_after == created_files_before\n\n    def test_empty_input_text(self, tmp_path: Path):\n        \"\"\"Editing empty text should work.\"\"\"\n        script = tmp_path / \"write-editor.sh\"\n        script.write_text('#!/bin/sh\\nprintf \"new content\" > \"$1\"\\ntouch -t 209901010000 \"$1\"\\n')\n        script.chmod(script.stat().st_mode | stat.S_IEXEC)\n\n        result = edit_text_in_editor(\"\", configured=str(script))\n        assert result == \"new content\"\n\n    def test_unicode_content(self, tmp_path: Path):\n        \"\"\"Unicode content should roundtrip correctly.\"\"\"\n        editor = self._make_fake_editor(tmp_path, modify=True)\n        result = edit_text_in_editor(\"你好世界 🌍\", configured=editor)\n        assert result is not None\n        assert \"你好世界 🌍\" in result\n\n    def test_multiline_content(self, tmp_path: Path):\n        \"\"\"Multi-line content should be preserved.\"\"\"\n        editor = self._make_fake_editor(tmp_path, modify=True)\n        original = \"line 1\\nline 2\\nline 3\"\n        result = edit_text_in_editor(original, configured=editor)\n        assert result is not None\n        assert \"line 1\\nline 2\\nline 3\" in result\n\n    def test_subprocess_call_uses_clean_env(self, tmp_path: Path):\n        \"\"\"subprocess.call should be invoked with get_clean_env().\"\"\"\n        editor = self._make_fake_editor(tmp_path, modify=True)\n\n        with patch(\"kimi_cli.utils.editor.subprocess.call\", return_value=0) as mock_call:\n            # Need to also patch get_editor_command to return our editor\n            # since subprocess.call is mocked, mtime won't change\n            edit_text_in_editor(\"text\", configured=editor)\n            assert mock_call.called\n            _, kwargs = mock_call.call_args\n            assert \"env\" in kwargs\n\n    def test_temp_file_has_md_suffix(self, tmp_path: Path):\n        \"\"\"Temporary file should have .md suffix for syntax highlighting.\"\"\"\n        captured_path = []\n\n        original_call = subprocess.call\n\n        def spy_call(cmd, **kwargs):\n            # cmd is like [editor, tmpfile]\n            captured_path.append(cmd[-1])\n            return original_call(cmd, **kwargs)\n\n        editor = self._make_fake_editor(tmp_path, modify=True)\n        with patch(\"kimi_cli.utils.editor.subprocess.call\", side_effect=spy_call):\n            edit_text_in_editor(\"text\", configured=editor)\n\n        assert len(captured_path) == 1\n        assert captured_path[0].endswith(\".md\")\n\n\n# ---------------------------------------------------------------------------\n# Config integration\n# ---------------------------------------------------------------------------\n\n\nclass TestConfigIntegration:\n    \"\"\"Tests for editor config field.\"\"\"\n\n    def test_default_editor_empty_by_default(self):\n        \"\"\"default_editor should default to empty string.\"\"\"\n        from kimi_cli.config import get_default_config\n\n        config = get_default_config()\n        assert config.default_editor == \"\"\n\n    def test_load_config_with_editor(self):\n        \"\"\"Config with default_editor should load correctly.\"\"\"\n        from kimi_cli.config import load_config_from_string\n\n        config = load_config_from_string('default_editor = \"vim\"\\n')\n        assert config.default_editor == \"vim\"\n\n    def test_load_config_with_editor_args(self):\n        \"\"\"Config with editor command containing args should load correctly.\"\"\"\n        from kimi_cli.config import load_config_from_string\n\n        config = load_config_from_string('default_editor = \"code --wait\"\\n')\n        assert config.default_editor == \"code --wait\"\n\n    def test_existing_config_without_editor_field(self):\n        \"\"\"Existing config without default_editor should default to empty.\"\"\"\n        from kimi_cli.config import load_config_from_string\n\n        config = load_config_from_string('default_model = \"\"\\n')\n        assert config.default_editor == \"\"\n"
  },
  {
    "path": "tests/utils/test_file_utils.py",
    "content": "from __future__ import annotations\n\nfrom kimi_cli.tools.file.utils import detect_file_type\n\n\ndef test_detect_file_type_suffixes():\n    assert detect_file_type(\"image.PNG\").kind == \"image\"\n    assert detect_file_type(\"clip.mp4\").kind == \"video\"\n    assert detect_file_type(\"notes.txt\").kind == \"text\"\n    assert detect_file_type(\"Makefile\").kind == \"text\"\n    assert detect_file_type(\".env\").kind == \"text\"\n    assert detect_file_type(\"icon.svg\").kind == \"text\"\n    assert detect_file_type(\"archive.tar.gz\").kind == \"unknown\"\n    assert detect_file_type(\"my file.pdf\").kind == \"unknown\"\n    # TypeScript files should not be misidentified as MPEG Transport Stream (video/mp2t)\n    assert detect_file_type(\"app.ts\").kind == \"text\"\n    assert detect_file_type(\"component.tsx\").kind == \"text\"\n    assert detect_file_type(\"module.mts\").kind == \"text\"\n    assert detect_file_type(\"common.cts\").kind == \"text\"\n\n\ndef test_detect_file_type_header_overrides():\n    png_header = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"pngdata\"\n    mp4_header = b\"\\x00\\x00\\x00\\x18ftypmp42\\x00\\x00\\x00\\x00mp42isom\"\n    iso5_header = b\"\\x00\\x00\\x00\\x18ftypiso5\\x00\\x00\\x00\\x00iso5isom\"\n    binary_header = b\"\\x00\\x00binary\"\n\n    assert detect_file_type(\"sample\", header=png_header).kind == \"image\"\n    assert detect_file_type(\"sample.bin\", header=png_header).mime_type == \"image/png\"\n    assert detect_file_type(\"sample\", header=mp4_header).kind == \"video\"\n    assert detect_file_type(\"sample\", header=iso5_header).kind == \"video\"\n    assert detect_file_type(\"sample.png\", header=mp4_header).kind == \"image\"\n    assert detect_file_type(\"notes.txt\", header=binary_header).kind == \"unknown\"\n"
  },
  {
    "path": "tests/utils/test_frontmatter.py",
    "content": "from pathlib import Path\nfrom tempfile import TemporaryDirectory\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.utils.frontmatter import read_frontmatter\n\n\ndef test_read_frontmatter_parses_yaml():\n    with TemporaryDirectory() as tmpdir:\n        path = Path(tmpdir) / \"frontmatter.md\"\n        path.write_text(\n            \"\"\"---\nname: test-skill\ndescription: A test skill\nextra: 123\n---\n\n# Body\n\"\"\",\n            encoding=\"utf-8\",\n        )\n\n        data = read_frontmatter(path)\n\n        assert data == {\n            \"name\": \"test-skill\",\n            \"description\": \"A test skill\",\n            \"extra\": 123,\n        }\n\n\ndef test_read_frontmatter_invalid_yaml():\n    with TemporaryDirectory() as tmpdir:\n        path = Path(tmpdir) / \"frontmatter.md\"\n        path.write_text(\n            \"\"\"---\nname: \"unterminated\ndescription: oops\n---\n\"\"\",\n            encoding=\"utf-8\",\n        )\n\n        with pytest.raises(ValueError) as exc_info:\n            read_frontmatter(path)\n\n        assert str(exc_info.value) == snapshot(\"Invalid frontmatter YAML.\")\n"
  },
  {
    "path": "tests/utils/test_is_within_workspace.py",
    "content": "\"\"\"Tests for is_within_workspace and is_within_directory utility functions.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import PurePosixPath, PureWindowsPath\n\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.utils.path import is_within_directory, is_within_workspace\n\n\ndef test_within_work_dir():\n    \"\"\"Path inside work_dir should be accepted.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    assert is_within_workspace(KaosPath(\"/home/user/project/src/main.py\"), work_dir)\n\n\ndef test_work_dir_itself():\n    \"\"\"Work dir itself should be accepted.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    assert is_within_workspace(work_dir, work_dir)\n\n\ndef test_outside_work_dir_no_additional():\n    \"\"\"Path outside work_dir with no additional dirs should be rejected.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    assert not is_within_workspace(KaosPath(\"/home/user/other/file.py\"), work_dir)\n\n\ndef test_within_additional_dir():\n    \"\"\"Path inside an additional dir should be accepted.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    additional = [KaosPath(\"/home/user/lib\")]\n    assert is_within_workspace(KaosPath(\"/home/user/lib/module.py\"), work_dir, additional)\n\n\ndef test_additional_dir_itself():\n    \"\"\"The additional dir path itself should be accepted.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    additional = [KaosPath(\"/home/user/lib\")]\n    assert is_within_workspace(KaosPath(\"/home/user/lib\"), work_dir, additional)\n\n\ndef test_outside_all_dirs():\n    \"\"\"Path outside both work_dir and additional dirs should be rejected.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    additional = [KaosPath(\"/home/user/lib\")]\n    assert not is_within_workspace(KaosPath(\"/tmp/evil\"), work_dir, additional)\n\n\ndef test_multiple_additional_dirs():\n    \"\"\"Path within any of multiple additional dirs should be accepted.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    additional = [KaosPath(\"/home/user/lib\"), KaosPath(\"/opt/shared\")]\n    assert is_within_workspace(KaosPath(\"/opt/shared/config.json\"), work_dir, additional)\n\n\ndef test_prefix_attack_work_dir():\n    \"\"\"Path sharing prefix but not actually inside work_dir should be rejected.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    assert not is_within_workspace(KaosPath(\"/home/user/project-evil/hack.py\"), work_dir)\n\n\ndef test_prefix_attack_additional_dir():\n    \"\"\"Path sharing prefix but not actually inside additional dir should be rejected.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    additional = [KaosPath(\"/home/user/lib\")]\n    assert not is_within_workspace(KaosPath(\"/home/user/lib-evil/hack.py\"), work_dir, additional)\n\n\ndef test_empty_additional_dirs():\n    \"\"\"Empty additional_dirs sequence should not cause errors.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    assert is_within_workspace(KaosPath(\"/home/user/project/a.py\"), work_dir, [])\n    assert not is_within_workspace(KaosPath(\"/tmp/x\"), work_dir, [])\n\n\ndef test_default_additional_dirs():\n    \"\"\"Default parameter (no additional_dirs) should work.\"\"\"\n    work_dir = KaosPath(\"/home/user/project\")\n    assert is_within_workspace(KaosPath(\"/home/user/project/a.py\"), work_dir)\n    assert not is_within_workspace(KaosPath(\"/tmp/x\"), work_dir)\n\n\n# ── Cross-platform path tests ──────────────────────────────────────────────\n#\n# is_within_directory uses PurePath(str(path)).relative_to(...), which delegates\n# to the platform's PurePath implementation. These tests verify the underlying\n# logic works with both POSIX and Windows-style paths by testing PurePath\n# directly, ensuring no hardcoded \"/\" comparisons sneak in.\n\n\ndef test_purepath_relative_to_posix():\n    \"\"\"PurePosixPath.relative_to correctly detects containment.\"\"\"\n    base = PurePosixPath(\"/home/user/project\")\n    assert PurePosixPath(\"/home/user/project/src/main.py\").is_relative_to(base)\n    assert not PurePosixPath(\"/home/user/project-evil/hack.py\").is_relative_to(base)\n    assert not PurePosixPath(\"/tmp/other\").is_relative_to(base)\n\n\ndef test_purepath_relative_to_windows():\n    \"\"\"PureWindowsPath.relative_to correctly detects containment with backslashes.\"\"\"\n    base = PureWindowsPath(\"C:\\\\Users\\\\user\\\\project\")\n    child = PureWindowsPath(\"C:\\\\Users\\\\user\\\\project\\\\src\\\\main.py\")\n    assert child.is_relative_to(base)\n\n    sneaky = PureWindowsPath(\"C:\\\\Users\\\\user\\\\project-evil\\\\hack.py\")\n    assert not sneaky.is_relative_to(base)\n\n    outside = PureWindowsPath(\"D:\\\\other\")\n    assert not outside.is_relative_to(base)\n\n\ndef test_purepath_windows_forward_slash_normalized():\n    \"\"\"PureWindowsPath treats forward slashes the same as backslashes.\"\"\"\n    base = PureWindowsPath(\"C:/Users/user/project\")\n    child = PureWindowsPath(\"C:/Users/user/project/src/main.py\")\n    assert child.is_relative_to(base)\n\n\ndef test_is_within_directory_prefix_attack():\n    \"\"\"is_within_directory must not be fooled by shared path prefixes.\"\"\"\n    # This is the exact bug that a naive startswith(\"/\" + ...) check would miss\n    assert not is_within_directory(\n        KaosPath(\"/home/user/project-evil\"), KaosPath(\"/home/user/project\")\n    )\n    assert is_within_directory(KaosPath(\"/home/user/project/sub\"), KaosPath(\"/home/user/project\"))\n\n\ndef test_is_within_directory_self():\n    \"\"\"A directory is considered within itself.\"\"\"\n    d = KaosPath(\"/home/user/project\")\n    assert is_within_directory(d, d)\n\n\ndef test_is_within_workspace_uses_relative_to_not_string_ops():\n    \"\"\"Verify workspace check is immune to string-prefix false positives.\"\"\"\n    work_dir = KaosPath(\"/app\")\n    additional = [KaosPath(\"/app-data\")]\n\n    # /app-data is an additional dir, so paths inside it should pass\n    assert is_within_workspace(KaosPath(\"/app-data/file.txt\"), work_dir, additional)\n\n    # /app-data-evil shares prefix with /app-data but is not inside it\n    assert not is_within_workspace(KaosPath(\"/app-data-evil/file.txt\"), work_dir, additional)\n"
  },
  {
    "path": "tests/utils/test_list_directory.py",
    "content": "\"\"\"Tests for list_directory robustness and formatting.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport platform\n\nimport pytest\nfrom inline_snapshot import snapshot\nfrom kaos.path import KaosPath\n\nfrom kimi_cli.utils.path import list_directory\n\n\n@pytest.mark.skipif(platform.system() == \"Windows\", reason=\"Unix-specific symlink tests.\")\nasync def test_list_directory_unix(temp_work_dir: KaosPath) -> None:\n    # Create a regular file and a directory (use KaosPath async ops for style consistency)\n    await (temp_work_dir / \"regular.txt\").write_text(\"hello\")\n    await (temp_work_dir / \"adir\").mkdir()\n    await (temp_work_dir / \"adir\" / \"inside.txt\").write_text(\"world\")\n    await (temp_work_dir / \"emptydir\").mkdir()\n    await (temp_work_dir / \"largefile.bin\").write_bytes(b\"x\" * 10_000_000)\n    os.symlink(\n        (temp_work_dir / \"regular.txt\").unsafe_to_local_path(),\n        (temp_work_dir / \"link_to_regular\").unsafe_to_local_path(),\n    )\n    os.symlink(\n        (temp_work_dir / \"missing.txt\").unsafe_to_local_path(),\n        (temp_work_dir / \"link_to_regular_missing\").unsafe_to_local_path(),\n    )\n\n    out = await list_directory(temp_work_dir)\n    out_without_size = \"\\n\".join(\n        sorted(\n            line.split(maxsplit=2)[0] + \" \" + line.split(maxsplit=2)[2] for line in out.splitlines()\n        )\n    )  # Remove size for snapshot stability\n    assert out_without_size == snapshot(\n        \"\"\"\\\n-rw-r--r-- largefile.bin\n-rw-r--r-- link_to_regular\n-rw-r--r-- regular.txt\n?--------- link_to_regular_missing [stat failed]\ndrwxr-xr-x adir\ndrwxr-xr-x emptydir\\\n\"\"\"\n    )\n\n\n@pytest.mark.skipif(platform.system() != \"Windows\", reason=\"Windows-specific symlink tests.\")\nasync def test_list_directory_windows(temp_work_dir: KaosPath) -> None:\n    # Create a regular file and a directory (use KaosPath async ops for style consistency)\n    await (temp_work_dir / \"regular.txt\").write_text(\"hello\")\n    await (temp_work_dir / \"adir\").mkdir()\n    await (temp_work_dir / \"adir\" / \"inside.txt\").write_text(\"world\")\n    await (temp_work_dir / \"emptydir\").mkdir()\n    await (temp_work_dir / \"largefile.bin\").write_bytes(b\"x\" * 10_000_000)\n\n    out = await list_directory(temp_work_dir)\n    assert out == snapshot(\"\"\"\\\ndrwxrwxrwx          0 adir\ndrwxrwxrwx          0 emptydir\n-rw-rw-rw-   10000000 largefile.bin\n-rw-rw-rw-          5 regular.txt\\\n\"\"\")\n"
  },
  {
    "path": "tests/utils/test_message_utils.py",
    "content": "\"\"\"Tests for message utility functions.\"\"\"\n\nfrom __future__ import annotations\n\nfrom kosong.message import Message\n\nfrom kimi_cli.utils.message import message_stringify\nfrom kimi_cli.wire.types import ImageURLPart, TextPart\n\n\ndef test_extract_text_from_string_content():\n    \"\"\"Test extracting text from message with string content.\"\"\"\n    message = Message(role=\"user\", content=\"Simple text\")\n    result = message.extract_text(sep=\"\\n\")\n\n    assert result == \"Simple text\"\n\n\ndef test_extract_text_from_content_parts():\n    \"\"\"Test extracting text from message with content parts.\"\"\"\n    text_part1 = TextPart(text=\"Hello\")\n    text_part2 = TextPart(text=\"World\")\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n\n    message = Message(role=\"user\", content=[text_part1, image_part, text_part2])\n    result = message.extract_text(sep=\"\\n\")\n\n    assert result == \"Hello\\nWorld\"\n\n\ndef test_extract_text_from_empty_content_parts():\n    \"\"\"Test extracting text from message with no text parts.\"\"\"\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n    message = Message(role=\"user\", content=[image_part])\n    result = message.extract_text(sep=\"\\n\")\n\n    assert result == \"\"\n\n\ndef test_stringify_string_content():\n    \"\"\"Test stringifying message with string content.\"\"\"\n    message = Message(role=\"user\", content=\"Simple text\")\n    result = message_stringify(message)\n\n    assert result == \"Simple text\"\n\n\ndef test_stringify_text_parts():\n    \"\"\"Test stringifying message with text parts.\"\"\"\n    text_part1 = TextPart(text=\"Hello\")\n    text_part2 = TextPart(text=\"World\")\n    message = Message(role=\"user\", content=[text_part1, text_part2])\n    result = message_stringify(message)\n\n    assert result == \"HelloWorld\"\n\n\ndef test_stringify_mixed_parts():\n    \"\"\"Test stringifying message with text and image parts.\"\"\"\n    text_part1 = TextPart(text=\"Hello\")\n    image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=\"https://example.com/image.jpg\"))\n    text_part2 = TextPart(text=\"World\")\n\n    message = Message(role=\"user\", content=[text_part1, image_part, text_part2])\n    result = message_stringify(message)\n\n    assert result == \"Hello[image]World\"\n\n\ndef test_stringify_only_image_parts():\n    \"\"\"Test stringifying message with only image parts.\"\"\"\n    image_part1 = ImageURLPart(\n        image_url=ImageURLPart.ImageURL(url=\"https://example.com/image1.jpg\")\n    )\n    image_part2 = ImageURLPart(\n        image_url=ImageURLPart.ImageURL(url=\"https://example.com/image2.jpg\")\n    )\n\n    message = Message(role=\"user\", content=[image_part1, image_part2])\n    result = message_stringify(message)\n\n    assert result == \"[image][image]\"\n\n\ndef test_stringify_empty_string():\n    \"\"\"Test stringifying message with empty string content.\"\"\"\n    message = Message(role=\"user\", content=\"\")\n    result = message_stringify(message)\n\n    assert result == \"\"\n\n\ndef test_stringify_empty_parts():\n    \"\"\"Test stringifying message with empty content parts.\"\"\"\n    message = Message(role=\"user\", content=[])\n    result = message_stringify(message)\n\n    assert result == \"\"\n\n\ndef test_extract_text_from_empty_string():\n    \"\"\"Test extracting text from empty string content.\"\"\"\n    message = Message(role=\"user\", content=\"\")\n    result = message.extract_text(sep=\"\\n\")\n\n    assert result == \"\"\n"
  },
  {
    "path": "tests/utils/test_pyinstaller_utils.py",
    "content": "from __future__ import annotations\n\nimport platform\nimport sys\nfrom pathlib import Path\n\nfrom inline_snapshot import snapshot\n\n\ndef test_pyinstaller_datas():\n    from kimi_cli.utils.pyinstaller import datas\n\n    project_root = Path(__file__).parent.parent.parent\n    python_version = f\"{sys.version_info.major}.{sys.version_info.minor}\"\n    site_packages = f\".venv/lib/python{python_version}/site-packages\"\n    rg_binary = \"rg.exe\" if platform.system() == \"Windows\" else \"rg\"\n    has_rg_binary = (project_root / \"src/kimi_cli/deps/bin\" / rg_binary).exists()\n    datas = [\n        (\n            Path(path)\n            .relative_to(project_root)\n            .as_posix()\n            .replace(\".venv/Lib/site-packages\", site_packages),\n            Path(dst).as_posix(),\n        )\n        for path, dst in datas\n    ]\n\n    datas = [(p, d) for p, d in datas if \"web/static\" not in d]\n\n    expected_datas = [\n        (\n            f\"{site_packages}/dateparser/data/dateparser_tz_cache.pkl\",\n            \"dateparser/data\",\n        ),\n        (\n            f\"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/INSTALLER\",\n            \"fastmcp/../fastmcp-2.12.5.dist-info\",\n        ),\n        (\n            f\"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/METADATA\",\n            \"fastmcp/../fastmcp-2.12.5.dist-info\",\n        ),\n        (\n            f\"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/RECORD\",\n            \"fastmcp/../fastmcp-2.12.5.dist-info\",\n        ),\n        (\n            f\"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/REQUESTED\",\n            \"fastmcp/../fastmcp-2.12.5.dist-info\",\n        ),\n        (\n            f\"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/WHEEL\",\n            \"fastmcp/../fastmcp-2.12.5.dist-info\",\n        ),\n        (\n            f\"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/entry_points.txt\",\n            \"fastmcp/../fastmcp-2.12.5.dist-info\",\n        ),\n        (\n            f\"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/licenses/LICENSE\",\n            \"fastmcp/../fastmcp-2.12.5.dist-info/licenses\",\n        ),\n        (\n            \"src/kimi_cli/CHANGELOG.md\",\n            \"kimi_cli\",\n        ),\n        (\"src/kimi_cli/agents/default/agent.yaml\", \"kimi_cli/agents/default\"),\n        (\"src/kimi_cli/agents/default/sub.yaml\", \"kimi_cli/agents/default\"),\n        (\"src/kimi_cli/agents/default/system.md\", \"kimi_cli/agents/default\"),\n        (\"src/kimi_cli/agents/okabe/agent.yaml\", \"kimi_cli/agents/okabe\"),\n        (\"src/kimi_cli/prompts/compact.md\", \"kimi_cli/prompts\"),\n        (\"src/kimi_cli/prompts/init.md\", \"kimi_cli/prompts\"),\n        (\n            \"src/kimi_cli/skills/kimi-cli-help/SKILL.md\",\n            \"kimi_cli/skills/kimi-cli-help\",\n        ),\n        (\n            \"src/kimi_cli/skills/skill-creator/SKILL.md\",\n            \"kimi_cli/skills/skill-creator\",\n        ),\n        (\"src/kimi_cli/tools/ask_user/description.md\", \"kimi_cli/tools/ask_user\"),\n        (\n            \"src/kimi_cli/tools/dmail/dmail.md\",\n            \"kimi_cli/tools/dmail\",\n        ),\n        (\"src/kimi_cli/tools/background/list.md\", \"kimi_cli/tools/background\"),\n        (\"src/kimi_cli/tools/background/output.md\", \"kimi_cli/tools/background\"),\n        (\"src/kimi_cli/tools/background/stop.md\", \"kimi_cli/tools/background\"),\n        (\n            \"src/kimi_cli/tools/file/glob.md\",\n            \"kimi_cli/tools/file\",\n        ),\n        (\n            \"src/kimi_cli/tools/file/grep.md\",\n            \"kimi_cli/tools/file\",\n        ),\n        (\n            \"src/kimi_cli/tools/file/read.md\",\n            \"kimi_cli/tools/file\",\n        ),\n        (\n            \"src/kimi_cli/tools/file/read_media.md\",\n            \"kimi_cli/tools/file\",\n        ),\n        (\n            \"src/kimi_cli/tools/file/replace.md\",\n            \"kimi_cli/tools/file\",\n        ),\n        (\n            \"src/kimi_cli/tools/file/write.md\",\n            \"kimi_cli/tools/file\",\n        ),\n        (\"src/kimi_cli/tools/multiagent/create.md\", \"kimi_cli/tools/multiagent\"),\n        (\"src/kimi_cli/tools/multiagent/task.md\", \"kimi_cli/tools/multiagent\"),\n        (\"src/kimi_cli/tools/plan/description.md\", \"kimi_cli/tools/plan\"),\n        (\"src/kimi_cli/tools/plan/enter_description.md\", \"kimi_cli/tools/plan\"),\n        (\"src/kimi_cli/tools/shell/bash.md\", \"kimi_cli/tools/shell\"),\n        (\"src/kimi_cli/tools/shell/powershell.md\", \"kimi_cli/tools/shell\"),\n        (\n            \"src/kimi_cli/tools/think/think.md\",\n            \"kimi_cli/tools/think\",\n        ),\n        (\n            \"src/kimi_cli/tools/todo/set_todo_list.md\",\n            \"kimi_cli/tools/todo\",\n        ),\n        (\n            \"src/kimi_cli/tools/web/fetch.md\",\n            \"kimi_cli/tools/web\",\n        ),\n        (\n            \"src/kimi_cli/tools/web/search.md\",\n            \"kimi_cli/tools/web\",\n        ),\n    ]\n    if has_rg_binary:\n        expected_datas.append((f\"src/kimi_cli/deps/bin/{rg_binary}\", \"kimi_cli/deps/bin\"))\n\n    assert sorted(datas) == snapshot(sorted(expected_datas))\n\n\ndef test_pyinstaller_hiddenimports():\n    from kimi_cli.utils.pyinstaller import hiddenimports\n\n    assert sorted(hiddenimports) == snapshot(\n        [\n            \"kimi_cli.tools\",\n            \"kimi_cli.tools.ask_user\",\n            \"kimi_cli.tools.background\",\n            \"kimi_cli.tools.display\",\n            \"kimi_cli.tools.dmail\",\n            \"kimi_cli.tools.file\",\n            \"kimi_cli.tools.file.glob\",\n            \"kimi_cli.tools.file.grep_local\",\n            \"kimi_cli.tools.file.plan_mode\",\n            \"kimi_cli.tools.file.read\",\n            \"kimi_cli.tools.file.read_media\",\n            \"kimi_cli.tools.file.replace\",\n            \"kimi_cli.tools.file.utils\",\n            \"kimi_cli.tools.file.write\",\n            \"kimi_cli.tools.multiagent\",\n            \"kimi_cli.tools.multiagent.create\",\n            \"kimi_cli.tools.multiagent.task\",\n            \"kimi_cli.tools.plan\",\n            \"kimi_cli.tools.plan.enter\",\n            \"kimi_cli.tools.plan.heroes\",\n            \"kimi_cli.tools.shell\",\n            \"kimi_cli.tools.test\",\n            \"kimi_cli.tools.think\",\n            \"kimi_cli.tools.todo\",\n            \"kimi_cli.tools.utils\",\n            \"kimi_cli.tools.web\",\n            \"kimi_cli.tools.web.fetch\",\n            \"kimi_cli.tools.web.search\",\n            \"setproctitle\",\n        ]\n    )\n"
  },
  {
    "path": "tests/utils/test_result_builder.py",
    "content": "\"\"\"Tests for ToolResultBuilder.\"\"\"\n\nfrom __future__ import annotations\n\nfrom kimi_cli.tools.utils import ToolResultBuilder\n\n\ndef test_basic_functionality():\n    \"\"\"Test basic functionality without limits.\"\"\"\n    builder = ToolResultBuilder(max_chars=50)\n\n    written1 = builder.write(\"Hello\")\n    written2 = builder.write(\" world\")\n\n    assert written1 == 5\n    assert written2 == 6\n\n    result = builder.ok(\"Operation completed\")\n    assert result.output == \"Hello world\"\n    assert result.message == \"Operation completed.\"\n    assert not builder.is_full\n\n\ndef test_char_limit_truncation():\n    \"\"\"Test character limit truncation.\"\"\"\n    builder = ToolResultBuilder(max_chars=10)\n\n    written1 = builder.write(\"Hello\")\n    written2 = builder.write(\" world!\")  # This should trigger truncation\n\n    assert written1 == 5\n    assert written2 == 14  # \"[...truncated]\" marker was added\n    assert builder.is_full\n\n    result = builder.ok(\"Operation completed\")\n    assert result.output == \"Hello[...truncated]\"\n    assert \"Operation completed.\" in result.message\n    assert \"Output is truncated\" in result.message\n\n\ndef test_line_length_limit():\n    \"\"\"Test line length limit functionality.\"\"\"\n    builder = ToolResultBuilder(max_chars=100, max_line_length=20)\n\n    written = builder.write(\"This is a very long line that should be truncated\\n\")\n\n    assert written == 20  # Line was truncated to fit marker\n\n    result = builder.ok()\n    assert isinstance(result.output, str)\n    assert \"[...truncated]\" in result.output\n    assert \"Output is truncated\" in result.message\n\n\ndef test_both_limits():\n    \"\"\"Test both character and line limits together.\"\"\"\n    builder = ToolResultBuilder(max_chars=40, max_line_length=20)\n\n    w1 = builder.write(\"Line 1\\n\")  # 7 chars\n    w2 = builder.write(\"This is a very long line that exceeds limit\\n\")  # 20 chars (truncated)\n    w3 = builder.write(\"This would exceed char limit\")  # 14 chars (truncated)\n\n    assert w1 == 7\n    assert w2 == 20  # Line truncated to fit limit\n    assert w3 == 14  # Line truncated due to char limit\n    assert builder.is_full\n    # Total might exceed 40 due to truncation markers\n\n    result = builder.ok()\n    assert isinstance(result.output, str)\n    assert \"[...truncated]\" in result.output\n    assert \"Output is truncated\" in result.message\n\n\ndef test_error_result():\n    \"\"\"Test error result creation.\"\"\"\n    builder = ToolResultBuilder(max_chars=20)\n\n    builder.write(\"Some output\")\n    result = builder.error(\"Something went wrong\", brief=\"Error occurred\")\n\n    assert result.output == \"Some output\"\n    assert result.message == \"Something went wrong\"\n    assert result.brief == \"Error occurred\"\n\n\ndef test_error_with_truncation():\n    \"\"\"Test error result with truncated output.\"\"\"\n    builder = ToolResultBuilder(max_chars=10)\n\n    builder.write(\"Very long output that exceeds limit\")\n    result = builder.error(\"Command failed\", brief=\"Failed\")\n\n    assert isinstance(result.output, str)\n    assert \"[...truncated]\" in result.output\n    assert \"Command failed\" in result.message\n    assert \"Output is truncated\" in result.message\n    assert result.brief == \"Failed\"\n\n\ndef test_properties():\n    \"\"\"Test builder properties.\"\"\"\n    builder = ToolResultBuilder(max_chars=20, max_line_length=30)\n\n    assert builder.n_chars == 0\n    assert builder.n_lines == 0\n    assert not builder.is_full\n\n    builder.write(\"Short\\n\")\n    assert builder.n_chars == 6\n    assert builder.n_lines == 1\n\n    builder.write(\"1\\n2\\n\")\n    assert builder.n_chars == 10\n    assert builder.n_lines == 3\n\n    builder.write(\"More text that exceeds\")  # Will trigger char truncation\n    assert builder.is_full\n\n\ndef test_write_when_full():\n    \"\"\"Test writing when buffer is already full.\"\"\"\n    builder = ToolResultBuilder(max_chars=5)\n\n    written1 = builder.write(\"Hello\")  # Fills buffer exactly\n    written2 = builder.write(\" world\")  # Should write nothing\n\n    assert written1 == 5\n    assert written2 == 0\n    assert builder.is_full\n\n    result = builder.ok()\n    assert result.output == \"Hello\"\n\n\ndef test_multiline_handling():\n    \"\"\"Test proper multiline text handling.\"\"\"\n    builder = ToolResultBuilder(max_chars=100)\n\n    written = builder.write(\"Line 1\\nLine 2\\nLine 3\")\n\n    assert written == 20\n    assert builder.n_lines == 2  # Two newlines\n\n    result = builder.ok()\n    assert result.output == \"Line 1\\nLine 2\\nLine 3\"\n\n\ndef test_empty_write():\n    \"\"\"Test writing empty string.\"\"\"\n    builder = ToolResultBuilder(max_chars=50)\n\n    written = builder.write(\"\")\n\n    assert written == 0\n    assert builder.n_chars == 0\n    assert not builder.is_full\n"
  },
  {
    "path": "tests/utils/test_rich_markdown.py",
    "content": "from rich.console import Console\n\nfrom kimi_cli.utils.rich.markdown import Markdown\n\n\ndef test_markdown_html_block_renders_without_stack_error() -> None:\n    console = Console(width=80, record=True)\n    markdown = Markdown(\"<analysis>\\nHello\\n</analysis>\\n\")\n    segments = list(console.render(markdown))\n    rendered = \"\".join(segment.text for segment in segments)\n    assert \"<analysis>\" in rendered\n"
  },
  {
    "path": "tests/utils/test_slash_command.py",
    "content": "\"\"\"Tests for slash command functionality using inline-snapshot.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport pytest\nfrom inline_snapshot import snapshot\n\nfrom kimi_cli.utils.slashcmd import (\n    SlashCommand,\n    SlashCommandCall,\n    SlashCommandRegistry,\n    parse_slash_command_call,\n)\n\n\ndef check_slash_commands(registry: SlashCommandRegistry[Any], snapshot: Any):\n    \"\"\"Check slash commands match snapshot.\"\"\"\n    import json\n\n    # Use the public list_commands() API and build the alias mapping\n    alias_to_cmd: dict[str, SlashCommand[Any]] = {}\n    for cmd in registry.list_commands():\n        alias_to_cmd[cmd.name] = cmd\n        for alias in cmd.aliases:\n            alias_to_cmd[alias] = cmd\n\n    pretty_commands = json.dumps(\n        {\n            alias: f\"{cmd.slash_name()}: {cmd.description}\"\n            for (alias, cmd) in sorted(alias_to_cmd.items())\n        },\n        indent=2,\n        sort_keys=True,\n    )\n    assert pretty_commands == snapshot\n\n\ndef test_parse_slash_command_call():\n    \"\"\"Test parsing slash command calls, focusing on edge cases.\"\"\"\n\n    # Regular cases should work\n    result = parse_slash_command_call(\"/help\")\n    assert result == snapshot(SlashCommandCall(name=\"help\", args=\"\", raw_input=\"/help\"))\n\n    result = parse_slash_command_call(\"/search query\")\n    assert result == snapshot(\n        SlashCommandCall(name=\"search\", args=\"query\", raw_input=\"/search query\")\n    )\n\n    result = parse_slash_command_call(\"/skill:doc-writing\")\n    assert result == snapshot(\n        SlashCommandCall(name=\"skill:doc-writing\", args=\"\", raw_input=\"/skill:doc-writing\")\n    )\n\n    # Edge cases: double slash\n    assert parse_slash_command_call(\"//comment\") is None\n    assert parse_slash_command_call(\"//\") is None\n\n    # Edge cases: /* and # comments\n    assert parse_slash_command_call(\"/* comment */\") is None\n    assert parse_slash_command_call(\"# comment\") is None\n    assert parse_slash_command_call(\"#!/bin/bash\") is None\n\n    # Edge cases: Chinese characters in args (should work)\n    result = parse_slash_command_call(\"/echo 你好世界\")\n    assert result == snapshot(\n        SlashCommandCall(name=\"echo\", args=\"你好世界\", raw_input=\"/echo 你好世界\")\n    )\n\n    result = parse_slash_command_call(\"/search 中文查询 english query\")\n    assert result == snapshot(\n        SlashCommandCall(\n            name=\"search\",\n            args=\"中文查询 english query\",\n            raw_input=\"/search 中文查询 english query\",\n        )\n    )\n\n    result = parse_slash_command_call(\"/skill:update-docs 这是一个 带空格的    内容\")\n    assert result == snapshot(\n        SlashCommandCall(\n            name=\"skill:update-docs\",\n            args=\"这是一个 带空格的    内容\",\n            raw_input=\"/skill:update-docs 这是一个 带空格的    内容\",\n        )\n    )\n\n    # Chinese characters in command name should fail (regex only allows a-zA-Z0-9_- and :)\n    assert parse_slash_command_call(\"/测试命令 参数\") is None\n    assert parse_slash_command_call(\"/命令\") is None\n\n    # Invalid cases should return None\n    assert parse_slash_command_call(\"\") is None\n    assert parse_slash_command_call(\"help\") is None\n    assert parse_slash_command_call(\"/\") is None\n    assert parse_slash_command_call(\"/skill:\") is None\n    assert parse_slash_command_call(\"/.invalid\") is None\n\n    # Quoted input should be preserved as raw text\n    result = parse_slash_command_call('/cmd \"unmatched quote')\n    assert result == snapshot(\n        SlashCommandCall(name=\"cmd\", args='\"unmatched quote', raw_input='/cmd \"unmatched quote')\n    )\n\n    result = parse_slash_command_call(\"/cmd '\")\n    assert result == snapshot(SlashCommandCall(name=\"cmd\", args=\"'\", raw_input=\"/cmd '\"))\n\n\n@pytest.fixture\ndef test_registry() -> SlashCommandRegistry[Any]:\n    \"\"\"Create a clean test registry for each test.\"\"\"\n    return SlashCommandRegistry()\n\n\ndef test_slash_command_registration(test_registry: SlashCommandRegistry[Any]) -> None:\n    \"\"\"Test all slash command registration scenarios.\"\"\"\n\n    # Basic registration\n    @test_registry.command  # noqa: F811\n    def basic(app: object, args: str) -> None:  # noqa: F811 # type: ignore[reportUnusedFunction]\n        \"\"\"Basic command.\"\"\"\n        pass\n\n    # Custom name, original name should be ignored\n    @test_registry.command(name=\"run\")  # noqa: F811\n    def start(app: object, args: str) -> None:  # noqa: F811 # type: ignore[reportUnusedFunction]\n        \"\"\"Run something.\"\"\"\n        pass\n\n    # Aliases only, original name should be kept\n    @test_registry.command(aliases=[\"h\", \"?\"])  # noqa: F811\n    def help(app: object, args: str) -> None:  # noqa: F811 # type: ignore[reportUnusedFunction]\n        \"\"\"Show help.\"\"\"\n        pass\n\n    # Custom name with aliases\n    @test_registry.command(name=\"search\", aliases=[\"s\", \"find\"])  # noqa: F811\n    def query(app: object, args: str) -> None:  # noqa: F811 # type: ignore[reportUnusedFunction]\n        \"\"\"Search items.\"\"\"\n        pass\n\n    # Edge cases: no doc, whitespace doc, duplicate aliases\n    @test_registry.command  # noqa: F811\n    def no_doc(app: object, args: str) -> None:  # noqa: F811 # type: ignore[reportUnusedFunction]\n        pass\n\n    @test_registry.command  # noqa: F811\n    def whitespace_doc(  # noqa: F811 # type: ignore[reportUnusedFunction]\n        app: object, args: str\n    ) -> None:\n        \"\"\"\\n\\t\"\"\"\n        pass\n\n    @test_registry.command(aliases=[\"dup\", \"dup\"])  # noqa: F811\n    def dedup_test(  # noqa: F811 # type: ignore[reportUnusedFunction]\n        app: object, args: str\n    ) -> None:\n        \"\"\"Test deduplication.\"\"\"\n        pass\n\n    check_slash_commands(\n        test_registry,\n        snapshot(\"\"\"\\\n{\n  \"?\": \"/help (h, ?): Show help.\",\n  \"basic\": \"/basic: Basic command.\",\n  \"dedup_test\": \"/dedup_test (dup, dup): Test deduplication.\",\n  \"dup\": \"/dedup_test (dup, dup): Test deduplication.\",\n  \"find\": \"/search (s, find): Search items.\",\n  \"h\": \"/help (h, ?): Show help.\",\n  \"help\": \"/help (h, ?): Show help.\",\n  \"no_doc\": \"/no_doc: \",\n  \"run\": \"/run: Run something.\",\n  \"s\": \"/search (s, find): Search items.\",\n  \"search\": \"/search (s, find): Search items.\",\n  \"whitespace_doc\": \"/whitespace_doc: \"\n}\\\n\"\"\"),\n    )\n\n\ndef test_slash_command_overwriting(test_registry: SlashCommandRegistry[Any]) -> None:\n    \"\"\"Test command overwriting behavior.\"\"\"\n\n    @test_registry.command  # noqa: F811\n    def test_cmd(app: object, args: str) -> None:  # noqa: F811 # type: ignore[reportUnusedFunction]\n        \"\"\"First version.\"\"\"\n        pass\n\n    check_slash_commands(\n        test_registry,\n        snapshot(\"\"\"\\\n{\n  \"test_cmd\": \"/test_cmd: First version.\"\n}\\\n\"\"\"),\n    )\n\n    @test_registry.command(name=\"test_cmd\")  # noqa: F811\n    def _test_cmd(  # noqa: F811 # type: ignore[reportUnusedFunction]\n        app: object, args: str\n    ) -> None:\n        \"\"\"Second version.\"\"\"\n        pass\n\n    check_slash_commands(\n        test_registry,\n        snapshot(\"\"\"\\\n{\n  \"test_cmd\": \"/test_cmd: Second version.\"\n}\\\n\"\"\"),\n    )\n"
  },
  {
    "path": "tests/utils/test_typing_utils.py",
    "content": "from typing import Optional, Union\n\nfrom kimi_cli.utils.typing import flatten_union\n\n\nclass A:\n    pass\n\n\nclass B:\n    pass\n\n\ntype Foo = A | B | int | str\ntype Bar = Foo | float\n\n\ndef test_flatten_union():\n    assert flatten_union(Foo) == (A, B, int, str)\n    assert flatten_union(A | B | int | str) == (A, B, int, str)\n    assert flatten_union(Bar) == (A, B, int, str, float)\n    assert flatten_union(Foo | float) == (A, B, int, str, float)\n\n\ndef test_flatten_typing_union():\n    assert flatten_union(Union[A, B]) == (A, B)  # noqa: UP007\n    assert flatten_union(Union[Foo, float]) == (A, B, int, str, float)  # noqa: UP007\n    assert flatten_union(Optional[A]) == (A, type(None))  # noqa: UP045\n"
  },
  {
    "path": "tests/utils/test_utils_environment.py",
    "content": "import platform\n\nimport pytest\nfrom kaos.path import KaosPath\n\n\n@pytest.mark.skipif(platform.system() == \"Windows\", reason=\"Skipping test on Windows\")\nasync def test_environment_detection(monkeypatch):\n    monkeypatch.setattr(platform, \"system\", lambda: \"Linux\")\n    monkeypatch.setattr(platform, \"machine\", lambda: \"x86_64\")\n    monkeypatch.setattr(platform, \"version\", lambda: \"5.15.0-123-generic\")\n\n    async def _mock_is_file(self: KaosPath) -> bool:\n        return str(self) == \"/usr/bin/bash\"\n\n    monkeypatch.setattr(KaosPath, \"is_file\", _mock_is_file)\n\n    from kimi_cli.utils.environment import Environment\n\n    env = await Environment.detect()\n    assert env.os_kind == \"Linux\"\n    assert env.os_arch == \"x86_64\"\n    assert env.os_version == \"5.15.0-123-generic\"\n    assert env.shell_name == \"bash\"\n    assert str(env.shell_path) == \"/usr/bin/bash\"\n\n\n@pytest.mark.skipif(platform.system() != \"Windows\", reason=\"Skipping test on non-Windows\")\nasync def test_environment_detection_windows(monkeypatch):\n    monkeypatch.setattr(platform, \"system\", lambda: \"Windows\")\n    monkeypatch.setattr(platform, \"machine\", lambda: \"AMD64\")\n    monkeypatch.setattr(platform, \"version\", lambda: \"10.0.19044\")\n\n    from kimi_cli.utils.environment import Environment\n\n    env = await Environment.detect()\n    assert env.os_kind == \"Windows\"\n    assert env.os_arch == \"AMD64\"\n    assert env.os_version == \"10.0.19044\"\n    assert env.shell_name == \"Windows PowerShell\"\n    assert str(env.shell_path) == \"powershell.exe\"\n"
  },
  {
    "path": "tests/utils/test_utils_path.py",
    "content": "\"\"\"Tests for path utility functions.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom pathlib import Path\n\nimport pytest\n\nfrom kimi_cli.utils.path import next_available_rotation, sanitize_cli_path\n\n\nasync def test_next_available_rotation_empty_dir(tmp_path):\n    \"\"\"Test next_available_rotation with empty directory.\"\"\"\n    test_file = tmp_path / \"test.txt\"\n    result = await next_available_rotation(test_file)\n\n    assert result == tmp_path / \"test_1.txt\"\n\n\nasync def test_next_available_rotation_no_existing_rotations(tmp_path):\n    \"\"\"Test next_available_rotation with no existing rotation files.\"\"\"\n    # Create the parent directory\n    test_file = tmp_path / \"test.txt\"\n\n    result = await next_available_rotation(test_file)\n\n    assert result == tmp_path / \"test_1.txt\"\n\n\nasync def test_next_available_rotation_with_existing_rotations(tmp_path):\n    \"\"\"Test next_available_rotation with existing rotation files.\"\"\"\n    # Create existing rotation files\n    (tmp_path / \"test_1.txt\").write_text(\"content1\")\n    (tmp_path / \"test_2.txt\").write_text(\"content2\")\n    (tmp_path / \"test_5.txt\").write_text(\"content5\")  # Gap in numbering\n\n    test_file = tmp_path / \"test.txt\"\n    result = await next_available_rotation(test_file)\n\n    # Should find the highest number (5) and return next (6)\n    assert result == tmp_path / \"test_6.txt\"\n\n\nasync def test_next_available_rotation_mixed_files(tmp_path):\n    \"\"\"Test next_available_rotation with mixed files in directory.\"\"\"\n    # Create various files, only some match the pattern\n    (tmp_path / \"test_1.txt\").write_text(\"content1\")\n    (tmp_path / \"test_3.txt\").write_text(\"content3\")\n    (tmp_path / \"other_file.txt\").write_text(\"other\")\n    (tmp_path / \"test_backup.txt\").write_text(\"backup\")\n    (tmp_path / \"different_2.txt\").write_text(\"different\")\n\n    test_file = tmp_path / \"test.txt\"\n    result = await next_available_rotation(test_file)\n\n    # Should find the highest matching number (3) and return next (4)\n    assert result == tmp_path / \"test_4.txt\"\n\n\nasync def test_next_available_rotation_different_extensions(tmp_path):\n    \"\"\"Test next_available_rotation with different file extensions.\"\"\"\n    # Create files with same base name but different extensions\n    (tmp_path / \"document_1.pdf\").write_text(\"pdf1\")\n    (tmp_path / \"document_2.pdf\").write_text(\"pdf2\")\n    (tmp_path / \"document.txt\").write_text(\"txt\")  # Different extension\n\n    test_file = tmp_path / \"document.pdf\"\n    result = await next_available_rotation(test_file)\n\n    # Should only consider .pdf files\n    assert result == tmp_path / \"document_3.pdf\"\n\n\nasync def test_next_available_rotation_complex_name(tmp_path):\n    \"\"\"Test next_available_rotation with complex file names.\"\"\"\n    # Note: path.stem for \"my-backup_file.tar.gz\" is \"my-backup_file.tar\"\n    # and path.suffix is \".gz\", so the function looks for \"my-backup_file.tar_N.gz\"\n    (tmp_path / \"my-backup_file.tar_1.gz\").write_text(\"backup1\")\n    (tmp_path / \"my-backup_file.tar_3.gz\").write_text(\"backup3\")\n\n    test_file = tmp_path / \"my-backup_file.tar.gz\"\n    result = await next_available_rotation(test_file)\n\n    # Should find the highest number (3) and return next (4)\n    assert result == tmp_path / \"my-backup_file.tar_4.gz\"\n\n\nasync def test_next_available_rotation_parent_not_exists():\n    \"\"\"Test next_available_rotation when parent directory doesn't exist.\"\"\"\n    test_file = Path(\"/non/existent/directory/test.txt\")\n    result = await next_available_rotation(test_file)\n\n    assert result is None\n\n\nasync def test_next_available_rotation_zero_padding(tmp_path):\n    \"\"\"Test next_available_rotation matches zero-padded numbers (regex \\\\d+ matches them).\"\"\"\n    # Create files with zero-padded numbers (they will match \\d+ pattern)\n    (tmp_path / \"test_01.txt\").write_text(\"padded1\")\n    (tmp_path / \"test_007.txt\").write_text(\"padded7\")\n    (tmp_path / \"test_5.txt\").write_text(\"normal5\")\n\n    test_file = tmp_path / \"test.txt\"\n    result = await next_available_rotation(test_file)\n\n    # Should find the highest number (7 from test_007.txt) and return next (8)\n    assert result == tmp_path / \"test_8.txt\"\n\n\nasync def test_next_available_rotation_large_numbers(tmp_path):\n    \"\"\"Test next_available_rotation with large numbers.\"\"\"\n    # Create files with large numbers\n    (tmp_path / \"log_999.txt\").write_text(\"log999\")\n    (tmp_path / \"log_1000.txt\").write_text(\"log1000\")\n    (tmp_path / \"log_1500.txt\").write_text(\"log1500\")\n\n    test_file = tmp_path / \"log.txt\"\n    result = await next_available_rotation(test_file)\n\n    # Should find the highest number (1500) and return next (1501)\n    assert result == tmp_path / \"log_1501.txt\"\n\n\nasync def test_next_available_rotation_directory_with_suffix(tmp_path):\n    \"\"\"Test next_available_rotation with directories that have suffix-like names.\"\"\"\n    # Create directories with numbered suffixes\n    (tmp_path / \"backup_1\").mkdir()\n    (tmp_path / \"backup_2\").mkdir()\n    (tmp_path / \"backup_5\").mkdir()\n\n    test_dir = tmp_path / \"backup\"\n    result = await next_available_rotation(test_dir)\n\n    # Should find the highest number (5) and return next (6)\n    assert result == tmp_path / \"backup_6\"\n\n\nasync def test_next_available_rotation_directory_empty_suffix(tmp_path):\n    \"\"\"Test next_available_rotation with directories (empty suffix).\"\"\"\n    # Create directories with numbered suffixes\n    (tmp_path / \"data_1\").mkdir()\n    (tmp_path / \"data_3\").mkdir()\n\n    test_dir = tmp_path / \"data\"\n    result = await next_available_rotation(test_dir)\n\n    # Should find the highest number (3) and return next (4)\n    assert result == tmp_path / \"data_4\"\n\n\nasync def test_next_available_rotation_directory_with_extension(tmp_path):\n    \"\"\"Test next_available_rotation with directory names containing dots.\"\"\"\n    # Note: for path \"config.backup\", stem is \"config\" and suffix is \".backup\"\n    # So the function looks for \"config_N.backup\" pattern\n    (tmp_path / \"config_1.backup\").mkdir()\n    (tmp_path / \"config_2.backup\").mkdir()\n\n    test_dir = tmp_path / \"config.backup\"\n    result = await next_available_rotation(test_dir)\n\n    # Should find the highest number (2) and return next (3)\n    assert result == tmp_path / \"config_3.backup\"\n\n\nasync def test_next_available_rotation_mixed_files_and_dirs(tmp_path):\n    \"\"\"Test next_available_rotation with mixed files and directories.\"\"\"\n    # Create both files and directories with matching patterns\n    (tmp_path / \"archive_1.txt\").write_text(\"file1\")\n    (tmp_path / \"archive_2\").mkdir()  # Directory, no extension\n    (tmp_path / \"archive_3.txt\").write_text(\"file3\")\n\n    test_path = tmp_path / \"archive.txt\"\n    result = await next_available_rotation(test_path)\n\n    # Should only consider files with matching extension (.txt)\n    assert result == tmp_path / \"archive_4.txt\"\n\n\nasync def test_next_available_rotation_directory_pattern_with_extension(tmp_path):\n    \"\"\"Test next_available_rotation with directory that has extension-like suffix.\"\"\"\n    # Note: for path \"my.data\", stem is \"my\" and suffix is \".data\"\n    # So the function looks for \"my_N.data\" pattern\n    (tmp_path / \"my_1.data\").mkdir()\n    (tmp_path / \"my_2.data\").mkdir()\n    (tmp_path / \"my_3.data\").mkdir()\n\n    test_dir = tmp_path / \"my.data\"\n    result = await next_available_rotation(test_dir)\n\n    # Should find the highest number (3) and return next (4)\n    assert result == tmp_path / \"my_4.data\"\n\n\nasync def test_next_available_rotation_creates_placeholder(tmp_path):\n    \"\"\"Ensure the rotation path is reserved by creating an empty file.\"\"\"\n\n    target = tmp_path / \"log.txt\"\n    reserved = await next_available_rotation(target)\n\n    assert reserved is not None\n    assert reserved == tmp_path / \"log_1.txt\"\n    assert reserved.exists()\n\n\nasync def test_next_available_rotation_concurrent_calls(tmp_path):\n    \"\"\"Concurrent reservations must yield unique paths.\"\"\"\n\n    target = tmp_path / \"events.log\"\n    results = await asyncio.gather(*(next_available_rotation(target) for _ in range(5)))\n\n    assert all(path is not None for path in results)\n    names = {path.name for path in results if path is not None}\n    assert len(names) == 5\n    assert names == {\n        \"events_1.log\",\n        \"events_2.log\",\n        \"events_3.log\",\n        \"events_4.log\",\n        \"events_5.log\",\n    }\n\n\n# ---------------------------------------------------------------------------\n# sanitize_cli_path tests\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.parametrize(\n    \"raw, expected\",\n    [\n        # macOS drag-and-drop: single quotes\n        (\"'/Users/me/file.txt'\", \"/Users/me/file.txt\"),\n        # double quotes\n        ('\"/Users/me/file.txt\"', \"/Users/me/file.txt\"),\n        # leading/trailing whitespace + quotes\n        (\"  '/Users/me/file.txt'  \", \"/Users/me/file.txt\"),\n        # plain path – no change\n        (\"/Users/me/file.txt\", \"/Users/me/file.txt\"),\n        # empty string\n        (\"\", \"\"),\n        # whitespace only\n        (\"   \", \"\"),\n        # single quote char – not a pair\n        (\"'\", \"'\"),\n        # mismatched quotes – no stripping\n        (\"'/Users/me/file.txt\\\"\", \"'/Users/me/file.txt\\\"\"),\n        # quotes inside path – should be kept\n        (\"/Users/it's/a path\", \"/Users/it's/a path\"),\n        # path with spaces inside quotes (common macOS drag)\n        (\"'/Users/me/my docs/file.txt'\", \"/Users/me/my docs/file.txt\"),\n    ],\n)\ndef test_sanitize_cli_path(raw: str, expected: str):\n    assert sanitize_cli_path(raw) == expected\n"
  },
  {
    "path": "tests/vis/test_app.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\nfrom fastapi.testclient import TestClient\n\nfrom kimi_cli.metadata import Metadata, WorkDirMeta, save_metadata\nfrom kimi_cli.vis.api import system as vis_system_api\nfrom kimi_cli.vis.app import create_app\n\n\ndef test_vis_sessions_include_session_dir(\n    monkeypatch,\n    tmp_path: Path,\n) -> None:\n    monkeypatch.setenv(\"KIMI_SHARE_DIR\", str(tmp_path))\n\n    work_dir = tmp_path / \"project\"\n    work_dir.mkdir()\n    metadata = Metadata(work_dirs=[WorkDirMeta(path=str(work_dir))])\n    save_metadata(metadata)\n\n    session_dir = metadata.work_dirs[0].sessions_dir / \"session123\"\n    session_dir.mkdir(parents=True)\n    (session_dir / \"context.jsonl\").write_text(\"{}\\n\", encoding=\"utf-8\")\n\n    with TestClient(create_app()) as client:\n        response = client.get(\"/api/vis/sessions\")\n\n    assert response.status_code == 200\n    payload = response.json()\n    assert len(payload) == 1\n    assert payload[0][\"session_id\"] == \"session123\"\n    assert payload[0][\"session_dir\"] == str(session_dir)\n    assert payload[0][\"work_dir\"] == str(work_dir)\n\n\ndef test_vis_app_mounts_open_in_route() -> None:\n    with TestClient(create_app()) as client:\n        response = client.post(\n            \"/api/open-in\",\n            json={\"app\": \"finder\", \"path\": \"/definitely/missing/path\"},\n        )\n\n    assert response.status_code == 400\n\n\ndef test_vis_capabilities_report_open_in_support(monkeypatch) -> None:\n    monkeypatch.setattr(vis_system_api.sys, \"platform\", \"linux\")\n\n    with TestClient(create_app()) as client:\n        response = client.get(\"/api/vis/capabilities\")\n\n    assert response.status_code == 200\n    assert response.json() == {\"open_in_supported\": False}\n"
  },
  {
    "path": "tests/web/test_open_in.py",
    "content": "from __future__ import annotations\n\nimport pytest\n\nfrom kimi_cli.web.api import open_in as open_in_api\n\n\n@pytest.mark.anyio\nasync def test_open_in_supports_windows_directory(monkeypatch, tmp_path) -> None:\n    calls: list[list[str]] = []\n\n    monkeypatch.setattr(open_in_api.sys, \"platform\", \"win32\")\n    monkeypatch.setattr(open_in_api, \"_spawn_process\", lambda args: calls.append(args))\n\n    response = await open_in_api.open_in(\n        open_in_api.OpenInRequest(app=\"finder\", path=str(tmp_path))\n    )\n\n    assert response.ok is True\n    assert calls == [[\"explorer\", str(tmp_path)]]\n\n\n@pytest.mark.anyio\nasync def test_open_in_supports_windows_file_selection(monkeypatch, tmp_path) -> None:\n    calls: list[list[str]] = []\n    file_path = tmp_path / \"note.txt\"\n    file_path.write_text(\"hello\", encoding=\"utf-8\")\n\n    monkeypatch.setattr(open_in_api.sys, \"platform\", \"win32\")\n    monkeypatch.setattr(open_in_api, \"_spawn_process\", lambda args: calls.append(args))\n\n    response = await open_in_api.open_in(\n        open_in_api.OpenInRequest(app=\"finder\", path=str(file_path))\n    )\n\n    assert response.ok is True\n    assert calls == [[\"explorer\", f\"/select,{file_path}\"]]\n\n\n@pytest.mark.anyio\nasync def test_open_in_offloads_sync_work_to_thread(monkeypatch, tmp_path) -> None:\n    offloaded: dict[str, object] = {}\n\n    def fake_open_in_sync(request, path, *, is_file: bool) -> None:\n        offloaded[\"request\"] = request\n        offloaded[\"path\"] = path\n        offloaded[\"is_file\"] = is_file\n\n    async def fake_to_thread(func, *args, **kwargs):\n        offloaded[\"func\"] = func\n        return func(*args, **kwargs)\n\n    monkeypatch.setattr(open_in_api.sys, \"platform\", \"win32\")\n    monkeypatch.setattr(open_in_api, \"_open_in_sync\", fake_open_in_sync)\n    monkeypatch.setattr(open_in_api.asyncio, \"to_thread\", fake_to_thread)\n\n    response = await open_in_api.open_in(\n        open_in_api.OpenInRequest(app=\"finder\", path=str(tmp_path))\n    )\n\n    assert response.ok is True\n    assert offloaded[\"func\"] is fake_open_in_sync\n    assert offloaded[\"path\"] == tmp_path.resolve()\n    assert offloaded[\"is_file\"] is False\n"
  },
  {
    "path": "tests_ai/scripts/main.yaml",
    "content": "version: 1\nagent:\n  extend: default\n  system_prompt_args:\n    ROLE_ADDITIONAL: |\n      <role>\n      !!IMPORTANT!!\n\n      You are now assigned an important task - to audit the current codebase. You shall not scan over the codebase blindly, instead, you should follow the following instructions.\n\n      There are some markdown files prefixed with `test_` in the directory the user is about to specify. Each of these files is a \"test\", which contains the instruction to test/audit a specific aspect of the requirements/invariants we want to ensure in this codebase. The level-1 header in each of these files is the \"name\" of the \"test\". In each of the \"test\"s, there may be one or more \"case\"s. The level-2 headers provide the \"name\"s of such \"case\"s, in the form of `Case <n>: <case-name>` or `<case-name>`. In each \"case\", a \"Scope\" section and a \"Requirements\" section are given, specifying the scope in the codebase you should examine for this \"case\", and the required invariants, respectively.\n\n      \"Test\"s are not related or dependent to each other. You should spawn as many as possible \"worker\" subagents via Task tool in parallel (in one single response) to do the audit. First, you must list/glob all the `test_*.md` files inside the directory the user is about to specify via a user message. You must not directly read the file contents after listing. Then, you must spawn a \"worker\" subagent for each of the `test_*.md` files, let the subagent read the test file content, examine all the \"case\"s in it, determine whether the \"case\"s can \"pass\" or not.\n\n      Once all `test_*.md` files are \"executed\". You must collect the results from all the subagents and compile them into one JSON file `report.json` in the following format:\n\n      ```json\n      [\n        {\n          \"file\": \"/path/to/the/test.md\",\n          \"name\": \"The name of the test\",\n          \"cases\": [\n            {\n              \"name\": \"The name of the case without the Case <n> prefix\",\n              \"pass\": true  // or false if failed to pass\n            }\n          ]\n        }\n      ]\n      ```\n\n      The `report.json` file must be in correct JSON format, with proper character escaping. It must be written to the directory given by the user which contains all the test files.\n\n      Beside the `report.json`, you must also compile a concise human-readable summary in the final response, containing the passed and failed test cases, and suggestions on how to fix the failed ones.\n      </role>\n  tools:\n    - \"kimi_cli.tools.multiagent:Task\"\n    - \"kimi_cli.tools.think:Think\"\n    - \"kimi_cli.tools.todo:SetTodoList\"\n    - \"kimi_cli.tools.shell:Shell\"\n    - \"kimi_cli.tools.file:Glob\"\n    - \"kimi_cli.tools.file:WriteFile\" # for the final report file\n    # - \"kimi_cli.tools.web:SearchWeb\"\n    # - \"kimi_cli.tools.web:FetchURL\"\n  subagents:\n    worker:\n      path: ./worker.yaml\n      description: \"The worker subagent to examine one test file.\"\n"
  },
  {
    "path": "tests_ai/scripts/run.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Execute AI-driven audits and emit pytest-style results.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport shutil\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nRESET = \"\\033[0m\"\nGREEN = \"\\033[32m\"\nRED = \"\\033[31m\"\nYELLOW = \"\\033[33m\"\n\n\ndef load_report(report_path: Path) -> list[dict]:\n    try:\n        raw = report_path.read_text(encoding=\"utf-8\")\n    except FileNotFoundError as exc:\n        raise SystemExit(f\"ERROR: report.json not found at {report_path}\") from exc\n\n    try:\n        payload = json.loads(raw)\n    except json.JSONDecodeError as exc:\n        raise SystemExit(f\"ERROR: invalid JSON in {report_path}: {exc}\") from exc\n\n    if not isinstance(payload, list):\n        raise SystemExit(f\"ERROR: expected a list in {report_path}\")\n\n    return payload\n\n\ndef run_agent(script_dir: Path, tests_dir: Path) -> None:\n    cmd = [\n        \"uv\",\n        \"run\",\n        \"kimi\",\n        \"--yolo\",\n        \"--agent-file\",\n        str(script_dir / \"main.yaml\"),\n        \"-c\",\n        str(tests_dir),\n    ]\n\n    subprocess.run(cmd, check=True)\n\n\ndef colorize(text: str, color: str, use_color: bool) -> str:\n    if not use_color:\n        return text\n    return f\"{color}{text}{RESET}\"\n\n\ndef emit_results(report: list[dict], *, use_color: bool) -> tuple[int, int]:\n    passed = 0\n    failed = 0\n\n    for test in report:\n        if not isinstance(test, dict):\n            raise SystemExit(\"ERROR: each test entry must be an object.\")\n\n        test_file = Path(str(test.get(\"file\", \"\")))\n        display_file = test_file.name or str(test_file)\n\n        cases = test.get(\"cases\", [])\n        if not isinstance(cases, list):\n            raise SystemExit(f\"ERROR: 'cases' must be a list in {display_file}.\")\n\n        for case in cases:\n            if not isinstance(case, dict):\n                raise SystemExit(f\"ERROR: each case must be an object in {display_file}.\")\n\n            case_name = str(case.get(\"name\") or \"unnamed_case\")\n            status = bool(case.get(\"pass\"))\n            outcome = colorize(\n                \"PASSED\" if status else \"FAILED\",\n                GREEN if status else RED,\n                use_color,\n            )\n            print(f'{display_file}::\"{case_name}\" {outcome}')\n\n            if status:\n                passed += 1\n            else:\n                failed += 1\n\n    return passed, failed\n\n\ndef render_summary_line(summary: str, duration: float, *, use_color: bool, failed: int) -> str:\n    duration_text = f\"in {duration:.2f}s\"\n    if failed:\n        summary_color = RED\n    elif summary == \"no tests ran\":\n        summary_color = YELLOW\n    else:\n        summary_color = GREEN\n\n    text = f\"{summary} {duration_text}\"\n    colored_text = colorize(f\" {text} \", summary_color, use_color)\n    terminal_width = shutil.get_terminal_size((80, 20)).columns\n    base = f\"=== {text} ===\"\n\n    if terminal_width <= len(base):\n        return colorize(base, summary_color, use_color)\n\n    extra = terminal_width - len(base)\n    left_extra = extra // 2\n    right_extra = extra - left_extra\n    left = \"===\" + \"=\" * left_extra\n    right = \"=\" * right_extra + \"===\"\n\n    return f\"{left}{colored_text}{right}\"\n\n\ndef main(argv: list[str] | None = None) -> int:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"tests_dir\", nargs=\"?\", default=\"tests_ai\")\n    args = parser.parse_args(argv)\n\n    script_dir = Path(__file__).resolve().parent\n    tests_dir = Path(args.tests_dir).resolve()\n\n    if not tests_dir.is_dir():\n        raise SystemExit(f\"ERROR: tests directory '{tests_dir}' does not exist.\")\n\n    start = time.perf_counter()\n    run_agent(script_dir, tests_dir)\n    duration = time.perf_counter() - start\n\n    report = load_report(tests_dir / \"report.json\")\n    use_color = sys.stdout.isatty()\n    passed, failed = emit_results(report, use_color=use_color)\n\n    if failed:\n        summary = f\"{failed} failed\"\n        if passed:\n            summary += f\", {passed} passed\"\n    elif passed:\n        summary = f\"{passed} passed\"\n    else:\n        summary = \"no tests ran\"\n\n    print()\n    summary_line = render_summary_line(summary, duration, use_color=use_color, failed=failed)\n    print(summary_line)\n\n    return 1 if failed else 0\n\n\nif __name__ == \"__main__\":\n    try:\n        raise SystemExit(main())\n    except subprocess.CalledProcessError as exc:\n        raise SystemExit(exc.returncode) from exc\n"
  },
  {
    "path": "tests_ai/scripts/worker.yaml",
    "content": "version: 1\nagent:\n  extend: default\n  system_prompt_args:\n    ROLE_ADDITIONAL: |\n      <role>\n      !!IMPORTANT!!\n\n      You are a subagent spawned by a main agent that is auditing the current codebase. The main agent will assign you one or more test files, and you must examine the codebase with the instructions in the test files, determine whether each \"case\" can \"pass\" or not.\n\n      <test_file_specification>\n      Each of the test file contains the instruction to test/audit a specific aspect of the requirements/invariants we want to ensure in this codebase. The level-1 header in each of these files is the \"name\" of the \"test\". In each of the \"test\"s, there may be one or more \"case\"s. The level-2 headers provide the \"name\"s of such \"case\"s, in the form of `Case <n>: <case-name>` or `<case-name>`. In each \"case\", a \"Scope\" section and a \"Requirements\" section are given, specifying the scope in the codebase you should examine for this \"case\", and the required invariants, respectively.\n      </test_file_specification>\n\n      You must scan over ALL and ONLY the files/directories specified in the `Scope` section, carefully examine all possible function calls, statements, operations that violate the \"Requirements\". You MUST ONLY examine the specified requirements, DO NOT diverge. Each case has its own scope, DO NOT refer to files in another case's scope when examining one case.\n\n      Cases may not be related to each other, you are encouraged to call SendDMail tool to rewind the context once you finish one case, to reduce interference. You should leave clear message about the cases you already examined in the D-Mail to avoid duplicated work.\n\n      Once you finish the audit tasks the main agent assigned to you, you should respond in the last message with a clear summary. The summary MUST include the \"test\"'s name, all the \"case\"s' names, whether each \"case\" can \"pass\", all the spots that cause the unsatisfaction of the defined requirements of each \"case\" and suggestion on how to fix.\n      </role>\n  tools:\n    - \"kimi_cli.tools.dmail:SendDMail\"\n    - \"kimi_cli.tools.think:Think\"\n    - \"kimi_cli.tools.todo:SetTodoList\"\n    - \"kimi_cli.tools.shell:Shell\"\n    - \"kimi_cli.tools.file:ReadFile\"\n    - \"kimi_cli.tools.file:Glob\"\n    - \"kimi_cli.tools.file:Grep\"\n    # - \"kimi_cli.tools.web:SearchWeb\"\n    # - \"kimi_cli.tools.web:FetchURL\"\n  subagents:\n"
  },
  {
    "path": "tests_ai/test_cli_loading_time.md",
    "content": "# CLI Loading Time\n\n## `src/kimi_cli/__init__.py` be empty\n\n**Scope**\n\n`src/kimi_cli/__init__.py`\n\n**Requirements**\n\nThe `src/kimi_cli/__init__.py` file must be empty, containing no code or imports.\n\n## No unnecessary import in `src/kimi_cli/cli.py`\n\n**Scope**\n\n`src/kimi_cli/cli.py`\n\n**Requirements**\n\nThe `src/kimi_cli/cli.py` file must not import any modules from `kimi_cli` or `kosong`, except for `kimi_cli.constant`, at the top level.\n\n## As-needed imports in `src/kimi_cli/app.py`\n\n**Scope**\n\n`src/kimi_cli/app.py`\n\n**Requirements**\n\nThe `src/kimi_cli/app.py` file must not import any modules prefixed with `kimi_cli.ui` at the top level; instead, UI-specific modules should be imported within functions as needed.\n\n<examples>\n\n```python\n# top-level\nfrom kimi_cli.ui.shell import ShellApp  # Incorrect: top-level import of UI module\n\n# inside function\nasync def run_shell_app(...):\n    from kimi_cli.ui.shell import ShellApp  # Correct: import as needed\n    app = ShellApp(...)\n    await app.run()\n```\n\n</examples>\n\n## `--help` should run fast\n\n**Scope**\n\nNo specific source file.\n\n**Requirements**\n\nThe time taken to run `uv run kimi --help` must be less than 150 milliseconds on average over 5 runs after a 3-run warm-up.\n"
  },
  {
    "path": "tests_ai/test_encoding_error_handling.md",
    "content": "# Error Handling for Decoding\n\n## Error handling when decoding user-provided content\n\n**Scope**\n\nAll Python files inside `src/kimi_cli/tools/` except for the `load_desc` function.\n\n**Requirements**\n\nWhen decoding user-provided content, for example, reading files, decoding subprocess output, etc., `errors=\"replace\"` must be specified to avoid runtime panics due to malformed UTF-8 sequences.\n\nWriting files and encoding Python strings to bytes do not require `errors=\"replace\"`.\n\n<examples>\n```python\nsubprocess.run(..., encoding=\"utf-8\", errors=\"replace\")  # Correct: replaces undecodable bytes\naiofiles.open(..., encoding=\"utf-8\", errors=\"replace\")  # Correct: replaces undecodable bytes\n```\n</examples>\n"
  },
  {
    "path": "tests_ai/test_utf8_encoding.md",
    "content": "# UTF-8 Encoding and Decoding\n\n## Explicit UTF-8 encoding\n\n**Scope**\n\nAll Python files inside `src/kimi_cli/`.\n\n**Requirements**\n\nWhen reading or writing files, encoding or decoding text, `encoding=\"utf-8\"` must be explicitly specified.\n\n<examples>\n```python\ntext.encode()  # Incorrect: relies on default encoding\npath.read_text(encoding=\"utf-8\")  # Correct: explicitly specifies UTF-8\nwith open(file, \"r\", encoding=\"utf-8\") as f:  # Correct\nwith aiofiles.open(file, \"w\", encoding=\"utf-8\") as f:  # Correct\nprocess.output.decode() # Incorrect: relies on default encoding\n```\n</examples>\n"
  },
  {
    "path": "tests_e2e/AGENTS.md",
    "content": "# tests_e2e Wire E2E Guide\n\n## Goals and Scope\n- Test only `kimi --wire` JSON-RPC + wire messages; no Shell UI/Print/ACP/Term/shortcuts.\n- Do not test `--agent okabe`.\n- Do not test: W-23, W-26, W-29, W-27 (env overrides).\n\n## Execution Rules\n- Tests run via `uv run kimi` by default; set `KIMI_E2E_WIRE_CMD` to override the base command\n  (e.g. `../kimi-agent-rs/target/debug/kimi-agent` or `kimi-agent` on PATH). `--wire` is appended if missing.\n- Always isolate `HOME`, `USERPROFILE`, and `KIMI_SHARE_DIR`, and use a temporary `--work-dir` to\n  avoid touching real `~/.kimi`.\n- Use `inline_snapshot` for snapshot testing; snapshots can start empty and be updated later.\n- Wire traffic is line-delimited JSON; `event`/`request`/responses may interleave.\n\n## Test Matrix\n**Startup and Protocol**\n- W-01 Handshake: `initialize` returns `protocol_version=1.1`, `server`, and non-empty\n  `slash_commands`.\n- W-02 External tool registration and call: register `external_tools`, trigger a\n  `ToolCallRequest`, return `ToolResult`, turn finishes.\n- W-03 External tool conflict: registering a tool name that conflicts with a built-in is\n  rejected with a reason.\n- W-04 Prompt without handshake: send `prompt` without `initialize`, turn still completes.\n- W-05 LLM not set: missing config yields `-32001` (LLM is not set).\n- W-06 Max steps: set a tiny `--max-steps-per-turn`, response is `status=max_steps_reached`.\n\n**Prompt and Event Stream**\n- W-07 Basic turn: `TurnBegin`/`StepBegin`/`ContentPart(text)`/`StatusUpdate` flow.\n- W-08 Multiline input: `TurnBegin.user_input` preserves newlines.\n- W-09 Content parts input: `user_input` as `ContentPart[]` (text/image/audio/video) with\n  capabilities enabled.\n- W-10 Thinking toggle: `--thinking` emits `ContentPart(type=think)`, `--no-thinking` does not\n  (real LLM).\n- W-11 Concurrent prompt: second `prompt` returns `-32000` (turn already in progress).\n- W-12 Cancel turn: `cancel` returns `{}`, original `prompt` becomes `status=cancelled`\n  (may emit `StepInterrupted`, real LLM).\n\n**Tools and Approvals**\n- W-13 Shell approval: `Shell` triggers `ApprovalRequest`, `approve` yields `ToolResult`.\n- W-14 Approval reject: `reject` ends turn; tool does not run.\n- W-15 Session approval: `approve_for_session` removes subsequent blocking approvals.\n- W-16 YOLO: `--yolo` skips blocking approvals.\n- W-17 DisplayBlock coverage: `Shell`/`WriteFile`/`StrReplaceFile`/`SetTodoList` emit expected\n  display types.\n- W-18 Tool args streaming: `ToolCallPart.arguments_part` can be stitched into full args.\n\n**Sessions and Context**\n- W-19 Session files: `--session <id>` writes `context.jsonl` and `wire.jsonl`.\n- W-20 Continue session: `--continue` appends to the same session files.\n- W-21 Clear context: `/clear` ensures the next `prompt` does not rely on prior context.\n- W-22 Manual compaction: `/compact` triggers `CompactionBegin/CompactionEnd`.\n- W-24 Status stats: `StatusUpdate.context_usage/token_usage` types are valid and change over time.\n\n**Config and Runtime Flags**\n- W-25 `--config` inline: JSON/TOML string config works.\n- W-28 CLI override: `--model` overrides `default_model`.\n- W-30 Work dir: `prompt` asks \"where is the current directory\", verify `--work-dir` (real LLM).\n\n**Extensions (Agents/Skills/MCP)**\n- W-31 Built-in agent boundary: `default` calling `SendDMail` fails or is rejected.\n- W-32 Custom agent: `--agent-file` excludes tool and calls are rejected.\n- W-33 Subagents: prompt \"parallel call two task tools, each runs shell sleep 0.5 and 1 second\";\n  verify Task/SubagentEvent streaming/ToolResult/multiple approvals (real LLM).\n- W-34 Skill call: create a test skill, `/skill:test` injects `SKILL.md` (wire has no `/help`).\n- W-35 Flow skill: `/flow:<name>` runs the flow until `END`.\n- W-36 MCP: use a Python fastmcp test server, load via `--mcp-config-file`, verify tool works.\n\n**Resilience and Errors**\n- W-37 Invalid JSON: malformed JSON line returns `-32700`.\n- W-38 Invalid request: missing fields returns `-32600`.\n- W-39 Unknown method: returns `-32601`.\n- W-40 Invalid params: bad structure returns `-32602`.\n- W-41 Cancel without active turn: returns `-32000`.\n- W-42 LLM errors: unsupported model `-32002` and service error `-32003`.\n\n## Real LLM Placeholders\n- W-10/W-12/W-30/W-33 require a real provider; everything else uses `_scripted_echo`.\n"
  },
  {
    "path": "tests_e2e/__init__.py",
    "content": ""
  },
  {
    "path": "tests_e2e/test_mcp_cli.py",
    "content": "from __future__ import annotations\n\nimport json\nimport subprocess\nimport sys\nimport textwrap\nfrom pathlib import Path\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    base_command,\n    make_env,\n    make_home_dir,\n    normalize_value,\n    repo_root,\n    share_dir,\n)\n\n\ndef _normalize_cli_output(text: str, *, replace: dict[str, str] | None = None) -> str:\n    normalized = text\n    if replace:\n        for old, new in replace.items():\n            if old and old in normalized:\n                normalized = normalized.replace(old, new)\n    normalized = normalize_value(normalized)\n    normalized = normalized.replace(\"kimi-agent mcp\", \"<cmd> mcp\")\n    normalized = normalized.replace(\"kimi mcp\", \"<cmd> mcp\")\n    return normalized\n\n\ndef _run_cli(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]:\n    cmd = base_command() + args\n    return subprocess.run(\n        cmd,\n        cwd=repo_root(),\n        env=env,\n        text=True,\n        encoding=\"utf-8\",\n        errors=\"replace\",\n        capture_output=True,\n        timeout=30,\n    )\n\n\ndef _mcp_config_path(home_dir: Path) -> Path:\n    return share_dir(home_dir) / \"mcp.json\"\n\n\ndef _load_mcp_config(\n    home_dir: Path, *, replacements: dict[str, str] | None = None\n) -> dict[str, object]:\n    config_path = _mcp_config_path(home_dir)\n    assert config_path.exists()\n    data = json.loads(config_path.read_text(encoding=\"utf-8\"))\n    normalized = normalize_value(data, replacements=replacements)\n    assert isinstance(normalized, dict)\n    return normalized\n\n\ndef test_mcp_stdio_management(tmp_path: Path) -> None:\n    home_dir = make_home_dir(tmp_path)\n    env = make_env(home_dir)\n\n    server_path = tmp_path / \"mcp_server.py\"\n    server_path.write_text(\n        textwrap.dedent(\n            \"\"\"\n            from fastmcp.server import FastMCP\n\n            server = FastMCP(\"test-mcp\")\n\n            @server.tool\n            def ping(text: str) -> str:\n                \\\"\\\"\\\"pong the input text\\\"\\\"\\\"\n                return f\"pong:{text}\"\n\n            if __name__ == \"__main__\":\n                server.run(transport=\"stdio\", show_banner=False)\n            \"\"\"\n        ).strip()\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    replacements = {\n        str(sys.executable): \"<python>\",\n        str(server_path): \"<server>\",\n    }\n\n    add = _run_cli(\n        [\n            \"mcp\",\n            \"add\",\n            \"--transport\",\n            \"stdio\",\n            \"test\",\n            \"--\",\n            sys.executable,\n            str(server_path),\n        ],\n        env,\n    )\n    assert add.returncode == 0, _normalize_cli_output(add.stderr, replace=replacements)\n    assert _normalize_cli_output(add.stdout, replace=replacements) == snapshot(\n        \"Added MCP server 'test' to <home_dir>/.kimi/mcp.json.\\n\"\n    )\n    assert _load_mcp_config(home_dir, replacements=replacements) == snapshot(\n        {\"mcpServers\": {\"test\": {\"args\": [\"<server>\"], \"command\": \"<python>\"}}}\n    )\n\n    listed = _run_cli([\"mcp\", \"list\"], env)\n    assert listed.returncode == 0, _normalize_cli_output(listed.stderr, replace=replacements)\n    assert _normalize_cli_output(listed.stdout, replace=replacements) == snapshot(\n        \"\"\"\\\nMCP config file: <home_dir>/.kimi/mcp.json\n  test (stdio): <python> <server>\n\"\"\"\n    )\n\n    tested = _run_cli([\"mcp\", \"test\", \"test\"], env)\n    assert tested.returncode == 0, _normalize_cli_output(tested.stderr, replace=replacements)\n    assert _normalize_cli_output(tested.stdout, replace=replacements) == snapshot(\n        \"\"\"\\\nTesting connection to 'test'...\n✓ Connected to 'test'\n  Available tools: 1\n  Tools:\n    - ping: pong the input text\n\"\"\"\n    )\n\n    removed = _run_cli([\"mcp\", \"remove\", \"test\"], env)\n    assert removed.returncode == 0, _normalize_cli_output(removed.stderr, replace=replacements)\n    assert _normalize_cli_output(removed.stdout, replace=replacements) == snapshot(\n        \"Removed MCP server 'test' from <home_dir>/.kimi/mcp.json.\\n\"\n    )\n    assert _load_mcp_config(home_dir, replacements=replacements) == snapshot({\"mcpServers\": {}})\n\n    listed_empty = _run_cli([\"mcp\", \"list\"], env)\n    assert listed_empty.returncode == 0, _normalize_cli_output(\n        listed_empty.stderr, replace=replacements\n    )\n    assert _normalize_cli_output(listed_empty.stdout, replace=replacements) == snapshot(\n        \"\"\"\\\nMCP config file: <home_dir>/.kimi/mcp.json\nNo MCP servers configured.\n\"\"\"\n    )\n\n\ndef test_mcp_http_management_and_auth_errors(tmp_path: Path) -> None:\n    home_dir = make_home_dir(tmp_path)\n    env = make_env(home_dir)\n    replacements = {str(sys.executable): \"<python>\"}\n\n    add_http = _run_cli(\n        [\n            \"mcp\",\n            \"add\",\n            \"--transport\",\n            \"http\",\n            \"remote\",\n            \"https://example.com/mcp\",\n            \"--header\",\n            \"X-Test: 1\",\n        ],\n        env,\n    )\n    assert add_http.returncode == 0, _normalize_cli_output(add_http.stderr)\n    assert _normalize_cli_output(add_http.stdout) == snapshot(\n        \"Added MCP server 'remote' to <home_dir>/.kimi/mcp.json.\\n\"\n    )\n    assert _load_mcp_config(home_dir, replacements=replacements) == snapshot(\n        {\n            \"mcpServers\": {\n                \"remote\": {\n                    \"headers\": {\"X-Test\": \"1\"},\n                    \"transport\": \"http\",\n                    \"url\": \"https://example.com/mcp\",\n                }\n            }\n        }\n    )\n\n    add_oauth = _run_cli(\n        [\n            \"mcp\",\n            \"add\",\n            \"--transport\",\n            \"http\",\n            \"--auth\",\n            \"oauth\",\n            \"oauth\",\n            \"https://example.com/oauth\",\n        ],\n        env,\n    )\n    assert add_oauth.returncode == 0, _normalize_cli_output(add_oauth.stderr)\n    assert _normalize_cli_output(add_oauth.stdout) == snapshot(\n        \"Added MCP server 'oauth' to <home_dir>/.kimi/mcp.json.\\n\"\n    )\n    assert _load_mcp_config(home_dir, replacements=replacements) == snapshot(\n        {\n            \"mcpServers\": {\n                \"oauth\": {\n                    \"auth\": \"oauth\",\n                    \"transport\": \"http\",\n                    \"url\": \"https://example.com/oauth\",\n                },\n                \"remote\": {\n                    \"headers\": {\"X-Test\": \"1\"},\n                    \"transport\": \"http\",\n                    \"url\": \"https://example.com/mcp\",\n                },\n            }\n        }\n    )\n\n    list_http = _run_cli([\"mcp\", \"list\"], env)\n    assert list_http.returncode == 0, _normalize_cli_output(list_http.stderr)\n    assert _normalize_cli_output(list_http.stdout) == snapshot(\n        \"\"\"\\\nMCP config file: <home_dir>/.kimi/mcp.json\n  remote (http): https://example.com/mcp\n  oauth (http): https://example.com/oauth [authorization required - run: <cmd> mcp auth oauth]\n\"\"\"\n    )\n\n    auth_http = _run_cli([\"mcp\", \"auth\", \"remote\"], env)\n    assert auth_http.returncode != 0\n    assert _normalize_cli_output(auth_http.stderr) == snapshot(\n        \"MCP server 'remote' does not use OAuth. Add with --auth oauth.\\n\"\n    )\n\n    reset_http = _run_cli([\"mcp\", \"reset-auth\", \"remote\"], env)\n    assert reset_http.returncode == 0, _normalize_cli_output(reset_http.stderr)\n    assert _normalize_cli_output(reset_http.stdout) == snapshot(\n        \"OAuth tokens cleared for 'remote'.\\n\"\n    )\n\n    auth_stdio = _run_cli(\n        [\n            \"mcp\",\n            \"add\",\n            \"--transport\",\n            \"stdio\",\n            \"local\",\n            \"--\",\n            sys.executable,\n            \"-c\",\n            \"print('noop')\",\n        ],\n        env,\n    )\n    assert auth_stdio.returncode == 0, _normalize_cli_output(auth_stdio.stderr)\n    assert _load_mcp_config(home_dir, replacements=replacements) == snapshot(\n        {\n            \"mcpServers\": {\n                \"local\": {\"args\": [\"-c\", \"print('noop')\"], \"command\": \"<python>\"},\n                \"oauth\": {\n                    \"auth\": \"oauth\",\n                    \"transport\": \"http\",\n                    \"url\": \"https://example.com/oauth\",\n                },\n                \"remote\": {\n                    \"headers\": {\"X-Test\": \"1\"},\n                    \"transport\": \"http\",\n                    \"url\": \"https://example.com/mcp\",\n                },\n            }\n        }\n    )\n\n    auth_stdio = _run_cli([\"mcp\", \"auth\", \"local\"], env)\n    assert auth_stdio.returncode != 0\n    assert _normalize_cli_output(auth_stdio.stderr) == snapshot(\n        \"MCP server 'local' is not a remote server.\\n\"\n    )\n\n    reset_stdio = _run_cli([\"mcp\", \"reset-auth\", \"local\"], env)\n    assert reset_stdio.returncode != 0\n    assert _normalize_cli_output(reset_stdio.stderr) == snapshot(\n        \"MCP server 'local' is not a remote server.\\n\"\n    )\n\n    remove_missing = _run_cli([\"mcp\", \"remove\", \"missing\"], env)\n    assert remove_missing.returncode != 0\n    assert _normalize_cli_output(remove_missing.stderr) == snapshot(\n        \"MCP server 'missing' not found.\\n\"\n    )\n"
  },
  {
    "path": "tests_e2e/test_wire_approvals_tools.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom collections.abc import Mapping\nfrom typing import Any\n\nfrom inline_snapshot import snapshot\n\nfrom .wire_helpers import (\n    build_approval_response,\n    build_set_todo_call,\n    build_shell_tool_call,\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    normalize_value,\n    send_initialize,\n    start_wire,\n    summarize_messages,\n    write_scripted_config,\n)\n\n\ndef _extract_request_payload(messages: list[dict[str, Any]]) -> dict[str, Any]:\n    for msg in messages:\n        if msg.get(\"method\") != \"request\":\n            continue\n        params = msg.get(\"params\")\n        if not isinstance(params, dict):\n            continue\n        payload = params.get(\"payload\")\n        if isinstance(payload, dict):\n            return payload\n    raise AssertionError(\"Missing request payload\")\n\n\ndef _tool_call_line(tool_call_id: str, name: str, args: Mapping[str, Any]) -> str:\n    payload = {\"id\": tool_call_id, \"name\": name, \"arguments\": json.dumps(args)}\n    return f\"tool_call: {json.dumps(payload)}\"\n\n\ndef _display_types(payload: dict[str, Any]) -> list[str]:\n    display = payload.get(\"display\")\n    if not isinstance(display, list):\n        return []\n    types: list[str] = []\n    for item in display:\n        if not isinstance(item, dict):\n            continue\n        item_type = item.get(\"type\")\n        if isinstance(item_type, str):\n            types.append(item_type)\n    return types\n\n\ndef test_shell_approval_approve(tmp_path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-1\", \"echo ok\"),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run shell\"},\n            }\n        )\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"approve\"),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"run shell\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"step1\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"Shell\", \"arguments\": '{\"command\": \"echo ok\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"request\",\n                    \"type\": \"ApprovalRequest\",\n                    \"payload\": {\n                        \"id\": \"<uuid>\",\n                        \"tool_call_id\": \"tc-1\",\n                        \"sender\": \"Shell\",\n                        \"action\": \"run command\",\n                        \"description\": \"Run command `echo ok`\",\n                        \"display\": [{\"type\": \"shell\", \"language\": \"bash\", \"command\": \"echo ok\"}],\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ApprovalResponse\",\n                    \"payload\": {\"request_id\": \"<uuid>\", \"response\": \"approve\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"ok\\n\",\n                            \"message\": \"Command executed successfully.\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_shell_approval_reject(tmp_path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-1\", \"echo ok\"),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run shell\"},\n            }\n        )\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"reject\"),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"run shell\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"step1\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"Shell\", \"arguments\": '{\"command\": \"echo ok\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"request\",\n                    \"type\": \"ApprovalRequest\",\n                    \"payload\": {\n                        \"id\": \"<uuid>\",\n                        \"tool_call_id\": \"tc-1\",\n                        \"sender\": \"Shell\",\n                        \"action\": \"run command\",\n                        \"description\": \"Run command `echo ok`\",\n                        \"display\": [{\"type\": \"shell\", \"language\": \"bash\", \"command\": \"echo ok\"}],\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ApprovalResponse\",\n                    \"payload\": {\"request_id\": \"<uuid>\", \"response\": \"reject\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": True,\n                            \"output\": \"\",\n                            \"message\": \"The tool call is rejected by the user. Please follow the new instructions from the user.\",\n                            \"display\": [{\"type\": \"brief\", \"text\": \"Rejected by user\"}],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_approve_for_session(tmp_path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-1\", \"echo first\"),\n            ]\n        ),\n        \"text: done\",\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-2\", \"echo second\"),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run shell\"},\n            }\n        )\n        resp1, messages1 = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"approve_for_session\"),\n        )\n        assert resp1.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-2\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run shell again\"},\n            }\n        )\n        resp2, messages2 = collect_until_response(wire, \"prompt-2\")\n        assert resp2.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert all(msg.get(\"method\") != \"request\" for msg in messages2)\n        assert summarize_messages(messages1 + messages2) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"run shell\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"step1\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"Shell\", \"arguments\": '{\"command\": \"echo first\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"request\",\n                    \"type\": \"ApprovalRequest\",\n                    \"payload\": {\n                        \"id\": \"<uuid>\",\n                        \"tool_call_id\": \"tc-1\",\n                        \"sender\": \"Shell\",\n                        \"action\": \"run command\",\n                        \"description\": \"Run command `echo first`\",\n                        \"display\": [{\"type\": \"shell\", \"language\": \"bash\", \"command\": \"echo first\"}],\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ApprovalResponse\",\n                    \"payload\": {\"request_id\": \"<uuid>\", \"response\": \"approve_for_session\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"first\\n\",\n                            \"message\": \"Command executed successfully.\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"run shell again\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"step1\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-2\",\n                        \"function\": {\"name\": \"Shell\", \"arguments\": '{\"command\": \"echo second\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-2\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"second\\n\",\n                            \"message\": \"Command executed successfully.\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_yolo_skips_approval(tmp_path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-1\", \"echo ok\"),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run shell\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert all(msg.get(\"method\") != \"request\" for msg in messages)\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"run shell\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"step1\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"Shell\", \"arguments\": '{\"command\": \"echo ok\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"ok\\n\",\n                            \"message\": \"Command executed successfully.\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_display_block_shell(tmp_path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-1\", \"echo ok\"),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run shell\"},\n            }\n        )\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"approve\"),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        payload = _extract_request_payload(messages)\n        assert \"shell\" in _display_types(payload)\n        assert normalize_value(payload) == snapshot(\n            {\n                \"id\": \"<uuid>\",\n                \"tool_call_id\": \"tc-1\",\n                \"sender\": \"Shell\",\n                \"action\": \"run command\",\n                \"description\": \"Run command `echo ok`\",\n                \"display\": [{\"type\": \"shell\", \"language\": \"bash\", \"command\": \"echo ok\"}],\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_display_block_diff_write_file(tmp_path) -> None:\n    write_args = {\"path\": \"file.txt\", \"content\": \"hello\", \"mode\": \"overwrite\"}\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: write\",\n                _tool_call_line(\"tc-1\", \"WriteFile\", write_args),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"write file\"},\n            }\n        )\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"approve\"),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        payload = _extract_request_payload(messages)\n        assert \"diff\" in _display_types(payload)\n        assert normalize_value(payload) == snapshot(\n            {\n                \"id\": \"<uuid>\",\n                \"tool_call_id\": \"tc-1\",\n                \"sender\": \"WriteFile\",\n                \"action\": \"edit file\",\n                \"description\": \"Write file `<work_dir>/file.txt`\",\n                \"display\": [\n                    {\n                        \"type\": \"diff\",\n                        \"path\": \"<work_dir>/file.txt\",\n                        \"old_text\": \"\",\n                        \"new_text\": \"hello\",\n                    }\n                ],\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_display_block_diff_str_replace(tmp_path) -> None:\n    replace_args = {\n        \"path\": \"file.txt\",\n        \"edit\": {\"old\": \"hello\", \"new\": \"hi\", \"replace_all\": False},\n    }\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: replace\",\n                _tool_call_line(\"tc-1\", \"StrReplaceFile\", replace_args),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    (work_dir / \"file.txt\").write_text(\"hello\", encoding=\"utf-8\")\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"replace\"},\n            }\n        )\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"approve\"),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        payload = _extract_request_payload(messages)\n        assert \"diff\" in _display_types(payload)\n        assert normalize_value(payload) == snapshot(\n            {\n                \"id\": \"<uuid>\",\n                \"tool_call_id\": \"tc-1\",\n                \"sender\": \"StrReplaceFile\",\n                \"action\": \"edit file\",\n                \"description\": \"Edit file `<work_dir>/file.txt`\",\n                \"display\": [\n                    {\n                        \"type\": \"diff\",\n                        \"path\": \"<work_dir>/file.txt\",\n                        \"old_text\": \"hello\",\n                        \"new_text\": \"hi\",\n                    }\n                ],\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_display_block_todo(tmp_path) -> None:\n    script = \"\\n\".join(\n        [\"text: todo\", build_set_todo_call(\"tc-1\", [{\"title\": \"one\", \"status\": \"pending\"}])]\n    )\n    scripts = [script, \"text: done\"]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"todo\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"todo\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"todo\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\n                            \"name\": \"SetTodoList\",\n                            \"arguments\": '{\"todos\": [{\"title\": \"one\", \"status\": \"pending\"}]}',\n                        },\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"\",\n                            \"message\": \"Todo list updated\",\n                            \"display\": [\n                                {\"type\": \"todo\", \"items\": [{\"title\": \"one\", \"status\": \"pending\"}]}\n                            ],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_tool_call_part_streaming(tmp_path) -> None:\n    part_middle = '\"todos\":[{\"title\":\"a\",\"status\":\"pending\"}]'\n    part_middle_json = json.dumps({\"arguments_part\": part_middle})\n    script = \"\\n\".join(\n        [\n            \"text: start\",\n            f\"tool_call: {json.dumps({'id': 'tc-1', 'name': 'SetTodoList', 'arguments': None})}\",\n            'tool_call_part: {\"arguments_part\": \"{\"}',\n            f\"tool_call_part: {part_middle_json}\",\n            'tool_call_part: {\"arguments_part\": \"}\"}',\n            \"tool_call_part:\",\n        ]\n    )\n    config_path = write_scripted_config(tmp_path, [script, \"text: done\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"stream\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"stream\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"start\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"SetTodoList\", \"arguments\": None},\n                        \"extras\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"ToolCallPart\", \"payload\": {\"arguments_part\": \"{\"}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCallPart\",\n                    \"payload\": {\"arguments_part\": '\"todos\":[{\"title\":\"a\",\"status\":\"pending\"}]'},\n                },\n                {\"method\": \"event\", \"type\": \"ToolCallPart\", \"payload\": {\"arguments_part\": \"}\"}},\n                {\"method\": \"event\", \"type\": \"ToolCallPart\", \"payload\": {\"arguments_part\": None}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"\",\n                            \"message\": \"Todo list updated\",\n                            \"display\": [\n                                {\"type\": \"todo\", \"items\": [{\"title\": \"a\", \"status\": \"pending\"}]}\n                            ],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_default_agent_missing_tool(tmp_path) -> None:\n    dmail_args = {\"message\": \"hi\"}\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: missing tool\",\n                _tool_call_line(\"tc-1\", \"SendDMail\", dmail_args),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"dmail\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"dmail\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"missing tool\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"SendDMail\", \"arguments\": '{\"message\": \"hi\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": True,\n                            \"output\": \"\",\n                            \"message\": \"Tool `SendDMail` not found\",\n                            \"display\": [{\"type\": \"brief\", \"text\": \"Tool `SendDMail` not found\"}],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_custom_agent_exclude_tool(tmp_path) -> None:\n    shell_args = {\"command\": \"echo hi\"}\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: missing tool\",\n                _tool_call_line(\"tc-1\", \"Shell\", shell_args),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    agent_path = tmp_path / \"agent.yaml\"\n    agent_path.write_text(\n        \"\\n\".join(\n            [\n                \"version: 1\",\n                \"agent:\",\n                \"  extend: default\",\n                \"  exclude_tools:\",\n                '    - \"kimi_cli.tools.shell:Shell\"',\n            ]\n        ),\n        encoding=\"utf-8\",\n    )\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n        agent_file=agent_path,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"shell\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"shell\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"missing tool\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"Shell\", \"arguments\": '{\"command\": \"echo hi\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": True,\n                            \"output\": \"\",\n                            \"message\": \"Tool `Shell` not found\",\n                            \"display\": [{\"type\": \"brief\", \"text\": \"Tool `Shell` not found\"}],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_config.py",
    "content": "from __future__ import annotations\n\nimport json\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    send_initialize,\n    start_wire,\n    summarize_messages,\n    write_scripts_file,\n)\n\n\ndef test_config_string(tmp_path) -> None:\n    scripts_path = write_scripts_file(tmp_path, [\"text: ok\"])\n    config_data = {\n        \"default_model\": \"scripted\",\n        \"models\": {\n            \"scripted\": {\n                \"provider\": \"scripted_provider\",\n                \"model\": \"scripted_echo\",\n                \"max_context_size\": 100000,\n            }\n        },\n        \"providers\": {\n            \"scripted_provider\": {\n                \"type\": \"_scripted_echo\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_path)},\n            }\n        },\n    }\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=None,\n        config_text=json.dumps(config_data),\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"hi\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"ok\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_model_override(tmp_path) -> None:\n    scripts_a = write_scripts_file(tmp_path, [\"text: from A\"], name=\"scripts-a.json\")\n    scripts_b = write_scripts_file(tmp_path, [\"text: from B\"], name=\"scripts-b.json\")\n    config_data = {\n        \"default_model\": \"model-a\",\n        \"models\": {\n            \"model-a\": {\n                \"provider\": \"provider-a\",\n                \"model\": \"scripted_echo\",\n                \"max_context_size\": 100000,\n            },\n            \"model-b\": {\n                \"provider\": \"provider-b\",\n                \"model\": \"scripted_echo\",\n                \"max_context_size\": 100000,\n            },\n        },\n        \"providers\": {\n            \"provider-a\": {\n                \"type\": \"_scripted_echo\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_a)},\n            },\n            \"provider-b\": {\n                \"type\": \"_scripted_echo\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_b)},\n            },\n        },\n    }\n    config_path = tmp_path / \"config.json\"\n    config_path.write_text(json.dumps(config_data), encoding=\"utf-8\")\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        extra_args=[\"--model\", \"model-b\"],\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"hi\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"from B\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_errors.py",
    "content": "from __future__ import annotations\n\nimport json\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    normalize_response,\n    send_initialize,\n    start_wire,\n    write_scripted_config,\n    write_scripts_file,\n)\n\n\ndef test_invalid_json_request(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: ok\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        wire.send_raw(\"{not-json}\")\n        resp = wire.read_json()\n        assert normalize_response(resp) == snapshot(\n            {\"error\": {\"code\": -32700, \"message\": \"Invalid JSON format\", \"data\": None}}\n        )\n    finally:\n        wire.close()\n\n\ndef test_invalid_request(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: ok\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        wire.send_json({\"jsonrpc\": \"2.1\", \"id\": \"bad\"})\n        resp = wire.read_json()\n        assert normalize_response(resp) == snapshot(\n            {\"error\": {\"code\": -32600, \"message\": \"Invalid request\", \"data\": None}}\n        )\n    finally:\n        wire.close()\n\n\ndef test_unknown_method(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: ok\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        wire.send_json({\"jsonrpc\": \"2.0\", \"id\": \"bad\", \"method\": \"nope\"})\n        resp = wire.read_json()\n        assert normalize_response(resp) == snapshot(\n            {\"error\": {\"code\": -32601, \"message\": \"Unexpected method received: nope\", \"data\": None}}\n        )\n    finally:\n        wire.close()\n\n\ndef test_invalid_params(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: ok\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        wire.send_json({\"jsonrpc\": \"2.0\", \"id\": \"bad\", \"method\": \"prompt\", \"params\": {}})\n        resp = wire.read_json()\n        assert normalize_response(resp) == snapshot(\n            {\n                \"error\": {\n                    \"code\": -32602,\n                    \"message\": \"Invalid parameters for method `prompt`\",\n                    \"data\": None,\n                }\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_cancel_without_prompt(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: ok\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        wire.send_json({\"jsonrpc\": \"2.0\", \"id\": \"cancel\", \"method\": \"cancel\"})\n        resp = wire.read_json()\n        assert normalize_response(resp) == snapshot(\n            {\"error\": {\"code\": -32000, \"message\": \"No agent turn is in progress\", \"data\": None}}\n        )\n    finally:\n        wire.close()\n\n\ndef test_llm_not_supported(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: ok\"], capabilities=[])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\n                    \"user_input\": [\n                        {\"type\": \"text\", \"text\": \"hello\"},\n                        {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,AAA\"}},\n                    ]\n                },\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert normalize_response(resp) == snapshot(\n            {\n                \"error\": {\n                    \"code\": -32002,\n                    \"message\": \"LLM model 'scripted_echo' does not support required capability: image_in.\",\n                    \"data\": None,\n                }\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_llm_not_set(tmp_path) -> None:\n    scripts_path = write_scripts_file(tmp_path, [\"text: ok\"])\n    config_data = {\n        \"default_model\": \"bad-model\",\n        \"models\": {\n            \"bad-model\": {\n                \"provider\": \"bad-provider\",\n                \"model\": \"\",\n                \"max_context_size\": 100000,\n            }\n        },\n        \"providers\": {\n            \"bad-provider\": {\n                \"type\": \"kimi\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_path)},\n            }\n        },\n    }\n    config_path = tmp_path / \"config.json\"\n    config_path.write_text(json.dumps(config_data), encoding=\"utf-8\")\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert normalize_response(resp) == snapshot(\n            {\"error\": {\"code\": -32001, \"message\": \"LLM is not set\", \"data\": None}}\n        )\n    finally:\n        wire.close()\n\n\ndef test_llm_provider_error(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"bad line without colon\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert normalize_response(resp) == snapshot(\n            {\n                \"error\": {\n                    \"code\": -32003,\n                    \"message\": \"Invalid echo DSL at line 1: 'bad line without colon'\",\n                    \"data\": None,\n                }\n            }\n        )\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_prompt.py",
    "content": "from __future__ import annotations\n\nfrom typing import Any\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    build_approval_response,\n    build_set_todo_call,\n    build_shell_tool_call,\n    collect_until_request,\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    normalize_response,\n    read_response,\n    send_initialize,\n    start_wire,\n    summarize_messages,\n    write_scripted_config,\n)\n\n\ndef _find_event(messages: list[dict[str, Any]], event_type: str) -> dict[str, Any]:\n    for msg in messages:\n        if msg.get(\"method\") != \"event\":\n            continue\n        params = msg.get(\"params\")\n        if isinstance(params, dict) and params.get(\"type\") == event_type:\n            return params\n    raise AssertionError(f\"Missing event {event_type}\")\n\n\ndef test_basic_prompt_events(tmp_path) -> None:\n    script = \"\\n\".join(\n        [\n            \"id: scripted-1\",\n            'usage: {\"input_other\": 5, \"output\": 2}',\n            \"text: Hello wire\",\n        ]\n    )\n    config_path = write_scripted_config(tmp_path, [script])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"hi\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"Hello wire\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": 5e-05,\n                        \"context_tokens\": 5,\n                        \"max_context_tokens\": 100000,\n                        \"token_usage\": {\n                            \"input_other\": 5,\n                            \"output\": 2,\n                            \"input_cache_read\": 0,\n                            \"input_cache_creation\": 0,\n                        },\n                        \"message_id\": \"scripted-1\",\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_multiline_prompt(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: ok\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        user_input = \"line1\\nline2\"\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": user_input},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        turn_begin = _find_event(messages, \"TurnBegin\")\n        payload = turn_begin.get(\"payload\")\n        assert isinstance(payload, dict)\n        assert payload.get(\"user_input\") == user_input\n        assert turn_begin == snapshot(\n            {\n                \"type\": \"TurnBegin\",\n                \"payload\": {\n                    \"user_input\": \"\"\"\\\nline1\nline2\\\n\"\"\"\n                },\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_content_part_prompt(tmp_path) -> None:\n    config_path = write_scripted_config(\n        tmp_path,\n        [\"text: ok\"],\n        capabilities=[\"image_in\", \"video_in\"],\n    )\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    content_parts = [\n        {\"type\": \"text\", \"text\": \"hello\"},\n        {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,AAA\"}},\n        {\"type\": \"audio_url\", \"audio_url\": {\"url\": \"data:audio/aac;base64,AAA\"}},\n        {\"type\": \"video_url\", \"video_url\": {\"url\": \"data:video/mp4;base64,AAA\"}},\n    ]\n    expected_parts = [\n        {\"type\": \"text\", \"text\": \"hello\"},\n        {\n            \"type\": \"image_url\",\n            \"image_url\": {\"url\": \"data:image/png;base64,AAA\", \"id\": None},\n        },\n        {\n            \"type\": \"audio_url\",\n            \"audio_url\": {\"url\": \"data:audio/aac;base64,AAA\", \"id\": None},\n        },\n        {\n            \"type\": \"video_url\",\n            \"video_url\": {\"url\": \"data:video/mp4;base64,AAA\", \"id\": None},\n        },\n    ]\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": content_parts},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        turn_begin = _find_event(messages, \"TurnBegin\")\n        payload = turn_begin.get(\"payload\")\n        assert isinstance(payload, dict)\n        assert payload.get(\"user_input\") == expected_parts\n        assert turn_begin == snapshot(\n            {\n                \"type\": \"TurnBegin\",\n                \"payload\": {\n                    \"user_input\": [\n                        {\"type\": \"text\", \"text\": \"hello\"},\n                        {\n                            \"type\": \"image_url\",\n                            \"image_url\": {\"url\": \"data:image/png;base64,AAA\", \"id\": None},\n                        },\n                        {\n                            \"type\": \"audio_url\",\n                            \"audio_url\": {\"url\": \"data:audio/aac;base64,AAA\", \"id\": None},\n                        },\n                        {\n                            \"type\": \"video_url\",\n                            \"video_url\": {\"url\": \"data:video/mp4;base64,AAA\", \"id\": None},\n                        },\n                    ]\n                },\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_max_steps_reached(tmp_path) -> None:\n    todo_line = build_set_todo_call(\"tc-1\", [{\"title\": \"x\", \"status\": \"pending\"}])\n    script = \"\\n\".join(\n        [\n            \"text: start\",\n            todo_line,\n        ]\n    )\n    config_path = write_scripted_config(tmp_path, [script])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        extra_args=[\"--max-steps-per-turn\", \"1\"],\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"max_steps_reached\"\n        assert normalize_response(resp) == snapshot(\n            {\"result\": {\"status\": \"max_steps_reached\", \"steps\": 1}}\n        )\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"run\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"start\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\n                            \"name\": \"SetTodoList\",\n                            \"arguments\": '{\"todos\": [{\"title\": \"x\", \"status\": \"pending\"}]}',\n                        },\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"\",\n                            \"message\": \"Todo list updated\",\n                            \"display\": [\n                                {\n                                    \"type\": \"todo\",\n                                    \"items\": [{\"title\": \"x\", \"status\": \"pending\"}],\n                                }\n                            ],\n                            \"extras\": None,\n                        },\n                    },\n                },\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_status_update_fields(tmp_path) -> None:\n    script = \"\\n\".join(\n        [\n            \"id: scripted-1\",\n            'usage: {\"input_other\": 5, \"output\": 2}',\n            \"text: hello\",\n        ]\n    )\n    config_path = write_scripted_config(tmp_path, [script])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        _, messages = collect_until_response(wire, \"prompt-1\")\n        status = _find_event(messages, \"StatusUpdate\")\n        payload = status.get(\"payload\")\n        assert isinstance(payload, dict)\n        assert isinstance(payload.get(\"token_usage\"), dict)\n        assert status == snapshot(\n            {\n                \"type\": \"StatusUpdate\",\n                \"payload\": {\n                    \"context_usage\": 5e-05,\n                    \"context_tokens\": 5,\n                    \"max_context_tokens\": 100000,\n                    \"token_usage\": {\n                        \"input_other\": 5,\n                        \"output\": 2,\n                        \"input_cache_read\": 0,\n                        \"input_cache_creation\": 0,\n                    },\n                    \"message_id\": \"scripted-1\",\n                    \"plan_mode\": False,\n                    \"mcp_status\": None,\n                },\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_concurrent_prompt_error(tmp_path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-1\", \"echo hi\"),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run\"},\n            }\n        )\n        request_msg, messages = collect_until_request(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-2\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"second\"},\n            }\n        )\n        prompt2_resp = normalize_response(read_response(wire, \"prompt-2\"))\n        assert prompt2_resp == snapshot(\n            {\n                \"error\": {\n                    \"code\": -32000,\n                    \"message\": \"An agent turn is already in progress\",\n                    \"data\": None,\n                }\n            }\n        )\n\n        wire.send_json(build_approval_response(request_msg, \"approve\"))\n        prompt1_resp, messages_after = collect_until_response(wire, \"prompt-1\")\n        assert prompt1_resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages + messages_after) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"run\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"step1\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\n                            \"name\": \"Shell\",\n                            \"arguments\": '{\"command\": \"echo hi\"}',\n                        },\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"request\",\n                    \"type\": \"ApprovalRequest\",\n                    \"payload\": {\n                        \"id\": \"<uuid>\",\n                        \"tool_call_id\": \"tc-1\",\n                        \"sender\": \"Shell\",\n                        \"action\": \"run command\",\n                        \"description\": \"Run command `echo hi`\",\n                        \"display\": [{\"type\": \"shell\", \"language\": \"bash\", \"command\": \"echo hi\"}],\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ApprovalResponse\",\n                    \"payload\": {\"request_id\": \"<uuid>\", \"response\": \"approve\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"hi\\n\",\n                            \"message\": \"Command executed successfully.\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_protocol.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom typing import Any\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    normalize_response,\n    send_initialize,\n    start_wire,\n    summarize_messages,\n    write_scripted_config,\n)\n\n\ndef _as_dict(value: Any) -> dict[str, Any]:\n    return value if isinstance(value, dict) else {}\n\n\ndef test_initialize_handshake(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: hello\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        resp = send_initialize(wire)\n        result = _as_dict(resp.get(\"result\"))\n        assert result.get(\"protocol_version\") == \"1.5\"\n        assert \"slash_commands\" in result\n        assert normalize_response(resp) == snapshot(\n            {\n                \"result\": {\n                    \"protocol_version\": \"1.5\",\n                    \"server\": {\"name\": \"Kimi Code CLI\", \"version\": \"<VERSION>\"},\n                    \"slash_commands\": [\n                        {\n                            \"name\": \"init\",\n                            \"description\": \"Analyze the codebase and generate an `AGENTS.md` file\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"compact\",\n                            \"description\": \"Compact the context (optionally with a custom focus, e.g. /compact keep db discussions)\",\n                            \"aliases\": [],\n                        },\n                        {\"name\": \"clear\", \"description\": \"Clear the context\", \"aliases\": [\"reset\"]},\n                        {\n                            \"name\": \"yolo\",\n                            \"description\": \"Toggle YOLO mode (auto-approve all actions)\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"plan\",\n                            \"description\": \"Toggle plan mode. Usage: /plan [on|off|view|clear]\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"add-dir\",\n                            \"description\": \"Add a directory to the workspace. Usage: /add-dir <path>. Run without args to list added dirs\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"export\",\n                            \"description\": \"Export current session context to a markdown file\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"import\",\n                            \"description\": \"Import context from a file or session ID\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"skill:kimi-cli-help\",\n                            \"description\": \"Answer Kimi Code CLI usage, configuration, and troubleshooting questions. Use when user asks about Kimi Code CLI installation, setup, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, how something works internally, or any questions about Kimi Code CLI itself.\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"skill:skill-creator\",\n                            \"description\": \"Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Kimi's capabilities with specialized knowledge, workflows, or tool integrations.\",\n                            \"aliases\": [],\n                        },\n                    ],\n                    \"capabilities\": {\"supports_question\": True},\n                }\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_initialize_external_tool_conflict(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: hello\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    external_tools = [\n        {\n            \"name\": \"Shell\",\n            \"description\": \"Conflicts with built-in\",\n            \"parameters\": {\"type\": \"object\", \"properties\": {}},\n        }\n    ]\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        resp = send_initialize(wire, external_tools=external_tools)\n        result = _as_dict(resp.get(\"result\"))\n        external_tools_result = _as_dict(result.get(\"external_tools\"))\n        rejected = external_tools_result.get(\"rejected\")\n        assert isinstance(rejected, list)\n        assert any(isinstance(item, dict) and item.get(\"name\") == \"Shell\" for item in rejected)\n        assert normalize_response(resp) == snapshot(\n            {\n                \"result\": {\n                    \"protocol_version\": \"1.5\",\n                    \"server\": {\"name\": \"Kimi Code CLI\", \"version\": \"<VERSION>\"},\n                    \"slash_commands\": [\n                        {\n                            \"name\": \"init\",\n                            \"description\": \"Analyze the codebase and generate an `AGENTS.md` file\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"compact\",\n                            \"description\": \"Compact the context (optionally with a custom focus, e.g. /compact keep db discussions)\",\n                            \"aliases\": [],\n                        },\n                        {\"name\": \"clear\", \"description\": \"Clear the context\", \"aliases\": [\"reset\"]},\n                        {\n                            \"name\": \"yolo\",\n                            \"description\": \"Toggle YOLO mode (auto-approve all actions)\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"plan\",\n                            \"description\": \"Toggle plan mode. Usage: /plan [on|off|view|clear]\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"add-dir\",\n                            \"description\": \"Add a directory to the workspace. Usage: /add-dir <path>. Run without args to list added dirs\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"export\",\n                            \"description\": \"Export current session context to a markdown file\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"import\",\n                            \"description\": \"Import context from a file or session ID\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"skill:kimi-cli-help\",\n                            \"description\": \"Answer Kimi Code CLI usage, configuration, and troubleshooting questions. Use when user asks about Kimi Code CLI installation, setup, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, how something works internally, or any questions about Kimi Code CLI itself.\",\n                            \"aliases\": [],\n                        },\n                        {\n                            \"name\": \"skill:skill-creator\",\n                            \"description\": \"Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Kimi's capabilities with specialized knowledge, workflows, or tool integrations.\",\n                            \"aliases\": [],\n                        },\n                    ],\n                    \"external_tools\": {\n                        \"accepted\": [],\n                        \"rejected\": [{\"name\": \"Shell\", \"reason\": \"conflicts with builtin tool\"}],\n                    },\n                    \"capabilities\": {\"supports_question\": True},\n                }\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_external_tool_call(tmp_path) -> None:\n    tool_args = json.dumps({\"path\": \"README.md\"})\n    tool_call = json.dumps({\"id\": \"tc-1\", \"name\": \"ext_tool\", \"arguments\": tool_args})\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: calling external tool\",\n                f\"tool_call: {tool_call}\",\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    external_tools = [\n        {\n            \"name\": \"ext_tool\",\n            \"description\": \"External tool\",\n            \"parameters\": {\n                \"type\": \"object\",\n                \"properties\": {\"path\": {\"type\": \"string\"}},\n                \"required\": [\"path\"],\n            },\n        }\n    ]\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire, external_tools=external_tools)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run external tool\"},\n            }\n        )\n\n        def handle_request(msg: dict[str, Any]) -> dict[str, Any]:\n            params = msg.get(\"params\")\n            payload = params.get(\"payload\") if isinstance(params, dict) else None\n            tool_call_id = payload.get(\"id\") if isinstance(payload, dict) else None\n            assert isinstance(tool_call_id, str)\n            return {\n                \"jsonrpc\": \"2.0\",\n                \"id\": msg.get(\"id\"),\n                \"result\": {\n                    \"tool_call_id\": tool_call_id,\n                    \"return_value\": {\n                        \"is_error\": False,\n                        \"output\": \"Opened\",\n                        \"message\": \"Opened README.md\",\n                        \"display\": [],\n                    },\n                },\n            }\n\n        resp, messages = collect_until_response(wire, \"prompt-1\", request_handler=handle_request)\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"run external tool\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"calling external tool\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"ext_tool\", \"arguments\": '{\"path\": \"README.md\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"request\",\n                    \"type\": \"ToolCallRequest\",\n                    \"payload\": {\n                        \"id\": \"tc-1\",\n                        \"name\": \"ext_tool\",\n                        \"arguments\": '{\"path\": \"README.md\"}',\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"Opened\",\n                            \"message\": \"Opened README.md\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_prompt_without_initialize(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: hello without init\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"hi\"}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"hello without init\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_question.py",
    "content": "\"\"\"E2E tests for AskUserQuestion via the Wire protocol.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import Any\n\nfrom .wire_helpers import (\n    build_ask_user_tool_call,\n    build_question_response,\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    send_initialize,\n    start_wire,\n    summarize_messages,\n    write_scripted_config,\n)\n\n\ndef _make_question(\n    question: str = \"Which option?\",\n    options: list[dict[str, str]] | None = None,\n    multi_select: bool = False,\n) -> dict[str, Any]:\n    if options is None:\n        options = [\n            {\"label\": \"Alpha\", \"description\": \"First\"},\n            {\"label\": \"Beta\", \"description\": \"Second\"},\n        ]\n    return {\n        \"question\": question,\n        \"header\": \"Test\",\n        \"options\": options,\n        \"multi_select\": multi_select,\n    }\n\n\ndef _question_request_handler(answers: dict[str, str]):\n    \"\"\"Return a request_handler that responds to QuestionRequest with the given answers.\"\"\"\n\n    def handler(msg: dict[str, Any]) -> dict[str, Any]:\n        params = msg.get(\"params\", {})\n        msg_type = params.get(\"type\")\n        if msg_type == \"QuestionRequest\":\n            return build_question_response(msg, answers)\n        # For other request types (e.g. approval), just approve\n        from .wire_helpers import build_approval_response\n\n        return build_approval_response(msg, \"approve\")\n\n    return handler\n\n\ndef test_question_request_answer(tmp_path) -> None:\n    \"\"\"Test normal question → answer flow through Wire protocol.\"\"\"\n    question = _make_question()\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: asking\",\n                build_ask_user_tool_call(\"tc-q1\", [question]),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,  # yolo to skip approval for other tools\n    )\n    try:\n        send_initialize(wire, capabilities={\"supports_question\": True})\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"ask me\"},\n            }\n        )\n\n        answers = {\"Which option?\": \"Alpha\"}\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=_question_request_handler(answers),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        # Verify the QuestionRequest was sent\n        summary = summarize_messages(messages)\n        question_requests = [m for m in summary if m.get(\"type\") == \"QuestionRequest\"]\n        assert len(question_requests) == 1\n        qr_payload = question_requests[0][\"payload\"]\n        assert qr_payload[\"tool_call_id\"] == \"tc-q1\"\n        assert len(qr_payload[\"questions\"]) == 1\n        assert qr_payload[\"questions\"][0][\"question\"] == \"Which option?\"\n\n        # Verify the ToolResult contains the answers\n        tool_results = [m for m in summary if m.get(\"type\") == \"ToolResult\"]\n        assert len(tool_results) >= 1\n        for tr in tool_results:\n            rv = tr[\"payload\"][\"return_value\"]\n            if tr[\"payload\"][\"tool_call_id\"] == \"tc-q1\":\n                assert not rv[\"is_error\"]\n                output = json.loads(rv[\"output\"])\n                assert output == {\"answers\": {\"Which option?\": \"Alpha\"}}\n                break\n        else:\n            raise AssertionError(\"ToolResult for tc-q1 not found\")\n    finally:\n        wire.close()\n\n\ndef test_question_request_error_response(tmp_path) -> None:\n    \"\"\"Test that a JSON-RPC error response resolves to empty answers without crash.\"\"\"\n    question = _make_question()\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: asking\",\n                build_ask_user_tool_call(\"tc-q2\", [question]),\n            ]\n        ),\n        \"text: done after error\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire, capabilities={\"supports_question\": True})\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"ask me\"},\n            }\n        )\n\n        def error_handler(msg: dict[str, Any]) -> dict[str, Any]:\n            params = msg.get(\"params\", {})\n            msg_type = params.get(\"type\")\n            if msg_type == \"QuestionRequest\":\n                return {\n                    \"jsonrpc\": \"2.0\",\n                    \"id\": msg.get(\"id\"),\n                    \"error\": {\"code\": -32000, \"message\": \"User cancelled\"},\n                }\n            from .wire_helpers import build_approval_response\n\n            return build_approval_response(msg, \"approve\")\n\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=error_handler,\n        )\n        # Should complete normally (not crash)\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        # Verify the ToolResult has empty answers (error response resolves to {})\n        summary = summarize_messages(messages)\n        tool_results = [m for m in summary if m.get(\"type\") == \"ToolResult\"]\n        for tr in tool_results:\n            if tr[\"payload\"][\"tool_call_id\"] == \"tc-q2\":\n                rv = tr[\"payload\"][\"return_value\"]\n                assert not rv[\"is_error\"]\n                output = json.loads(rv[\"output\"])\n                assert output[\"answers\"] == {}\n                assert \"dismissed\" in output.get(\"note\", \"\").lower()\n                break\n        else:\n            raise AssertionError(\"ToolResult for tc-q2 not found\")\n    finally:\n        wire.close()\n\n\ndef test_question_capability_negotiation(tmp_path) -> None:\n    \"\"\"Test that the server reports supports_question in its capabilities.\"\"\"\n    scripts = [\"text: hello\"]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n    )\n    try:\n        resp = send_initialize(wire, capabilities={\"supports_question\": True})\n        result = resp.get(\"result\", {})\n        # Server should always report supports_question: True\n        caps = result.get(\"capabilities\", {})\n        assert caps.get(\"supports_question\") is True\n    finally:\n        wire.close()\n\n\ndef test_ask_user_tool_hidden_when_question_not_supported(tmp_path) -> None:\n    \"\"\"When question support is disabled, AskUserQuestion should not emit QuestionRequest.\"\"\"\n    question = _make_question()\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: asking\",\n                build_ask_user_tool_call(\"tc-q-hidden\", [question]),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        # Initialize WITHOUT supports_question (defaults to false)\n        send_initialize(wire, capabilities={\"supports_question\": False})\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"ask me\"},\n            }\n        )\n\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=_question_request_handler({}),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        # The client does not support QuestionRequest, so no QuestionRequest should be emitted.\n        summary = summarize_messages(messages)\n        question_requests = [m for m in summary if m.get(\"type\") == \"QuestionRequest\"]\n        assert len(question_requests) == 0, (\n            \"AskUserQuestion tool should be hidden when client does not support questions\"\n        )\n\n        # The scripted AskUserQuestion call should complete with a tool error indicating\n        # the client cannot handle interactive questions.\n        tool_results = [m for m in summary if m.get(\"type\") == \"ToolResult\"]\n        for tr in tool_results:\n            if tr[\"payload\"][\"tool_call_id\"] != \"tc-q-hidden\":\n                continue\n            rv = tr[\"payload\"][\"return_value\"]\n            assert rv[\"is_error\"] is True\n            assert \"does not support interactive questions\" in rv[\"message\"]\n            assert \"Do NOT call this tool again\" in rv[\"message\"]\n            break\n        else:\n            raise AssertionError(\"ToolResult for tc-q-hidden not found\")\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_real_llm.py",
    "content": "from __future__ import annotations\n\nimport pytest\n\n\n@pytest.mark.skip(reason=\"requires real LLM\")\ndef test_work_dir_prompt() -> None:\n    pass\n\n\n@pytest.mark.skip(reason=\"requires real LLM\")\ndef test_parallel_task_subagents() -> None:\n    pass\n\n\n@pytest.mark.skip(reason=\"requires real LLM\")\ndef test_thinking_mode_toggle() -> None:\n    pass\n\n\n@pytest.mark.skip(reason=\"requires real LLM\")\ndef test_cancel_prompt() -> None:\n    pass\n"
  },
  {
    "path": "tests_e2e/test_wire_sessions.py",
    "content": "from __future__ import annotations\n\nimport hashlib\nimport json\nfrom pathlib import Path\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    build_approval_response,\n    build_shell_tool_call,\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    send_initialize,\n    share_dir,\n    start_wire,\n    summarize_messages,\n    write_scripted_config,\n)\n\n\ndef _session_dir(home_dir: Path, work_dir: Path) -> Path:\n    digest = hashlib.md5(str(work_dir).encode(\"utf-8\")).hexdigest()\n    return share_dir(home_dir) / \"sessions\" / digest\n\n\ndef _count_lines(path: Path) -> int:\n    if not path.exists():\n        return 0\n    return len(path.read_text(encoding=\"utf-8\").splitlines())\n\n\ndef _read_roles(path: Path) -> list[str]:\n    if not path.exists():\n        return []\n    roles: list[str] = []\n    for line in path.read_text(encoding=\"utf-8\").splitlines():\n        if not line.strip():\n            continue\n        roles.append(json.loads(line)[\"role\"])\n    return roles\n\n\ndef test_session_files_created(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: hello\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    session_id = \"e2e-session\"\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        extra_args=[\"--session\", session_id],\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n    finally:\n        wire.close()\n\n    session_dir = _session_dir(home_dir, work_dir) / session_id\n    context_file = session_dir / \"context.jsonl\"\n    wire_file = session_dir / \"wire.jsonl\"\n    assert context_file.exists()\n    assert wire_file.exists()\n    assert context_file.stat().st_size > 0\n    assert wire_file.stat().st_size > 0\n    assert sorted(p.name for p in session_dir.iterdir()) == snapshot(\n        [\"context.jsonl\", \"wire.jsonl\"]\n    )\n\n\ndef test_continue_session_appends(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: first\", \"text: second\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"first\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n    finally:\n        wire.close()\n\n    session_root = _session_dir(home_dir, work_dir)\n    session_ids = [p.name for p in session_root.iterdir() if p.is_dir()]\n    assert len(session_ids) == 1\n    session_id = session_ids[0]\n    session_dir = session_root / session_id\n    context_file = session_dir / \"context.jsonl\"\n    wire_file = session_dir / \"wire.jsonl\"\n    context_before = _count_lines(context_file)\n    wire_before = _count_lines(wire_file)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        extra_args=[\"--continue\"],\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-2\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"second\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-2\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n    finally:\n        wire.close()\n\n    context_after = _count_lines(context_file)\n    wire_after = _count_lines(wire_file)\n    assert context_after > context_before\n    assert wire_after > wire_before\n    assert {\n        \"context_before\": context_before,\n        \"context_after\": context_after,\n        \"wire_before\": wire_before,\n        \"wire_after\": wire_after,\n    } == snapshot({\"context_before\": 5, \"context_after\": 9, \"wire_before\": 6, \"wire_after\": 11})\n    assert _read_roles(context_file) == snapshot(\n        [\n            \"_system_prompt\",\n            \"_checkpoint\",\n            \"user\",\n            \"_checkpoint\",\n            \"assistant\",\n            \"_checkpoint\",\n            \"user\",\n            \"_checkpoint\",\n            \"assistant\",\n        ]\n    )\n\n\ndef test_clear_context_rotates(tmp_path) -> None:\n    config_path = write_scripted_config(tmp_path, [\"text: hello\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-2\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"/clear\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-2\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"/clear\"}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"The context has been cleared.\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": 0.0,\n                        \"context_tokens\": 0,\n                        \"max_context_tokens\": 100000,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": None,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n    session_root = _session_dir(home_dir, work_dir)\n    session_ids = [p.name for p in session_root.iterdir() if p.is_dir()]\n    assert len(session_ids) == 1\n    session_dir = session_root / session_ids[0]\n    context_file = session_dir / \"context.jsonl\"\n    assert _read_roles(context_file) == snapshot([\"_system_prompt\"])\n    rotated = sorted(\n        p.name\n        for p in session_dir.iterdir()\n        if p.is_file() and p.name.startswith(\"context_\") and p.suffix == \".jsonl\"\n    )\n    assert rotated == snapshot([\"context_1.jsonl\"])\n    assert _read_roles(session_dir / rotated[0]) == snapshot(\n        [\"_system_prompt\", \"_checkpoint\", \"user\", \"_checkpoint\", \"assistant\"]\n    )\n\n\ndef test_manual_compact(tmp_path) -> None:\n    scripts = [\n        \"text: hello\",\n        \"text: compacted summary\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-2\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"/compact\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-2\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\"method\": \"event\", \"type\": \"TurnBegin\", \"payload\": {\"user_input\": \"/compact\"}},\n                {\"method\": \"event\", \"type\": \"CompactionBegin\", \"payload\": {}},\n                {\"method\": \"event\", \"type\": \"CompactionEnd\", \"payload\": {}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"The context has been compacted.\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": 1e-05,\n                        \"context_tokens\": 1,\n                        \"max_context_tokens\": 100000,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": None,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_manual_compact_with_usage(tmp_path) -> None:\n    \"\"\"Compaction with enough messages to trigger an actual LLM call that returns usage.\"\"\"\n    scripts = [\n        \"text: hello\\nusage: input_other=10 output=5\",\n        \"text: I'm good\\nusage: input_other=30 output=8\",\n        \"text: compacted summary\\nusage: input_other=50 output=20\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n\n        # Two rounds of conversation to build up context beyond max_preserved_messages=2\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"hi\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-2\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"how are you\"},\n            }\n        )\n        resp, _ = collect_until_response(wire, \"prompt-2\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        # Now compact — this triggers a real compaction LLM call (script 3)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-3\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"/compact\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-3\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        # Verify context_usage is non-zero (usage.output=20 + preserved text estimate)\n        status_msg = [m for m in messages if m.get(\"params\", {}).get(\"type\") == \"StatusUpdate\"]\n        assert len(status_msg) == 1\n        context_usage = status_msg[0][\"params\"][\"payload\"][\"context_usage\"]\n        assert context_usage > 0, \"context_usage should be non-zero after compaction with usage\"\n    finally:\n        wire.close()\n\n\ndef test_replay_streams_wire_history(tmp_path) -> None:\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: step1\",\n                build_shell_tool_call(\"tc-1\", \"echo ok\"),\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        extra_args=[\"--session\", \"replay-session\"],\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run shell\"},\n            }\n        )\n        resp, _ = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"approve\"),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n\n        wire.send_json({\"jsonrpc\": \"2.0\", \"id\": \"replay-1\", \"method\": \"replay\"})\n        resp, messages = collect_until_response(wire, \"replay-1\")\n        assert resp.get(\"result\") == snapshot(\n            {\n                \"status\": \"finished\",\n                \"events\": 11,\n                \"requests\": 1,\n            }\n        )\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"run shell\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"step1\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"Shell\", \"arguments\": '{\"command\": \"echo ok\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"request\",\n                    \"type\": \"ApprovalRequest\",\n                    \"payload\": {\n                        \"id\": \"<uuid>\",\n                        \"tool_call_id\": \"tc-1\",\n                        \"sender\": \"Shell\",\n                        \"action\": \"run command\",\n                        \"description\": \"Run command `echo ok`\",\n                        \"display\": [{\"type\": \"shell\", \"language\": \"bash\", \"command\": \"echo ok\"}],\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ApprovalResponse\",\n                    \"payload\": {\"request_id\": \"<uuid>\", \"response\": \"approve\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": \"ok\\n\",\n                            \"message\": \"Command executed successfully.\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_skills_mcp.py",
    "content": "from __future__ import annotations\n\nimport hashlib\nimport json\nimport sys\nimport textwrap\nfrom pathlib import Path\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    build_approval_response,\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    send_initialize,\n    share_dir,\n    start_wire,\n    summarize_messages,\n    write_scripted_config,\n)\n\n\ndef _session_dir(home_dir: Path, work_dir: Path, session_id: str) -> Path:\n    digest = hashlib.md5(str(work_dir).encode(\"utf-8\")).hexdigest()\n    return share_dir(home_dir) / \"sessions\" / digest / session_id\n\n\ndef _read_user_texts(context_file: Path) -> list[str]:\n    texts: list[str] = []\n    for line in context_file.read_text(encoding=\"utf-8\").splitlines():\n        if not line.strip():\n            continue\n        payload = json.loads(line)\n        if payload.get(\"role\") != \"user\":\n            continue\n        content = payload.get(\"content\", \"\")\n        if isinstance(content, str):\n            texts.append(content)\n            continue\n        if isinstance(content, list):\n            text = \"\".join(\n                part.get(\"text\", \"\")\n                for part in content\n                if isinstance(part, dict) and part.get(\"type\") == \"text\"\n            )\n            texts.append(text)\n    return texts\n\n\ndef _normalize_newlines(text: str) -> str:\n    return text.replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n\n\ndef test_skill_prompt_injects_skill_text(tmp_path) -> None:\n    skill_dir = tmp_path / \"skills\"\n    skill_path = skill_dir / \"test-skill\"\n    skill_path.mkdir(parents=True)\n    skill_text = \"\\n\".join(\n        [\n            \"---\",\n            \"name: test\",\n            \"description: Test skill\",\n            \"---\",\n            \"\",\n            \"Use this skill in wire tests.\",\n        ]\n    )\n    skill_path.joinpath(\"SKILL.md\").write_text(skill_text + \"\\n\", encoding=\"utf-8\")\n\n    config_path = write_scripted_config(tmp_path, [\"text: skill ok\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n    session_id = \"skill-session\"\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        skills_dir=skill_dir,\n        extra_args=[\"--session\", session_id],\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"/skill:test\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"/skill:test\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"skill ok\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n    context_file = _session_dir(home_dir, work_dir, session_id) / \"context.jsonl\"\n    user_texts = _read_user_texts(context_file)\n    assert user_texts\n    assert _normalize_newlines(user_texts[-1]) == _normalize_newlines(skill_text.strip())\n\n\ndef test_flow_skill(tmp_path) -> None:\n    skill_dir = tmp_path / \"skills\"\n    flow_dir = skill_dir / \"test-flow\"\n    flow_dir.mkdir(parents=True)\n    flow_dir.joinpath(\"SKILL.md\").write_text(\n        \"\\n\".join(\n            [\n                \"---\",\n                \"name: test-flow\",\n                \"description: Test flow\",\n                \"type: flow\",\n                \"---\",\n                \"\",\n                \"```mermaid\",\n                \"flowchart TD\",\n                \"A([BEGIN]) --> B[Say hello]\",\n                \"B --> C([END])\",\n                \"```\",\n            ]\n        ),\n        encoding=\"utf-8\",\n    )\n\n    config_path = write_scripted_config(tmp_path, [\"text: flow done\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        skills_dir=skill_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"/flow:test-flow\"},\n            }\n        )\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"/flow:test-flow\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"Say hello\"},\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"flow done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n\n\ndef test_mcp_tool_call(tmp_path) -> None:\n    server_path = tmp_path / \"mcp_server.py\"\n    server_path.write_text(\n        textwrap.dedent(\n            \"\"\"\n            from fastmcp.server import FastMCP\n\n            server = FastMCP(\"test-mcp\")\n\n            @server.tool\n            def ping(text: str) -> str:\n                return f\"pong:{text}\"\n\n            if __name__ == \"__main__\":\n                server.run(transport=\"stdio\", show_banner=False)\n            \"\"\"\n        ).strip()\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    mcp_config = {\n        \"mcpServers\": {\n            \"test\": {\n                \"command\": sys.executable,\n                \"args\": [str(server_path)],\n            }\n        }\n    }\n    mcp_config_path = tmp_path / \"mcp.json\"\n    mcp_config_path.write_text(json.dumps(mcp_config), encoding=\"utf-8\")\n\n    tool_args = json.dumps({\"text\": \"hi\"})\n    tool_call = json.dumps({\"id\": \"tc-1\", \"name\": \"ping\", \"arguments\": tool_args})\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: call mcp\",\n                f\"tool_call: {tool_call}\",\n            ]\n        ),\n        \"text: done\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        mcp_config_path=mcp_config_path,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"call mcp\"},\n            }\n        )\n        resp, messages = collect_until_response(\n            wire,\n            \"prompt-1\",\n            request_handler=lambda msg: build_approval_response(msg, \"approve\"),\n        )\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n        assert summarize_messages(messages) == snapshot(\n            [\n                {\n                    \"method\": \"event\",\n                    \"type\": \"TurnBegin\",\n                    \"payload\": {\"user_input\": \"call mcp\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": None,\n                        \"mcp_status\": {\n                            \"loading\": True,\n                            \"connected\": 0,\n                            \"total\": 1,\n                            \"tools\": 0,\n                            \"servers\": [{\"name\": \"test\", \"status\": \"connecting\", \"tools\": []}],\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"MCPLoadingBegin\", \"payload\": {}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": None,\n                        \"mcp_status\": {\n                            \"loading\": False,\n                            \"connected\": 1,\n                            \"total\": 1,\n                            \"tools\": 1,\n                            \"servers\": [{\"name\": \"test\", \"status\": \"connected\", \"tools\": [\"ping\"]}],\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"MCPLoadingEnd\", \"payload\": {}},\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 1}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"call mcp\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolCall\",\n                    \"payload\": {\n                        \"type\": \"function\",\n                        \"id\": \"tc-1\",\n                        \"function\": {\"name\": \"ping\", \"arguments\": '{\"text\": \"hi\"}'},\n                        \"extras\": None,\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\n                    \"method\": \"request\",\n                    \"type\": \"ApprovalRequest\",\n                    \"payload\": {\n                        \"id\": \"<uuid>\",\n                        \"tool_call_id\": \"tc-1\",\n                        \"sender\": \"ping\",\n                        \"action\": \"mcp:ping\",\n                        \"description\": \"Call MCP tool `ping`.\",\n                        \"display\": [],\n                    },\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ApprovalResponse\",\n                    \"payload\": {\"request_id\": \"<uuid>\", \"response\": \"approve\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ToolResult\",\n                    \"payload\": {\n                        \"tool_call_id\": \"tc-1\",\n                        \"return_value\": {\n                            \"is_error\": False,\n                            \"output\": [{\"type\": \"text\", \"text\": \"pong:hi\"}],\n                            \"message\": \"\",\n                            \"display\": [],\n                            \"extras\": None,\n                        },\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"StepBegin\", \"payload\": {\"n\": 2}},\n                {\n                    \"method\": \"event\",\n                    \"type\": \"ContentPart\",\n                    \"payload\": {\"type\": \"text\", \"text\": \"done\"},\n                },\n                {\n                    \"method\": \"event\",\n                    \"type\": \"StatusUpdate\",\n                    \"payload\": {\n                        \"context_usage\": None,\n                        \"context_tokens\": None,\n                        \"max_context_tokens\": None,\n                        \"token_usage\": None,\n                        \"message_id\": None,\n                        \"plan_mode\": False,\n                        \"mcp_status\": None,\n                    },\n                },\n                {\"method\": \"event\", \"type\": \"TurnEnd\", \"payload\": {}},\n            ]\n        )\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/test_wire_steer.py",
    "content": "from __future__ import annotations\n\nfrom inline_snapshot import snapshot\n\nfrom tests_e2e.wire_helpers import (\n    build_approval_response,\n    build_shell_tool_call,\n    collect_until_request,\n    collect_until_response,\n    make_home_dir,\n    make_work_dir,\n    normalize_response,\n    read_response,\n    send_initialize,\n    start_wire,\n    write_scripted_config,\n)\n\n\ndef test_steer_no_active_turn(tmp_path) -> None:\n    \"\"\"Steer without an active turn returns INVALID_STATE.\"\"\"\n    config_path = write_scripted_config(tmp_path, [\"text: hello\"])\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"steer-1\",\n                \"method\": \"steer\",\n                \"params\": {\"user_input\": \"do something\"},\n            }\n        )\n        resp = normalize_response(read_response(wire, \"steer-1\"))\n        assert resp == snapshot(\n            {\n                \"error\": {\n                    \"code\": -32000,\n                    \"message\": \"No agent turn is in progress\",\n                    \"data\": None,\n                }\n            }\n        )\n    finally:\n        wire.close()\n\n\ndef test_steer_during_active_turn(tmp_path) -> None:\n    \"\"\"Steer during an active turn returns 'steered' and the model sees\n    the instruction in the next step.\"\"\"\n    # Script: step 1 calls a shell tool (blocks on approval), step 2 echoes back.\n    scripts = [\n        \"\\n\".join(\n            [\n                \"text: working\",\n                build_shell_tool_call(\"tc-1\", \"echo hi\"),\n            ]\n        ),\n        \"text: done after steer\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=False,\n    )\n    try:\n        send_initialize(wire)\n        # Start a prompt that will block on tool approval\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"run\"},\n            }\n        )\n        # Wait until the approval request arrives (turn is active)\n        request_msg, _ = collect_until_request(wire)\n\n        # Send steer while the turn is active\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"steer-1\",\n                \"method\": \"steer\",\n                \"params\": {\"user_input\": \"also do this\"},\n            }\n        )\n        steer_resp = normalize_response(read_response(wire, \"steer-1\"))\n        assert steer_resp == snapshot({\"result\": {\"status\": \"steered\"}})\n\n        # Approve the tool call to let the turn continue\n        wire.send_json(build_approval_response(request_msg, \"approve\"))\n\n        # Collect the rest of the turn\n        resp, _ = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n    finally:\n        wire.close()\n\n\ndef test_steer_forces_extra_step_on_no_tool_calls(tmp_path) -> None:\n    \"\"\"When the model stops with no_tool_calls but a steer is pending,\n    the agent loop should force another LLM step.\"\"\"\n    # Script: step 1 returns text only (no tool calls → would normally end turn),\n    # but if steer is consumed, step 2 runs and returns more text.\n    scripts = [\n        \"text: first response\",\n        \"text: steered response\",\n    ]\n    config_path = write_scripted_config(tmp_path, scripts)\n    work_dir = make_work_dir(tmp_path)\n    home_dir = make_home_dir(tmp_path)\n\n    wire = start_wire(\n        config_path=config_path,\n        config_text=None,\n        work_dir=work_dir,\n        home_dir=home_dir,\n        yolo=True,\n    )\n    try:\n        send_initialize(wire)\n        # Start a prompt\n        wire.send_json(\n            {\n                \"jsonrpc\": \"2.0\",\n                \"id\": \"prompt-1\",\n                \"method\": \"prompt\",\n                \"params\": {\"user_input\": \"start\"},\n            }\n        )\n        # The scripted provider returns \"first response\" immediately with\n        # no tool calls.  By the time we can send a steer, the turn may\n        # already have finished.  This is inherently racy in the e2e setup\n        # because the scripted provider doesn't block.\n        #\n        # Instead of trying to race, just verify the basic lifecycle: the\n        # prompt finishes successfully.\n        resp, messages = collect_until_response(wire, \"prompt-1\")\n        assert resp.get(\"result\", {}).get(\"status\") == \"finished\"\n    finally:\n        wire.close()\n"
  },
  {
    "path": "tests_e2e/wire_helpers.py",
    "content": "from __future__ import annotations\n\nimport contextlib\nimport json\nimport os\nimport queue\nimport shlex\nimport subprocess\nimport threading\nimport time\nimport uuid\nfrom collections.abc import Callable, Mapping\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import IO, Any\n\nTRACE_ENV = \"KIMI_TEST_TRACE\"\nWIRE_COMMAND_ENV = \"KIMI_E2E_WIRE_CMD\"\nDEFAULT_TIMEOUT = 5.0\n_PATH_REPLACEMENTS: dict[str, str] = {}\n\n\ndef repo_root() -> Path:\n    return Path(__file__).resolve().parents[1]\n\n\ndef _print_trace(label: str, text: str) -> None:\n    if os.getenv(TRACE_ENV) == \"1\":\n        print(\"-----\")\n        print(f\"{label}: {text}\")\n\n\ndef make_home_dir(tmp_path: Path) -> Path:\n    home_dir = tmp_path / \"home\"\n    home_dir.mkdir()\n    register_path_replacements(tmp_path=tmp_path, home_dir=home_dir)\n    return home_dir\n\n\ndef make_work_dir(tmp_path: Path) -> Path:\n    work_dir = tmp_path / \"work\"\n    work_dir.mkdir()\n    register_path_replacements(tmp_path=tmp_path, work_dir=work_dir)\n    return work_dir\n\n\ndef make_env(home_dir: Path) -> dict[str, str]:\n    env = os.environ.copy()\n    env[\"HOME\"] = str(home_dir)\n    env[\"USERPROFILE\"] = str(home_dir)\n    env[\"KIMI_SHARE_DIR\"] = str(share_dir(home_dir))\n    return env\n\n\ndef share_dir(home_dir: Path) -> Path:\n    return home_dir / \".kimi\"\n\n\ndef register_path_replacements(\n    *,\n    tmp_path: Path | None = None,\n    home_dir: Path | None = None,\n    work_dir: Path | None = None,\n) -> None:\n    _add_replacement(tmp_path, \"<tmp>\")\n    _add_replacement(home_dir, \"<home_dir>\")\n    _add_replacement(work_dir, \"<work_dir>\")\n\n\ndef _add_replacement(path: Path | None, token: str) -> None:\n    if path is None:\n        return\n    _PATH_REPLACEMENTS[str(path)] = token\n    resolved = path.resolve()\n    _PATH_REPLACEMENTS[str(resolved)] = token\n\n\ndef write_scripts_file(tmp_path: Path, scripts: list[str], name: str = \"scripts.json\") -> Path:\n    scripts_path = tmp_path / name\n    scripts_path.write_text(json.dumps(scripts), encoding=\"utf-8\")\n    return scripts_path\n\n\ndef write_scripted_config(\n    tmp_path: Path,\n    scripts: list[str],\n    *,\n    model_name: str = \"scripted\",\n    provider_name: str = \"scripted_provider\",\n    capabilities: list[str] | None = None,\n    loop_control: dict[str, Any] | None = None,\n) -> Path:\n    scripts_path = write_scripts_file(tmp_path, scripts)\n    model_config: dict[str, Any] = {\n        \"provider\": provider_name,\n        \"model\": \"scripted_echo\",\n        \"max_context_size\": 100000,\n    }\n    if capabilities:\n        model_config[\"capabilities\"] = capabilities\n\n    config_data: dict[str, Any] = {\n        \"default_model\": model_name,\n        \"models\": {model_name: model_config},\n        \"providers\": {\n            provider_name: {\n                \"type\": \"_scripted_echo\",\n                \"base_url\": \"\",\n                \"api_key\": \"\",\n                \"env\": {\"KIMI_SCRIPTED_ECHO_SCRIPTS\": str(scripts_path)},\n            }\n        },\n    }\n    if loop_control:\n        config_data[\"loop_control\"] = loop_control\n\n    config_path = tmp_path / \"config.json\"\n    config_path.write_text(json.dumps(config_data), encoding=\"utf-8\")\n    return config_path\n\n\ndef build_shell_tool_call(tool_call_id: str, command: str) -> str:\n    payload = {\n        \"id\": tool_call_id,\n        \"name\": \"Shell\",\n        \"arguments\": json.dumps({\"command\": command}),\n    }\n    return f\"tool_call: {json.dumps(payload)}\"\n\n\ndef build_set_todo_call(tool_call_id: str, todos: list[dict[str, str]]) -> str:\n    payload = {\n        \"id\": tool_call_id,\n        \"name\": \"SetTodoList\",\n        \"arguments\": json.dumps({\"todos\": todos}),\n    }\n    return f\"tool_call: {json.dumps(payload)}\"\n\n\ndef build_ask_user_tool_call(tool_call_id: str, questions: list[dict[str, Any]]) -> str:\n    \"\"\"Build a scripted tool call line for the AskUserQuestion tool.\"\"\"\n    payload = {\n        \"id\": tool_call_id,\n        \"name\": \"AskUserQuestion\",\n        \"arguments\": json.dumps({\"questions\": questions}),\n    }\n    return f\"tool_call: {json.dumps(payload)}\"\n\n\ndef build_question_response(request_msg: dict[str, Any], answers: dict[str, str]) -> dict[str, Any]:\n    \"\"\"Build a QuestionResponse JSON-RPC response (mirrors build_approval_response).\"\"\"\n    request_id = request_msg.get(\"id\")\n    payload = request_msg.get(\"params\", {}).get(\"payload\", {})\n    question_id = payload.get(\"id\")\n    return {\n        \"jsonrpc\": \"2.0\",\n        \"id\": request_id,\n        \"result\": {\"request_id\": question_id, \"answers\": answers},\n    }\n\n\nclass LineReader:\n    def __init__(self, stream: IO[str]) -> None:\n        # Use a background reader so Windows pipes don't rely on select().\n        self._stream = stream\n        self._queue: queue.Queue[str | None] = queue.Queue()\n        self._thread = threading.Thread(target=self._run, daemon=True)\n        self._thread.start()\n\n    def _run(self) -> None:\n        try:\n            for line in self._stream:\n                self._queue.put(line)\n        except Exception:\n            self._queue.put(None)\n            return\n        self._queue.put(None)\n\n    def read_line(self, timeout: float) -> str | None:\n        return self._queue.get(timeout=timeout)\n\n    def close(self) -> None:\n        with contextlib.suppress(Exception):\n            self._stream.close()\n        self._thread.join(timeout=0.5)\n\n\n@dataclass\nclass WireProcess:\n    process: subprocess.Popen[str]\n    reader: LineReader\n\n    def send_json(self, payload: dict[str, Any]) -> None:\n        assert self.process.stdin is not None\n        line = json.dumps(payload)\n        _print_trace(\"STDIN\", line)\n        self.process.stdin.write(line + \"\\n\")\n        self.process.stdin.flush()\n\n    def send_raw(self, line: str) -> None:\n        assert self.process.stdin is not None\n        _print_trace(\"STDIN\", line)\n        self.process.stdin.write(line + \"\\n\")\n        self.process.stdin.flush()\n\n    def read_json(self, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:\n        deadline = time.monotonic() + timeout\n        while True:\n            remaining = deadline - time.monotonic()\n            if remaining <= 0:\n                raise TimeoutError(\"Timed out waiting for wire output\")\n            try:\n                line = self.reader.read_line(timeout=remaining)\n            except queue.Empty:\n                continue\n            if line is None:\n                raise EOFError(\"Wire process closed output stream\")\n            line = line.strip()\n            if not line:\n                continue\n            _print_trace(\"STDOUT\", line)\n            try:\n                msg = json.loads(line)\n            except json.JSONDecodeError:\n                continue\n            if isinstance(msg, dict):\n                return msg\n\n    def close(self) -> None:\n        if self.process.stdin is not None:\n            with contextlib.suppress(Exception):\n                self.process.stdin.close()\n        self.reader.close()\n        if self.process.stdout is not None:\n            with contextlib.suppress(Exception):\n                self.process.stdout.close()\n        try:\n            self.process.wait(timeout=2)\n        except subprocess.TimeoutExpired:\n            self.process.terminate()\n            try:\n                self.process.wait(timeout=2)\n            except subprocess.TimeoutExpired:\n                self.process.kill()\n                self.process.wait()\n\n\ndef start_wire(\n    *,\n    config_path: Path | None,\n    config_text: str | None,\n    work_dir: Path,\n    home_dir: Path,\n    extra_args: list[str] | None = None,\n    yolo: bool = False,\n    mcp_config_path: Path | None = None,\n    skills_dir: Path | None = None,\n    agent_file: Path | None = None,\n) -> WireProcess:\n    cmd = _wire_base_command()\n    if yolo:\n        cmd.append(\"--yolo\")\n    if config_path is not None:\n        cmd.extend([\"--config-file\", str(config_path)])\n    if config_text is not None:\n        cmd.extend([\"--config\", config_text])\n    if mcp_config_path is not None:\n        cmd.extend([\"--mcp-config-file\", str(mcp_config_path)])\n    if skills_dir is not None:\n        cmd.extend([\"--skills-dir\", str(skills_dir)])\n    if agent_file is not None:\n        cmd.extend([\"--agent-file\", str(agent_file)])\n    if extra_args:\n        cmd.extend(extra_args)\n    cmd.extend([\"--work-dir\", str(work_dir)])\n\n    process = subprocess.Popen(\n        cmd,\n        cwd=repo_root(),\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        text=True,\n        env=make_env(home_dir),\n    )\n    assert process.stdout is not None\n    reader = LineReader(process.stdout)\n    return WireProcess(process=process, reader=reader)\n\n\ndef send_initialize(\n    wire: WireProcess,\n    *,\n    external_tools: list[dict[str, Any]] | None = None,\n    capabilities: dict[str, Any] | None = None,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {\"protocol_version\": \"1.1\"}\n    if external_tools:\n        params[\"external_tools\"] = external_tools\n    if capabilities is not None:\n        params[\"capabilities\"] = capabilities\n    wire.send_json({\"jsonrpc\": \"2.0\", \"id\": \"init\", \"method\": \"initialize\", \"params\": params})\n    return read_response(wire, \"init\")\n\n\ndef read_response(wire: WireProcess, response_id: str) -> dict[str, Any]:\n    while True:\n        msg = wire.read_json()\n        if msg.get(\"id\") == response_id:\n            return msg\n\n\ndef collect_until_response(\n    wire: WireProcess,\n    response_id: str,\n    *,\n    request_handler: Callable[[dict[str, Any]], dict[str, Any]] | None = None,\n) -> tuple[dict[str, Any], list[dict[str, Any]]]:\n    messages: list[dict[str, Any]] = []\n    while True:\n        msg = wire.read_json()\n        if msg.get(\"method\") in {\"event\", \"request\"}:\n            messages.append(msg)\n        if msg.get(\"method\") == \"request\" and request_handler is not None:\n            wire.send_json(request_handler(msg))\n        if msg.get(\"id\") == response_id:\n            return msg, messages\n\n\ndef collect_until_request(\n    wire: WireProcess,\n) -> tuple[dict[str, Any], list[dict[str, Any]]]:\n    messages: list[dict[str, Any]] = []\n    while True:\n        msg = wire.read_json()\n        if msg.get(\"method\") in {\"event\", \"request\"}:\n            messages.append(msg)\n        if msg.get(\"method\") == \"request\":\n            return msg, messages\n\n\ndef build_approval_response(request_msg: dict[str, Any], response: str) -> dict[str, Any]:\n    request_id = request_msg.get(\"id\")\n    payload = request_msg.get(\"params\", {}).get(\"payload\", {})\n    approval_id = payload.get(\"id\")\n    return {\n        \"jsonrpc\": \"2.0\",\n        \"id\": request_id,\n        \"result\": {\"request_id\": approval_id, \"response\": response},\n    }\n\n\ndef build_tool_result_response(\n    request_msg: dict[str, Any],\n    *,\n    output: str,\n    is_error: bool = False,\n) -> dict[str, Any]:\n    payload = request_msg.get(\"params\", {}).get(\"payload\", {})\n    tool_call_id = payload.get(\"id\")\n    return {\n        \"jsonrpc\": \"2.0\",\n        \"id\": request_msg.get(\"id\"),\n        \"result\": {\n            \"tool_call_id\": tool_call_id,\n            \"return_value\": {\n                \"is_error\": is_error,\n                \"output\": output,\n                \"message\": \"ok\" if not is_error else \"error\",\n                \"display\": [],\n            },\n        },\n    }\n\n\ndef normalize_value(value: Any, *, replacements: Mapping[str, str] | None = None) -> Any:\n    active_replacements = _PATH_REPLACEMENTS if replacements is None else replacements\n    if isinstance(value, dict):\n        normalized = {\n            k: normalize_value(v, replacements=active_replacements) for k, v in value.items()\n        }\n        normalized = _normalize_shell_display(normalized)\n        normalized = _normalize_error_data(normalized)\n        normalized = _normalize_tool_result_extras(normalized)\n        return normalized\n    if isinstance(value, list):\n        return [normalize_value(v, replacements=active_replacements) for v in value]\n    if isinstance(value, float):\n        return round(value, 6)\n    if isinstance(value, str):\n        value = _replace_paths(value, active_replacements)\n        value = _normalize_line_endings(value)\n        value = _normalize_path_separators(value, active_replacements)\n        value = _normalize_echo_error_message(value)\n        try:\n            uuid.UUID(value)\n        except (ValueError, AttributeError, TypeError):\n            return value\n        return \"<uuid>\"\n    return value\n\n\ndef _normalize_shell_display(value: dict[str, Any]) -> dict[str, Any]:\n    if value.get(\"type\") != \"shell\":\n        return value\n    language = value.get(\"language\")\n    if isinstance(language, str) and language.lower() in {\"powershell\", \"pwsh\"}:\n        value[\"language\"] = \"bash\"\n    return value\n\n\ndef _normalize_error_data(value: dict[str, Any]) -> dict[str, Any]:\n    error = value.get(\"error\")\n    if isinstance(error, dict) and \"data\" not in error:\n        error[\"data\"] = None\n    if \"code\" in value and \"message\" in value and \"data\" not in value:\n        value[\"data\"] = None\n    return value\n\n\ndef _normalize_tool_result_extras(value: dict[str, Any]) -> dict[str, Any]:\n    return_value = value.get(\"return_value\")\n    if isinstance(return_value, dict) and \"extras\" not in return_value:\n        return_value[\"extras\"] = None\n    return value\n\n\ndef _normalize_line_endings(value: str) -> str:\n    return value.replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n\n\ndef _normalize_path_separators(value: str, replacements: Mapping[str, str]) -> str:\n    if not replacements:\n        return value\n    tokens = set(replacements.values())\n    if not tokens:\n        return value\n    if any(token in value for token in tokens):\n        return value.replace(\"\\\\\", \"/\")\n    return value\n\n\ndef _replace_paths(value: str, replacements: Mapping[str, str]) -> str:\n    if not replacements:\n        return value\n    for old, new in sorted(replacements.items(), key=lambda item: len(item[0]), reverse=True):\n        if old and old in value:\n            value = value.replace(old, new)\n    return value\n\n\ndef _normalize_echo_error_message(value: str) -> str:\n    if not value.startswith(\"Invalid echo DSL at line\") and not value.startswith(\n        \"Unknown echo DSL kind\"\n    ):\n        return value\n    if \": \" not in value:\n        return value\n    prefix, raw = value.rsplit(\": \", 1)\n    raw = raw.strip()\n    if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {\"'\", '\"'}:\n        raw = raw[1:-1]\n    return f\"{prefix}: '{raw}'\"\n\n\ndef summarize_messages(\n    messages: list[dict[str, Any]], *, replacements: Mapping[str, str] | None = None\n) -> list[dict[str, Any]]:\n    summary: list[dict[str, Any]] = []\n    for msg in messages:\n        method = msg.get(\"method\")\n        if method not in {\"event\", \"request\"}:\n            continue\n        params = msg.get(\"params\", {})\n        entry = {\n            \"method\": method,\n            \"type\": params.get(\"type\"),\n            \"payload\": normalize_value(params.get(\"payload\"), replacements=replacements),\n        }\n        summary.append(entry)\n    return _normalize_message_order(summary)\n\n\ndef _normalize_server_version(value: Any) -> Any:\n    \"\"\"Normalize the server version in initialize response to '<VERSION>'.\"\"\"\n    if isinstance(value, dict):\n        value = {k: _normalize_server_version(v) for k, v in value.items()}\n        if value.get(\"name\") == \"Kimi Code CLI\" and \"version\" in value:\n            value = {**value, \"version\": \"<VERSION>\"}\n    elif isinstance(value, list):\n        value = [_normalize_server_version(v) for v in value]\n    return value\n\n\ndef normalize_response(\n    msg: dict[str, Any], *, replacements: Mapping[str, str] | None = None\n) -> dict[str, Any]:\n    if \"result\" in msg:\n        result = normalize_value(msg[\"result\"], replacements=replacements)\n        result = _normalize_server_version(result)\n        return {\"result\": result}\n    if \"error\" in msg:\n        normalized = {\"error\": normalize_value(msg[\"error\"], replacements=replacements)}\n        return _normalize_server_version(normalized)\n    return _normalize_server_version(normalize_value(msg, replacements=replacements))\n\n\ndef base_command() -> list[str]:\n    override = os.getenv(WIRE_COMMAND_ENV)\n    if override is not None:\n        override = override.strip()\n    parts = shlex.split(override, posix=os.name != \"nt\") if override else [\"uv\", \"run\", \"kimi\"]\n    return [part for part in parts if part != \"--wire\"]\n\n\ndef _wire_base_command() -> list[str]:\n    cmd = base_command()\n    if \"--wire\" not in cmd:\n        cmd.append(\"--wire\")\n    return cmd\n\n\ndef _normalize_message_order(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    normalized = list(messages)\n    step_boundaries = {\"StepBegin\", \"TurnBegin\", \"CompactionBegin\"}\n    idx = 0\n    while idx < len(normalized):\n        if normalized[idx].get(\"type\") != \"StepBegin\":\n            idx += 1\n            continue\n        start = idx\n        end = start + 1\n        while end < len(normalized):\n            msg_type = normalized[end].get(\"type\")\n            if msg_type in step_boundaries:\n                break\n            end += 1\n        block = normalized[start:end]\n        normalized[start:end] = _normalize_step_block(block)\n        idx = end\n    return normalized\n\n\ndef _normalize_step_block(block: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    if not block or block[0].get(\"type\") != \"StepBegin\":\n        return block\n    head = block[:1]\n    tail = block[1:]\n    if not tail:\n        return block\n    stream_events: list[dict[str, Any]] = []\n    status_updates: list[dict[str, Any]] = []\n    requests: list[dict[str, Any]] = []\n    approvals: list[dict[str, Any]] = []\n    tool_results: list[dict[str, Any]] = []\n    other: list[dict[str, Any]] = []\n    tool_call_order: list[str] = []\n    for msg in tail:\n        msg_type = msg.get(\"type\")\n        method = msg.get(\"method\")\n        if msg_type == \"ToolCall\":\n            payload = msg.get(\"payload\")\n            tool_call_id = payload.get(\"id\") if isinstance(payload, dict) else None\n            if isinstance(tool_call_id, str) and tool_call_id not in tool_call_order:\n                tool_call_order.append(tool_call_id)\n        if msg_type in {\"ContentPart\", \"ToolCall\", \"ToolCallPart\"}:\n            stream_events.append(msg)\n        elif msg_type == \"StatusUpdate\":\n            status_updates.append(msg)\n        elif method == \"request\":\n            requests.append(msg)\n        elif msg_type == \"ApprovalResponse\":\n            approvals.append(msg)\n        elif msg_type == \"ToolResult\":\n            tool_results.append(msg)\n        else:\n            other.append(msg)\n    tool_results = _order_tool_results(tool_results, tool_call_order)\n    return head + stream_events + status_updates + requests + approvals + tool_results + other\n\n\ndef _order_tool_results(\n    tool_results: list[dict[str, Any]], tool_call_order: list[str]\n) -> list[dict[str, Any]]:\n    if not tool_call_order:\n        return tool_results\n    by_id: dict[str, list[dict[str, Any]]] = {}\n    unknown: list[dict[str, Any]] = []\n    for msg in tool_results:\n        payload = msg.get(\"payload\")\n        tool_call_id = payload.get(\"tool_call_id\") if isinstance(payload, dict) else None\n        if isinstance(tool_call_id, str) and tool_call_id in tool_call_order:\n            by_id.setdefault(tool_call_id, []).append(msg)\n        else:\n            unknown.append(msg)\n    ordered: list[dict[str, Any]] = []\n    for tool_call_id in tool_call_order:\n        ordered.extend(by_id.get(tool_call_id, []))\n    ordered.extend(unknown)\n    return ordered\n"
  },
  {
    "path": "vis/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  }\n}\n"
  },
  {
    "path": "vis/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"description\" content=\"Kimi Agent Tracing - Debug and visualize agent sessions\" />\n    <meta name=\"theme-color\" content=\"#09090b\" media=\"(prefers-color-scheme: dark)\" />\n    <meta name=\"theme-color\" content=\"#ffffff\" media=\"(prefers-color-scheme: light)\" />\n    <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>\" />\n    <title>Kimi Agent Tracing</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "vis/package.json",
    "content": "{\n  \"name\": \"kimi-vis\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc -b && vite build\",\n    \"preview\": \"vite preview\",\n    \"typecheck\": \"tsc -b --noEmit\"\n  },\n  \"dependencies\": {\n    \"@fontsource-variable/inter\": \"^5.2.8\",\n    \"@radix-ui/react-collapsible\": \"^1.1.12\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@tailwindcss/vite\": \"^4.1.17\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"lucide-react\": \"^0.561.0\",\n    \"radix-ui\": \"^1.4.3\",\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-virtuoso\": \"4.17.0\",\n    \"shadcn\": \"^3.7.0\",\n    \"streamdown\": \"^2.3.0\",\n    \"tailwind-merge\": \"^3.4.0\",\n    \"tailwindcss\": \"^4.1.17\",\n    \"tw-animate-css\": \"^1.4.0\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^24.10.1\",\n    \"@types/react\": \"^19.2.5\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@vitejs/plugin-react\": \"^5.1.1\",\n    \"typescript\": \"~5.9.3\",\n    \"vite\": \"^7.2.4\"\n  }\n}\n"
  },
  {
    "path": "vis/src/App.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { SessionsExplorer } from \"@/features/sessions-explorer/sessions-explorer\";\nimport { StatisticsView } from \"@/features/statistics/statistics-view\";\nimport { WireViewer } from \"@/features/wire-viewer/wire-viewer\";\nimport { ContextViewer } from \"@/features/context-viewer/context-viewer\";\nimport { StateViewer } from \"@/features/state-viewer/state-viewer\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport {\n  type SessionInfo,\n  type WireEvent,\n  getSessionDownloadUrl,\n  getVisCapabilities,\n  getWireEvents,\n  listSessions,\n  openInPath,\n} from \"@/lib/api\";\nimport { isErrorEvent } from \"@/features/wire-viewer/wire-event-card\";\nimport {\n  ArrowLeft,\n  BarChart3,\n  Check,\n  Columns,\n  Copy,\n  Download,\n  FolderOpen,\n  List,\n  Moon,\n  RefreshCw,\n  Sun,\n  X,\n} from \"lucide-react\";\nimport { DualView } from \"@/features/dual-view/dual-view\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\n\ntype Tab = \"wire\" | \"context\" | \"state\" | \"dual\";\n\ninterface SessionStatsData {\n  turns: number;\n  steps: number;\n  toolCalls: number;\n  errors: number;\n  compactions: number;\n  durationSec: number;\n  inputTokens: number;\n  outputTokens: number;\n}\n\nfunction computeStats(events: WireEvent[]): SessionStatsData {\n  let turns = 0;\n  let steps = 0;\n  let toolCalls = 0;\n  let errors = 0;\n  let compactions = 0;\n  let inputTokens = 0;\n  let outputTokens = 0;\n\n  for (const e of events) {\n    if (e.type === \"TurnBegin\") turns++;\n    if (e.type === \"StepBegin\") steps++;\n    if (e.type === \"ToolCall\") toolCalls++;\n    if (e.type === \"CompactionBegin\") compactions++;\n    if (isErrorEvent(e)) errors++;\n    if (e.type === \"StatusUpdate\") {\n      const tu = e.payload.token_usage as Record<string, number> | undefined;\n      if (tu) {\n        inputTokens += (tu.input_other ?? 0) + (tu.input_cache_read ?? 0) + (tu.input_cache_creation ?? 0);\n        outputTokens += tu.output ?? 0;\n      }\n    }\n  }\n\n  const durationSec =\n    events.length >= 2\n      ? events[events.length - 1].timestamp - events[0].timestamp\n      : 0;\n\n  return { turns, steps, toolCalls, errors, compactions, durationSec, inputTokens, outputTokens };\n}\n\nfunction formatDuration(sec: number): string {\n  if (sec < 1) return `${(sec * 1000).toFixed(0)}ms`;\n  if (sec < 60) return `${sec.toFixed(1)}s`;\n  return `${(sec / 60).toFixed(1)}min`;\n}\n\nfunction formatTokens(n: number): string {\n  if (n === 0) return \"0\";\n  if (n < 1000) return `${n}`;\n  return `${(n / 1000).toFixed(1)}k`;\n}\n\nfunction getSessionDir(session: SessionInfo): string {\n  return session.session_dir;\n}\n\nfunction SessionDirectoryActions({\n  session,\n  openInSupported,\n}: {\n  session: SessionInfo;\n  openInSupported: boolean;\n}) {\n  const [copied, setCopied] = useState(false);\n\n  const handleOpenSessionDir = useCallback(async () => {\n    try {\n      await openInPath(\"finder\", session.session_dir);\n    } catch (error) {\n      console.error(\"Failed to open session directory:\", error);\n      window.alert(\n        error instanceof Error\n          ? `Failed to open session directory:\\n${error.message}`\n          : \"Failed to open session directory\",\n      );\n    }\n  }, [session.session_dir]);\n\n  const handleCopyDirInfo = useCallback(async () => {\n    try {\n      await navigator.clipboard.writeText(getSessionDir(session));\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } catch (error) {\n      console.error(\"Failed to copy DIR info:\", error);\n      window.alert(\"Failed to copy DIR info\");\n    }\n  }, [session]);\n\n  return (\n    <div className=\"flex shrink-0 items-center gap-1 px-1.5\">\n      {openInSupported && (\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <button\n              onClick={handleOpenSessionDir}\n              className=\"rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n              aria-label=\"Open current session directory\"\n            >\n              <span className=\"flex items-center gap-1\">\n                <FolderOpen size={13} />\n                Open Dir\n              </span>\n            </button>\n          </TooltipTrigger>\n          <TooltipContent side=\"bottom\" className=\"max-w-md break-all\">\n            Open current session directory\n            <div className=\"mt-1 font-mono text-[11px]\">{session.session_dir}</div>\n          </TooltipContent>\n        </Tooltip>\n      )}\n\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <button\n            onClick={handleCopyDirInfo}\n            className=\"rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            aria-label=\"Copy current session directory info\"\n          >\n            <span className=\"flex items-center gap-1\">\n              {copied ? <Check size={13} /> : <Copy size={13} />}\n              Copy DIR\n            </span>\n          </button>\n        </TooltipTrigger>\n        <TooltipContent side=\"bottom\">\n          {copied ? \"Copied session directory\" : \"Copy session directory path\"}\n        </TooltipContent>\n      </Tooltip>\n    </div>\n  );\n}\n\nfunction SessionStats({ sessionId, refreshKey }: { sessionId: string; refreshKey: number }) {\n  const [copied, setCopied] = useState(false);\n  const [events, setEvents] = useState<WireEvent[]>([]);\n  const [loaded, setLoaded] = useState(false);\n\n  useEffect(() => {\n    setLoaded(false);\n    getWireEvents(sessionId, refreshKey > 0)\n      .then((res) => setEvents(res.events))\n      .catch(() => setEvents([]))\n      .finally(() => setLoaded(true));\n  }, [sessionId, refreshKey]);\n\n  const stats = useMemo(() => computeStats(events), [events]);\n\n  if (!loaded || events.length === 0) return null;\n\n  const parts: string[] = [\n    `${stats.turns} turn${stats.turns !== 1 ? \"s\" : \"\"}`,\n    `${stats.steps} step${stats.steps !== 1 ? \"s\" : \"\"}`,\n    `${stats.toolCalls} tool call${stats.toolCalls !== 1 ? \"s\" : \"\"}`,\n  ];\n  if (stats.errors > 0) parts.push(`${stats.errors} error${stats.errors !== 1 ? \"s\" : \"\"}`);\n  if (stats.compactions > 0) parts.push(`${stats.compactions} compaction${stats.compactions !== 1 ? \"s\" : \"\"}`);\n\n  return (\n    <div className=\"min-w-0 flex flex-1 items-center gap-2 overflow-x-auto px-4 py-1.5 text-xs text-muted-foreground\">\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span\n            className=\"font-mono shrink-0 cursor-pointer hover:text-foreground transition-colors\"\n            onClick={() => {\n              const fullId = sessionId.split(\"/\").pop() ?? sessionId;\n              navigator.clipboard.writeText(fullId).catch(() => {});\n              setCopied(true);\n              setTimeout(() => setCopied(false), 2000);\n            }}\n          >\n            {sessionId.split(\"/\").pop() ?? sessionId}\n          </span>\n        </TooltipTrigger>\n        <TooltipContent>\n          {copied ? \"Copied!\" : \"Click to copy\"}\n        </TooltipContent>\n      </Tooltip>\n      <span className=\"text-border\">|</span>\n      <span className=\"shrink-0\">{parts.join(\" · \")}</span>\n      <span className=\"text-border\">|</span>\n      <span className=\"shrink-0\">{formatDuration(stats.durationSec)}</span>\n      {(stats.inputTokens > 0 || stats.outputTokens > 0) && (\n        <>\n          <span className=\"text-border\">|</span>\n          <span className=\"shrink-0\">\n            {formatTokens(stats.inputTokens)} in / {formatTokens(stats.outputTokens)} out\n          </span>\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction ShortcutRow({ keys, desc }: { keys: string; desc: string }) {\n  return (\n    <div className=\"flex items-center gap-3\">\n      <kbd className=\"inline-flex min-w-[2rem] items-center justify-center rounded border bg-muted px-1.5 py-0.5 font-mono text-xs\">\n        {keys}\n      </kbd>\n      <span className=\"text-muted-foreground\">{desc}</span>\n    </div>\n  );\n}\n\nexport function App() {\n  const { theme, toggleTheme } = useTheme();\n  const [sessionId, setSessionId] = useState<string | null>(() => {\n    const params = new URLSearchParams(window.location.search);\n    return params.get(\"session\");\n  });\n  const [activeTab, setActiveTab] = useState<Tab>(\"wire\");\n  const [explorerView, setExplorerView] = useState<\"sessions\" | \"statistics\">(\"sessions\");\n  const [showShortcutHelp, setShowShortcutHelp] = useState(false);\n  const [refreshKey, setRefreshKey] = useState(0);\n  const [refreshing, setRefreshing] = useState(false);\n  const [openInSupported, setOpenInSupported] = useState(false);\n  // Cross-reference navigation targets\n  const [contextScrollTarget, setContextScrollTarget] = useState<string | null>(null);\n  const [wireScrollTarget, setWireScrollTarget] = useState<string | null>(null);\n\n  const handleNavigateToContext = useCallback((toolCallId: string) => {\n    setContextScrollTarget(toolCallId);\n    setActiveTab(\"context\");\n  }, []);\n\n  const handleNavigateToWire = useCallback((toolCallId: string) => {\n    setWireScrollTarget(toolCallId);\n    setActiveTab(\"wire\");\n  }, []);\n\n  const handleSessionChange = useCallback((id: string | null) => {\n    setSessionId(id);\n    const url = new URL(window.location.href);\n    if (id) {\n      url.searchParams.set(\"session\", id);\n    } else {\n      url.searchParams.delete(\"session\");\n    }\n    window.history.pushState({}, \"\", url.toString());\n  }, []);\n\n  useEffect(() => {\n    const handler = () => {\n      const params = new URLSearchParams(window.location.search);\n      setSessionId(params.get(\"session\"));\n    };\n    window.addEventListener(\"popstate\", handler);\n    return () => window.removeEventListener(\"popstate\", handler);\n  }, []);\n\n  // Dynamic page title\n  const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listSessions>>>([]);\n  useEffect(() => {\n    listSessions().then(setSessions).catch(() => {});\n  }, []);\n  useEffect(() => {\n    getVisCapabilities()\n      .then((capabilities) => setOpenInSupported(capabilities.open_in_supported))\n      .catch((error) => {\n        console.error(\"Failed to load vis capabilities:\", error);\n        setOpenInSupported(false);\n      });\n  }, []);\n  const currentSession = useMemo(() => {\n    if (!sessionId) return null;\n    return sessions.find((s) => `${s.work_dir_hash}/${s.session_id}` === sessionId) ?? null;\n  }, [sessionId, sessions]);\n  useEffect(() => {\n    if (!sessionId) {\n      document.title = \"Kimi Agent Tracing\";\n      return;\n    }\n    const rawId = sessionId.split(\"/\").pop() ?? sessionId;\n    const label =\n      currentSession?.metadata?.title || currentSession?.title || rawId.slice(0, 8);\n    document.title = `${label} — Kimi Agent Tracing`;\n  }, [currentSession, sessionId]);\n\n  // Global keyboard shortcuts: 1/2/3 to switch tabs\n  useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      // Don't intercept when focused on input elements\n      const tag = (e.target as HTMLElement)?.tagName;\n      if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n\n      if (e.key === \"Escape\" && showShortcutHelp) {\n        setShowShortcutHelp(false);\n        return;\n      }\n\n      if (e.key === \"?\") {\n        setShowShortcutHelp((prev) => !prev);\n        return;\n      }\n\n      if (e.key === \"1\") setActiveTab(\"wire\");\n      else if (e.key === \"2\") setActiveTab(\"context\");\n      else if (e.key === \"3\") setActiveTab(\"state\");\n      else if (e.key === \"4\") setActiveTab(\"dual\");\n    };\n    window.addEventListener(\"keydown\", handler);\n    return () => window.removeEventListener(\"keydown\", handler);\n  }, [showShortcutHelp]);\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      {/* Header */}\n      <header className=\"flex items-center justify-between border-b px-4 py-3\">\n        <h1\n          className={`text-lg font-semibold tracking-tight flex items-center gap-2 ${\n            sessionId ? \"cursor-pointer hover:text-primary transition-colors\" : \"\"\n          }`}\n          onClick={() => sessionId && handleSessionChange(null)}\n          title={sessionId ? \"Back to Sessions Explorer\" : undefined}\n        >\n          {sessionId && <ArrowLeft size={16} className=\"text-muted-foreground\" />}\n          Kimi Agent Tracing\n        </h1>\n        <button\n          onClick={toggleTheme}\n          className=\"rounded-md p-2 hover:bg-accent\"\n          title={`Switch to ${theme === \"dark\" ? \"light\" : \"dark\"} mode`}\n        >\n          {theme === \"dark\" ? <Sun size={16} /> : <Moon size={16} />}\n        </button>\n      </header>\n\n      {/* Session Stats */}\n      {sessionId && (\n        <div className=\"flex items-center border-b\">\n          <SessionStats sessionId={sessionId} refreshKey={refreshKey} />\n          {currentSession && (\n            <SessionDirectoryActions\n              session={currentSession}\n              openInSupported={openInSupported}\n            />\n          )}\n          <a\n            href={getSessionDownloadUrl(sessionId)}\n            download\n            className=\"shrink-0 rounded-md p-1.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors\"\n            title=\"Download session files as ZIP\"\n          >\n            <Download size={14} />\n          </a>\n          <button\n            onClick={() => {\n              setRefreshing(true);\n              setRefreshKey((k) => k + 1);\n              listSessions(true).then(setSessions).catch(() => {});\n              setTimeout(() => setRefreshing(false), 600);\n            }}\n            className=\"mr-3 shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            title=\"Refresh session data\"\n          >\n            <RefreshCw size={14} className={refreshing ? \"animate-spin\" : \"\"} />\n          </button>\n        </div>\n      )}\n\n      {/* Tabs */}\n      {sessionId && (\n        <>\n          <div className=\"flex border-b px-4\">\n            {(\n              [\n                { key: \"wire\", label: \"Wire Events\", icon: null },\n                { key: \"context\", label: \"Context Messages\", icon: null },\n                { key: \"state\", label: \"State\", icon: null },\n                { key: \"dual\", label: \"Dual\", icon: <Columns size={14} /> },\n              ] as const\n            ).map(({ key, label, icon }) => (\n              <button\n                key={key}\n                onClick={() => setActiveTab(key)}\n                className={`relative flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors ${\n                  activeTab === key\n                    ? \"text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                }`}\n              >\n                {icon}\n                {label}\n                {activeTab === key && (\n                  <span className=\"absolute bottom-0 left-0 right-0 h-0.5 bg-primary\" />\n                )}\n              </button>\n            ))}\n          </div>\n\n          {/* Tab Content */}\n          <div className=\"flex-1 overflow-hidden\">\n            {activeTab === \"wire\" && (\n              <WireViewer\n                sessionId={sessionId}\n                refreshKey={refreshKey}\n                onNavigateToContext={handleNavigateToContext}\n                scrollToToolCallId={wireScrollTarget}\n                onScrollTargetConsumed={() => setWireScrollTarget(null)}\n              />\n            )}\n            {activeTab === \"context\" && (\n              <ContextViewer\n                sessionId={sessionId}\n                refreshKey={refreshKey}\n                onNavigateToWire={handleNavigateToWire}\n                scrollToToolCallId={contextScrollTarget}\n                onScrollTargetConsumed={() => setContextScrollTarget(null)}\n              />\n            )}\n            {activeTab === \"state\" && <StateViewer sessionId={sessionId} refreshKey={refreshKey} />}\n            {activeTab === \"dual\" && <DualView sessionId={sessionId} refreshKey={refreshKey} />}\n          </div>\n        </>\n      )}\n\n      {!sessionId && (\n        <div className=\"flex h-full flex-col overflow-hidden\">\n          {/* Explorer view tabs */}\n          <div className=\"flex items-center gap-1 border-b px-4 py-1.5\">\n            {(\n              [\n                { key: \"sessions\", label: \"Sessions\", icon: <List size={14} /> },\n                { key: \"statistics\", label: \"Statistics\", icon: <BarChart3 size={14} /> },\n              ] as const\n            ).map(({ key, label, icon }) => (\n              <button\n                key={key}\n                onClick={() => setExplorerView(key)}\n                className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${\n                  explorerView === key\n                    ? \"bg-accent text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground hover:bg-accent/50\"\n                }`}\n              >\n                {icon}\n                {label}\n              </button>\n            ))}\n          </div>\n\n          {/* Explorer content */}\n          {explorerView === \"sessions\" ? (\n            <SessionsExplorer onSelectSession={handleSessionChange} />\n          ) : (\n            <StatisticsView />\n          )}\n        </div>\n      )}\n\n      {/* Shortcut Help Overlay */}\n      {showShortcutHelp && (\n        <div\n          className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\"\n          onClick={() => setShowShortcutHelp(false)}\n        >\n          <div\n            className=\"relative w-full max-w-lg rounded-lg border bg-popover p-6 shadow-lg\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            <div className=\"flex items-center justify-between mb-4\">\n              <h2 className=\"text-lg font-semibold\">Keyboard Shortcuts</h2>\n              <button\n                onClick={() => setShowShortcutHelp(false)}\n                className=\"rounded-md p-1 hover:bg-accent\"\n              >\n                <X size={16} />\n              </button>\n            </div>\n\n            <div className=\"space-y-4 text-sm\">\n              <div>\n                <h3 className=\"text-xs font-medium uppercase text-muted-foreground mb-2\">Global</h3>\n                <div className=\"space-y-1.5\">\n                  <ShortcutRow keys=\"1\" desc=\"Wire Events\" />\n                  <ShortcutRow keys=\"2\" desc=\"Context Messages\" />\n                  <ShortcutRow keys=\"3\" desc=\"State\" />\n                  <ShortcutRow keys=\"4\" desc=\"Dual View\" />\n                  <ShortcutRow keys=\"?\" desc=\"Show shortcuts\" />\n                </div>\n              </div>\n\n              <div>\n                <h3 className=\"text-xs font-medium uppercase text-muted-foreground mb-2\">Wire Events</h3>\n                <div className=\"space-y-1.5\">\n                  <ShortcutRow keys=\"j / k\" desc=\"Navigate events\" />\n                  <ShortcutRow keys=\"Enter\" desc=\"Expand / collapse\" />\n                  <ShortcutRow keys=\"e\" desc=\"Next error\" />\n                  <ShortcutRow keys=\"/\" desc=\"Search\" />\n                  <ShortcutRow keys=\"Esc\" desc=\"Close panel\" />\n                </div>\n              </div>\n\n              <div>\n                <h3 className=\"text-xs font-medium uppercase text-muted-foreground mb-2\">Context Messages</h3>\n                <div className=\"space-y-1.5\">\n                  <ShortcutRow keys=\"/\" desc=\"Search\" />\n                  <ShortcutRow keys=\"Enter\" desc=\"Next match\" />\n                  <ShortcutRow keys=\"Shift+Enter\" desc=\"Previous match\" />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/components/markdown.tsx",
    "content": "import { memo, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { isValidElement } from \"react\";\nimport { Streamdown, type StreamdownProps } from \"streamdown\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, CopyIcon } from \"lucide-react\";\nimport type { Element } from \"hast\";\n\n/**\n * Escape HTML-like tags outside of code blocks.\n */\nconst escapeHtmlOutsideCodeBlocks = (text: string): string => {\n  const codeBlockRegex = /(^|\\n)```[a-z]*\\n[\\s\\S]*?\\n```|`[^`\\n]+`/g;\n  const codeBlocks: { start: number; end: number }[] = [];\n\n  let match;\n  while ((match = codeBlockRegex.exec(text)) !== null) {\n    const startsWithNewline = match[0].startsWith(\"\\n\");\n    const start = startsWithNewline ? match.index + 1 : match.index;\n    codeBlocks.push({ start, end: match.index + match[0].length });\n  }\n\n  const escapeForMarkdown = (str: string): string => {\n    let result = str.replace(/<(?=[a-zA-Z/!?])/g, \"\\uFF1C\");\n    result = result.replace(/(?<!^)(?<![-=])>/gm, \"\\uFF1E\");\n    result = result.replace(/\\n([ ]{4,})/g, \"\\n\\u200B$1\");\n    return result;\n  };\n\n  const result: string[] = [];\n  let lastEnd = 0;\n\n  for (const block of codeBlocks) {\n    const before = text.slice(lastEnd, block.start);\n    result.push(escapeForMarkdown(before));\n    result.push(text.slice(block.start, block.end));\n    lastEnd = block.end;\n  }\n\n  const after = text.slice(lastEnd);\n  result.push(escapeForMarkdown(after));\n  return result.join(\"\");\n};\n\n// Prevent margin collapse for Virtuoso height measurement\nconst streamdownRootClass = [\n  \"flow-root\",\n  \"[&_p]:m-0\",\n  \"[&_h1]:m-0\",\n  \"[&_h2]:m-0\",\n  \"[&_h3]:m-0\",\n  \"[&_h4]:m-0\",\n  \"[&_h5]:m-0\",\n  \"[&_h6]:m-0\",\n  \"[&_ul]:m-0\",\n  \"[&_ol]:m-0\",\n  \"[&_li]:m-0\",\n  \"[&_blockquote]:m-0\",\n  \"[&_hr]:m-0\",\n  \"[&_pre]:m-0\",\n].join(\" \");\n\nconst LANGUAGE_CLASS_RE = /language-([^\\s]+)/;\n\nconst getCodeLanguage = (className?: string): string | undefined => {\n  return className?.match(LANGUAGE_CLASS_RE)?.[1];\n};\n\nconst getCodeText = (children: ReactNode): string => {\n  if (typeof children === \"string\") return children;\n  if (Array.isArray(children)) return children.map(getCodeText).join(\"\");\n  if (isValidElement<{ children?: ReactNode }>(children))\n    return getCodeText(children.props.children);\n  return \"\";\n};\n\n/** Lightweight code block with copy button (no shiki highlighting) */\nfunction SimpleCodeBlock({\n  code,\n  language,\n  className,\n}: {\n  code: string;\n  language?: string;\n  className?: string;\n}) {\n  const [copied, setCopied] = useState(false);\n\n  const handleCopy = async () => {\n    await navigator.clipboard.writeText(code);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  return (\n    <div className={cn(\"group/code relative my-2 rounded border bg-card\", className)}>\n      <div className=\"absolute top-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover/code:opacity-100 transition-opacity\">\n        {language && (\n          <span className=\"text-[10px] text-muted-foreground font-mono px-1\">{language}</span>\n        )}\n        <button\n          onClick={handleCopy}\n          className=\"rounded p-1 hover:bg-muted text-muted-foreground hover:text-foreground\"\n        >\n          {copied ? <CheckIcon className=\"size-3.5\" /> : <CopyIcon className=\"size-3.5\" />}\n        </button>\n      </div>\n      <pre className=\"overflow-auto p-3 text-xs max-h-[60vh]\">\n        <code className=\"font-mono\">{code}</code>\n      </pre>\n    </div>\n  );\n}\n\ntype StreamdownCodeProps = ComponentProps<\"code\"> & { node?: Element };\n\nconst MarkdownCode = ({ className, children, node, ...props }: StreamdownCodeProps) => {\n  const isInline = node?.position?.start?.line === node?.position?.end?.line;\n\n  if (isInline) {\n    return (\n      <code\n        className={cn(\"rounded bg-secondary px-1 py-0.5 font-mono text-xs\", className)}\n        {...props}\n      >\n        {children}\n      </code>\n    );\n  }\n\n  return (\n    <SimpleCodeBlock\n      code={getCodeText(children)}\n      language={getCodeLanguage(className)}\n    />\n  );\n};\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst MarkdownPre = ({ children }: any) => children;\n\nconst markdownComponents: StreamdownProps[\"components\"] = {\n  code: MarkdownCode,\n  pre: MarkdownPre,\n};\n\n/** Markdown renderer using streamdown, matching kimi web's approach */\nexport const Markdown = memo(\n  ({ className, children, ...props }: Omit<StreamdownProps, \"components\" | \"rehypePlugins\">) => (\n    <Streamdown\n      className={cn(\n        \"streamdown-prose w-full text-sm leading-relaxed [&>*:first-child]:mt-0 [&>*:last-child]:mb-0\",\n        streamdownRootClass,\n        className,\n      )}\n      components={markdownComponents}\n      rehypePlugins={[]}\n      {...props}\n    >\n      {typeof children === \"string\"\n        ? escapeHtmlOutsideCodeBlocks(children)\n        : children}\n    </Streamdown>\n  ),\n  (prev, next) => prev.children === next.children,\n);\n\nMarkdown.displayName = \"Markdown\";\n"
  },
  {
    "path": "vis/src/components/ui/alert-dialog.tsx",
    "content": "import * as React from \"react\"\nimport { AlertDialog as AlertDialogPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        className={cn(\n          \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg\",\n          className,\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  )\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\"text-lg font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\"text-sm text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogAction({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {\n  return (\n    <AlertDialogPrimitive.Action\n      data-slot=\"alert-dialog-action\"\n      className={cn(\n        \"inline-flex h-9 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground ring-offset-background transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogCancel({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {\n  return (\n    <AlertDialogPrimitive.Cancel\n      data-slot=\"alert-dialog-cancel\"\n      className={cn(\n        \"inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n}\n"
  },
  {
    "path": "vis/src/components/ui/select.tsx",
    "content": "import * as React from \"react\"\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\"\nimport { Select as SelectPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\"\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  )\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"item-aligned\",\n  align = \"center\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          \"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className\n        )}\n        position={position}\n        align={align}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\"\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  )\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"px-2 py-1.5 text-xs text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className\n      )}\n      {...props}\n    >\n      <span\n        data-slot=\"select-item-indicator\"\n        className=\"absolute right-2 flex size-3.5 items-center justify-center\"\n      >\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  )\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"pointer-events-none -mx-1 my-1 h-px bg-border\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  )\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  )\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n}\n"
  },
  {
    "path": "vis/src/components/ui/tooltip.tsx",
    "content": "import * as React from \"react\"\nimport { Tooltip as TooltipPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "vis/src/features/context-viewer/assistant-message.tsx",
    "content": "import { useState } from \"react\";\nimport type { ContextMessage, ContentPart, ToolCallItem } from \"@/lib/api\";\nimport { normalizeContent } from \"@/lib/api\";\nimport { Markdown } from \"@/components/markdown\";\nimport { useRawMode, useNavigateToWire } from \"./context-viewer\";\nimport {\n  Bot,\n  Brain,\n  ChevronDown,\n  ChevronRight,\n  Wrench,\n  Image,\n  Music,\n  Video,\n  ArrowRight,\n} from \"lucide-react\";\n\ninterface AssistantMessageProps {\n  message: ContextMessage;\n}\n\nfunction ThinkingBlock({ part }: { part: ContentPart }) {\n  const [expanded, setExpanded] = useState(false);\n  const text = part.think ?? part.thinking ?? \"\";\n\n  // Nothing to show if there's no actual thinking text\n  if (!text) return null;\n\n  return (\n    <div className=\"my-1 rounded-md border border-dashed bg-muted/30 px-3 py-2\">\n      <button\n        onClick={() => setExpanded(!expanded)}\n        className=\"flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground\"\n      >\n        <Brain size={12} />\n        <span className=\"font-medium\">Thinking</span>\n        {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}\n      </button>\n      {expanded ? (\n        <div className=\"mt-2 whitespace-pre-wrap text-xs text-muted-foreground leading-relaxed\">\n          {text}\n        </div>\n      ) : (\n        <div className=\"mt-1 truncate text-xs text-muted-foreground\">\n          {text.slice(0, 100)}{text.length > 100 ? \"...\" : \"\"}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction ToolCallBlock({ toolCall }: { toolCall: ToolCallItem }) {\n  const [expanded, setExpanded] = useState(false);\n  const navigateToWire = useNavigateToWire();\n  let parsedArgs: unknown = null;\n  try {\n    parsedArgs = JSON.parse(toolCall.function.arguments);\n  } catch {\n    parsedArgs = toolCall.function.arguments;\n  }\n  const hasExtras = toolCall.extras && Object.keys(toolCall.extras).length > 0;\n\n  return (\n    <div className=\"my-1 rounded-md border bg-purple-500/5 dark:bg-purple-500/10 px-3 py-2\">\n      <div className=\"flex items-center gap-1.5\">\n        <button\n          onClick={() => setExpanded(!expanded)}\n          className=\"flex items-center gap-1.5 text-xs\"\n        >\n          <Wrench size={12} className=\"text-purple-600 dark:text-purple-400\" />\n          <span className=\"font-mono font-medium text-purple-700 dark:text-purple-300\">\n            {toolCall.function.name}\n          </span>\n          <span className=\"text-[10px] text-muted-foreground font-mono\">\n            {toolCall.id.slice(0, 12)}\n          </span>\n          {hasExtras && (\n            <span className=\"text-[10px] text-amber-600 dark:text-amber-400\">+extras</span>\n          )}\n          {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}\n        </button>\n        {navigateToWire && (\n          <span\n            role=\"button\"\n            tabIndex={0}\n            onClick={() => navigateToWire(toolCall.id)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") navigateToWire(toolCall.id);\n            }}\n            className=\"text-[10px] text-blue-600 dark:text-blue-400 hover:underline cursor-pointer flex items-center gap-0.5\"\n          >\n            <ArrowRight size={9} />\n            Wire\n          </span>\n        )}\n      </div>\n      {expanded && (\n        <div className=\"mt-2 space-y-2\">\n          <div className=\"rounded border bg-card p-2\">\n            <div className=\"text-[10px] font-medium text-muted-foreground mb-1\">Arguments</div>\n            <pre className=\"overflow-auto whitespace-pre-wrap text-[11px] font-mono text-card-foreground max-h-96\">\n              {typeof parsedArgs === \"string\"\n                ? parsedArgs\n                : JSON.stringify(parsedArgs, null, 2)}\n            </pre>\n          </div>\n          {hasExtras && (\n            <div className=\"rounded border bg-amber-500/5 p-2\">\n              <div className=\"text-[10px] font-medium text-amber-600 dark:text-amber-400 mb-1\">Extras</div>\n              <pre className=\"overflow-auto whitespace-pre-wrap text-[11px] font-mono text-card-foreground max-h-48\">\n                {JSON.stringify(toolCall.extras, null, 2)}\n              </pre>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction MediaBlock({ part, i }: { part: ContentPart; i: number }) {\n  if (part.type === \"image_url\" && part.image_url) {\n    return (\n      <div key={i} className=\"my-2\">\n        <div className=\"flex items-center gap-1 text-[10px] text-muted-foreground mb-1\">\n          <Image size={10} />\n          <span>Image</span>\n          {part.image_url.id && <span className=\"font-mono\">({part.image_url.id})</span>}\n        </div>\n        <img\n          src={part.image_url.url}\n          alt=\"generated\"\n          className=\"max-w-sm rounded-md border\"\n        />\n      </div>\n    );\n  }\n  if (part.type === \"audio_url\" && part.audio_url) {\n    return (\n      <div key={i} className=\"my-2\">\n        <div className=\"flex items-center gap-1 text-[10px] text-muted-foreground mb-1\">\n          <Music size={10} />\n          <span>Audio</span>\n          {part.audio_url.id && <span className=\"font-mono\">({part.audio_url.id})</span>}\n        </div>\n        <audio controls src={part.audio_url.url} className=\"max-w-sm\" />\n      </div>\n    );\n  }\n  if (part.type === \"video_url\" && part.video_url) {\n    return (\n      <div key={i} className=\"my-2\">\n        <div className=\"flex items-center gap-1 text-[10px] text-muted-foreground mb-1\">\n          <Video size={10} />\n          <span>Video</span>\n          {part.video_url.id && <span className=\"font-mono\">({part.video_url.id})</span>}\n        </div>\n        <video controls src={part.video_url.url} className=\"max-w-sm rounded-md border\" />\n      </div>\n    );\n  }\n  return null;\n}\n\nfunction RenderTextPart({ part, i }: { part: ContentPart; i: number }) {\n  const rawMode = useRawMode();\n  const text = part.text ?? \"\";\n  if (rawMode) {\n    return (\n      <pre key={i} className=\"whitespace-pre-wrap text-sm font-mono leading-relaxed\">\n        {text}\n      </pre>\n    );\n  }\n  return <Markdown key={i}>{text}</Markdown>;\n}\n\nfunction renderContentPart(part: ContentPart, i: number) {\n  switch (part.type) {\n    case \"text\":\n      return <RenderTextPart key={i} part={part} i={i} />;\n    case \"think\":\n    case \"thinking\":\n      return <ThinkingBlock key={i} part={part} />;\n    case \"image_url\":\n    case \"audio_url\":\n    case \"video_url\":\n      return <MediaBlock key={i} part={part} i={i} />;\n    default:\n      return (\n        <div key={i} className=\"my-1 rounded border bg-muted/20 px-2 py-1\">\n          <span className=\"text-[10px] font-mono text-muted-foreground\">[{part.type}]</span>\n          <pre className=\"overflow-auto whitespace-pre-wrap text-[11px] font-mono text-muted-foreground max-h-32\">\n            {JSON.stringify(part, null, 2)}\n          </pre>\n        </div>\n      );\n  }\n}\n\nexport function AssistantMessage({ message }: AssistantMessageProps) {\n  const [showRaw, setShowRaw] = useState(false);\n  const toolCalls = message.tool_calls ?? [];\n\n  return (\n    <div className=\"my-2 flex gap-3\">\n      {/* Avatar */}\n      <div className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-secondary-foreground\">\n        <Bot size={14} />\n      </div>\n\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2 mb-1\">\n          <span className=\"text-sm font-semibold\">Assistant</span>\n          {message.name && (\n            <span className=\"text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded\">\n              {message.name}\n            </span>\n          )}\n          {message.partial && (\n            <span className=\"text-[10px] text-amber-600 dark:text-amber-400\">streaming...</span>\n          )}\n          <button\n            onClick={() => setShowRaw(!showRaw)}\n            className=\"text-[10px] text-muted-foreground hover:text-foreground\"\n          >\n            {showRaw ? (\n              <ChevronDown size={12} className=\"inline\" />\n            ) : (\n              <ChevronRight size={12} className=\"inline\" />\n            )}{\" \"}\n            raw\n          </button>\n        </div>\n\n        {/* Raw JSON (above content) */}\n        {showRaw && (\n          <div className=\"mb-2 rounded-md border bg-card p-2\">\n            <pre className=\"overflow-auto whitespace-pre-wrap text-[11px] font-mono text-muted-foreground max-h-[500px]\">\n              {JSON.stringify(message, null, 2)}\n            </pre>\n          </div>\n        )}\n\n        {/* Content parts */}\n        {normalizeContent(message.content).map((part, i) => renderContentPart(part, i))}\n\n        {/* Tool calls */}\n        {toolCalls.map((tc) => (\n          <ToolCallBlock key={tc.id} toolCall={tc} />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/context-viewer/context-space-map.tsx",
    "content": "import { useMemo } from \"react\";\nimport { type ContextMessage, normalizeContent } from \"@/lib/api\";\n\ninterface SpaceCategory {\n  category: string;\n  label: string;\n  estimatedTokens: number;\n  percentage: number;\n  color: string;\n  messageCount: number;\n}\n\ninterface LargeItem {\n  messageIndex: number;\n  category: string;\n  estimatedTokens: number;\n  percentage: number;\n  preview: string;\n}\n\ninterface ContextSpaceMapProps {\n  messages: ContextMessage[];\n  onScrollToIndex: (idx: number) => void;\n}\n\nconst CATEGORY_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {\n  system: { label: \"System\", color: \"bg-blue-500/60\", bgColor: \"bg-blue-500\" },\n  user: { label: \"User\", color: \"bg-green-500/60\", bgColor: \"bg-green-500\" },\n  assistant: { label: \"Assistant\", color: \"bg-purple-500/60\", bgColor: \"bg-purple-500\" },\n  thinking: { label: \"Thinking\", color: \"bg-cyan-500/60\", bgColor: \"bg-cyan-500\" },\n  \"tool-result\": { label: \"Tool Result\", color: \"bg-amber-500/60\", bgColor: \"bg-amber-500\" },\n  internal: { label: \"Internal\", color: \"bg-gray-500/60\", bgColor: \"bg-gray-500\" },\n};\n\nfunction classifyMessage(msg: ContextMessage): { category: string; tokens: number }[] {\n  const results: { category: string; tokens: number }[] = [];\n\n  if (msg.role.startsWith(\"_\")) {\n    const raw = JSON.stringify(msg).length / 4;\n    results.push({ category: \"internal\", tokens: raw });\n    return results;\n  }\n\n  if (msg.role === \"system\") {\n    const parts = normalizeContent(msg.content);\n    let tokens = 0;\n    for (const p of parts) {\n      if (p.text) tokens += p.text.length / 4;\n    }\n    if (tokens === 0) tokens = JSON.stringify(msg.content).length / 4;\n    results.push({ category: \"system\", tokens });\n    return results;\n  }\n\n  if (msg.role === \"user\") {\n    const parts = normalizeContent(msg.content);\n    let tokens = 0;\n    for (const p of parts) {\n      if (p.text) tokens += p.text.length / 4;\n    }\n    if (tokens === 0) tokens = JSON.stringify(msg.content).length / 4;\n    results.push({ category: \"user\", tokens });\n    return results;\n  }\n\n  if (msg.role === \"tool\") {\n    const tokens = JSON.stringify(msg.content).length / 4;\n    results.push({ category: \"tool-result\", tokens });\n    return results;\n  }\n\n  if (msg.role === \"assistant\") {\n    const parts = normalizeContent(msg.content);\n    let textTokens = 0;\n    let thinkTokens = 0;\n    for (const p of parts) {\n      if (p.think) thinkTokens += p.think.length / 4;\n      else if (p.thinking) thinkTokens += p.thinking.length / 4;\n      else if (p.text) textTokens += p.text.length / 4;\n    }\n    if (msg.tool_calls) {\n      for (const tc of msg.tool_calls) {\n        textTokens += JSON.stringify(tc.function.arguments).length / 4;\n      }\n    }\n    if (textTokens > 0) results.push({ category: \"assistant\", tokens: textTokens });\n    if (thinkTokens > 0) results.push({ category: \"thinking\", tokens: thinkTokens });\n    if (results.length === 0) results.push({ category: \"assistant\", tokens: 1 });\n    return results;\n  }\n\n  // Fallback\n  const tokens = JSON.stringify(msg).length / 4;\n  results.push({ category: \"internal\", tokens });\n  return results;\n}\n\nfunction formatTokens(n: number): string {\n  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;\n  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;\n  return String(Math.round(n));\n}\n\nfunction getPreview(msg: ContextMessage): string {\n  const parts = normalizeContent(msg.content);\n  for (const p of parts) {\n    if (p.text) return p.text.slice(0, 80);\n    if (p.think) return p.think.slice(0, 80);\n    if (p.thinking) return p.thinking.slice(0, 80);\n  }\n  if (msg.tool_calls && msg.tool_calls.length > 0) {\n    return `tool_call: ${msg.tool_calls[0].function.name}(${msg.tool_calls[0].function.arguments.slice(0, 50)})`;\n  }\n  if (msg.role === \"tool\") {\n    return JSON.stringify(msg.content).slice(0, 80);\n  }\n  return JSON.stringify(msg).slice(0, 80);\n}\n\nexport function ContextSpaceMap({ messages, onScrollToIndex }: ContextSpaceMapProps) {\n  const { categories, largeItems, totalTokens } = useMemo(() => {\n    const catMap = new Map<string, { tokens: number; count: number }>();\n    const perMessage: { index: number; category: string; tokens: number }[] = [];\n\n    for (let i = 0; i < messages.length; i++) {\n      const msg = messages[i];\n      const classified = classifyMessage(msg);\n      for (const { category, tokens } of classified) {\n        const existing = catMap.get(category) || { tokens: 0, count: 0 };\n        existing.tokens += tokens;\n        existing.count += 1;\n        catMap.set(category, existing);\n        perMessage.push({ index: i, category, tokens });\n      }\n    }\n\n    const total = Array.from(catMap.values()).reduce((s, v) => s + v.tokens, 0);\n\n    const cats: SpaceCategory[] = Array.from(catMap.entries())\n      .map(([category, { tokens, count }]) => ({\n        category,\n        label: CATEGORY_CONFIG[category]?.label ?? category,\n        estimatedTokens: Math.round(tokens),\n        percentage: total > 0 ? (tokens / total) * 100 : 0,\n        color: CATEGORY_CONFIG[category]?.color ?? \"bg-gray-500/60\",\n        messageCount: count,\n      }))\n      .sort((a, b) => b.estimatedTokens - a.estimatedTokens);\n\n    const threshold = total * 0.05;\n    const large: LargeItem[] = perMessage\n      .filter((m) => m.tokens > threshold)\n      .sort((a, b) => b.tokens - a.tokens)\n      .slice(0, 10)\n      .map((m) => ({\n        messageIndex: m.index,\n        category: m.category,\n        estimatedTokens: Math.round(m.tokens),\n        percentage: total > 0 ? (m.tokens / total) * 100 : 0,\n        preview: getPreview(messages[m.index]),\n      }));\n\n    return { categories: cats, largeItems: large, totalTokens: Math.round(total) };\n  }, [messages]);\n\n  if (messages.length === 0) return null;\n\n  return (\n    <div className=\"border-b px-4 py-3 shrink-0 space-y-3\">\n      {/* Header */}\n      <div className=\"flex items-center gap-2\">\n        <span className=\"text-[10px] font-medium text-muted-foreground\">\n          Context Space Map\n        </span>\n        <span className=\"text-[10px] text-muted-foreground\">\n          (~{formatTokens(totalTokens)} est. tokens)\n        </span>\n      </div>\n\n      {/* Horizontal stacked bar */}\n      <div className=\"w-full h-5 bg-muted/30 rounded-sm overflow-hidden flex\">\n        {categories.map((cat) => (\n          <div\n            key={cat.category}\n            className={`h-full ${cat.color} hover:brightness-110 transition-all relative group`}\n            style={{ width: `${cat.percentage}%` }}\n            title={`${cat.label}: ${formatTokens(cat.estimatedTokens)} (${cat.percentage.toFixed(1)}%)`}\n          >\n            {cat.percentage > 8 && (\n              <span className=\"absolute inset-0 flex items-center justify-center text-[9px] text-white font-medium truncate px-1\">\n                {cat.label}\n              </span>\n            )}\n          </div>\n        ))}\n      </div>\n\n      {/* Legend */}\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n        {categories.map((cat) => (\n          <div key={cat.category} className=\"flex items-center gap-1\">\n            <div className={`w-2 h-2 rounded-sm ${cat.color}`} />\n            <span className=\"text-[10px] text-muted-foreground\">\n              {cat.label} {formatTokens(cat.estimatedTokens)} ({cat.percentage.toFixed(1)}%)\n            </span>\n          </div>\n        ))}\n      </div>\n\n      {/* Category breakdown table */}\n      <div className=\"overflow-x-auto\">\n        <table className=\"w-full text-[11px]\">\n          <thead>\n            <tr className=\"text-left text-muted-foreground border-b\">\n              <th className=\"py-1 pr-3 font-medium\">Category</th>\n              <th className=\"py-1 pr-3 font-medium text-right\">Messages</th>\n              <th className=\"py-1 pr-3 font-medium text-right\">Est. Tokens</th>\n              <th className=\"py-1 font-medium text-right\">Percentage</th>\n            </tr>\n          </thead>\n          <tbody>\n            {categories.map((cat) => (\n              <tr key={cat.category} className=\"border-b border-border/50\">\n                <td className=\"py-1 pr-3\">\n                  <span className=\"flex items-center gap-1.5\">\n                    <span className={`inline-block w-2 h-2 rounded-sm ${cat.color}`} />\n                    <span className=\"text-foreground\">{cat.label}</span>\n                  </span>\n                </td>\n                <td className=\"py-1 pr-3 text-right text-muted-foreground tabular-nums\">\n                  {cat.messageCount}\n                </td>\n                <td className=\"py-1 pr-3 text-right text-muted-foreground tabular-nums\">\n                  {formatTokens(cat.estimatedTokens)}\n                </td>\n                <td className=\"py-1 text-right text-muted-foreground tabular-nums\">\n                  {cat.percentage.toFixed(1)}%\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n\n      {/* Top large items */}\n      {largeItems.length > 0 && (\n        <div>\n          <div className=\"text-[10px] font-medium text-muted-foreground mb-1\">\n            Top {largeItems.length} Largest Items (&gt;5% of total)\n          </div>\n          <div className=\"flex flex-col gap-0.5\">\n            {largeItems.map((item, i) => {\n              const cfg = CATEGORY_CONFIG[item.category];\n              return (\n                <button\n                  key={`${item.messageIndex}-${i}`}\n                  onClick={() => onScrollToIndex(item.messageIndex)}\n                  className=\"flex items-center gap-2 px-2 py-1 rounded hover:bg-muted/50 text-left transition-colors group\"\n                >\n                  <span\n                    className={`shrink-0 text-[9px] px-1.5 py-0.5 rounded font-medium text-white ${cfg?.bgColor ?? \"bg-gray-500\"}`}\n                  >\n                    {cfg?.label ?? item.category}\n                  </span>\n                  <span className=\"flex-1 text-[11px] text-muted-foreground truncate group-hover:text-foreground\">\n                    {item.preview}\n                    {item.preview.length >= 80 ? \"...\" : \"\"}\n                  </span>\n                  <span className=\"shrink-0 text-[10px] text-muted-foreground tabular-nums\">\n                    {formatTokens(item.estimatedTokens)}\n                  </span>\n                  <span className=\"shrink-0 text-[10px] text-muted-foreground tabular-nums\">\n                    {item.percentage.toFixed(1)}%\n                  </span>\n                </button>\n              );\n            })}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/context-viewer/context-viewer.tsx",
    "content": "import { createContext, useContext, useEffect, useMemo, useRef, useState } from \"react\";\nimport { type ContextMessage, getContextMessages, normalizeContent } from \"@/lib/api\";\nimport { UserMessage } from \"./user-message\";\nimport { AssistantMessage } from \"./assistant-message\";\nimport { ToolMessage } from \"./tool-call-block\";\nimport { Markdown } from \"@/components/markdown\";\nimport { Virtuoso, type VirtuosoHandle } from \"react-virtuoso\";\nimport {\n  ChevronDown,\n  ChevronRight,\n  ChevronUp,\n  Activity,\n  Bookmark,\n  Eye,\n  EyeOff,\n  Code,\n  FileText,\n  BarChart3,\n  Search,\n  X,\n} from \"lucide-react\";\nimport { ContextSpaceMap } from \"./context-space-map\";\n\ninterface ContextViewerProps {\n  sessionId: string;\n  /** Increment to force-refresh data */\n  refreshKey?: number;\n  /** Callback to navigate to Wire Events tab for a specific tool_call_id */\n  onNavigateToWire?: (toolCallId: string) => void;\n  /** If set, scroll to the message with this tool_call_id */\n  scrollToToolCallId?: string | null;\n  /** Called after the scroll target has been consumed */\n  onScrollTargetConsumed?: () => void;\n}\n\n/** React context for cross-reference navigation */\nexport const NavigateToWireContext = createContext<((toolCallId: string) => void) | null>(null);\nexport const useNavigateToWire = () => useContext(NavigateToWireContext);\n\n/** Context for raw mode toggle - shared across all message components */\nexport const RawModeContext = createContext(false);\nexport const useRawMode = () => useContext(RawModeContext);\n\n/** Inline metadata row for _usage / _checkpoint / other internal records */\nfunction MetadataRow({ message }: { message: ContextMessage }) {\n  const [expanded, setExpanded] = useState(false);\n  const label = message.role === \"_usage\" ? \"Usage\" : message.role === \"_checkpoint\" ? \"Checkpoint\" : message.role;\n  const Icon = message.role === \"_usage\" ? Activity : Bookmark;\n\n  return (\n    <div className=\"my-0.5 ml-10 px-2 py-1 rounded border border-dashed bg-muted/10\">\n      <button\n        onClick={() => setExpanded(!expanded)}\n        className=\"flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-foreground\"\n      >\n        <Icon size={10} />\n        <span className=\"font-medium\">{label}</span>\n        {message.role === \"_usage\" && message.token_count != null && (\n          <span className=\"font-mono\">{message.token_count.toLocaleString()} tokens</span>\n        )}\n        {message.role === \"_checkpoint\" && message.id != null && (\n          <span className=\"font-mono\">id={message.id}</span>\n        )}\n        {expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}\n      </button>\n      {expanded && (\n        <pre className=\"mt-1 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-muted-foreground max-h-32\">\n          {JSON.stringify(message, null, 2)}\n        </pre>\n      )}\n    </div>\n  );\n}\n\nexport function ContextViewer({ sessionId, refreshKey = 0, onNavigateToWire, scrollToToolCallId, onScrollTargetConsumed }: ContextViewerProps) {\n  const [allMessages, setAllMessages] = useState<ContextMessage[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n  const [showInternal, setShowInternal] = useState(false);\n  const [rawMode, setRawMode] = useState(false);\n  const [showSpaceMap, setShowSpaceMap] = useState(false);\n  const [highlightedToolCallId, setHighlightedToolCallId] = useState<string | null>(null);\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const [searchNavIndex, setSearchNavIndex] = useState(0);\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(null);\n    getContextMessages(sessionId, refreshKey > 0)\n      .then((res) => {\n        setAllMessages(res.messages);\n      })\n      .catch((err) => setError(err.message))\n      .finally(() => setLoading(false));\n  }, [sessionId, refreshKey]);\n\n  const visibleMessages = useMemo(\n    () => showInternal ? allMessages : allMessages.filter((m) => !m.role.startsWith(\"_\")),\n    [allMessages, showInternal],\n  );\n\n  const internalCount = useMemo(\n    () => allMessages.filter((m) => m.role.startsWith(\"_\")).length,\n    [allMessages],\n  );\n\n  // Build tool_call_id -> visible message index lookup\n  const toolCallIdToIndex = useMemo(() => {\n    const map = new Map<string, number>();\n    visibleMessages.forEach((msg, idx) => {\n      if (msg.tool_call_id) map.set(msg.tool_call_id, idx);\n      if (msg.tool_calls) {\n        for (const tc of msg.tool_calls) {\n          map.set(tc.id, idx);\n        }\n      }\n    });\n    return map;\n  }, [visibleMessages]);\n\n  // Search: extract text from a message for matching\n  const searchMatchIndices = useMemo(() => {\n    if (!searchQuery) return [];\n    const q = searchQuery.toLowerCase();\n    const matches: number[] = [];\n    visibleMessages.forEach((msg, idx) => {\n      const parts = normalizeContent(msg.content);\n      for (const p of parts) {\n        if (p.text && p.text.toLowerCase().includes(q)) { matches.push(idx); return; }\n        if (p.think && p.think.toLowerCase().includes(q)) { matches.push(idx); return; }\n        if (p.thinking && p.thinking.toLowerCase().includes(q)) { matches.push(idx); return; }\n      }\n      if (msg.tool_calls) {\n        for (const tc of msg.tool_calls) {\n          if (tc.function.name.toLowerCase().includes(q) || tc.function.arguments.toLowerCase().includes(q)) {\n            matches.push(idx); return;\n          }\n        }\n      }\n      if (msg.role === \"tool\") {\n        const raw = JSON.stringify(msg.content).toLowerCase();\n        if (raw.includes(q)) { matches.push(idx); return; }\n      }\n    });\n    return matches;\n  }, [visibleMessages, searchQuery]);\n\n  // Reset nav index when search changes\n  useEffect(() => {\n    setSearchNavIndex(0);\n    if (searchMatchIndices.length > 0 && virtuosoRef.current) {\n      virtuosoRef.current.scrollToIndex({ index: searchMatchIndices[0], align: \"center\", behavior: \"smooth\" });\n    }\n  }, [searchQuery, searchMatchIndices]);\n\n  const searchMatchSet = useMemo(() => new Set(searchMatchIndices), [searchMatchIndices]);\n\n  const navigateSearch = (direction: \"next\" | \"prev\") => {\n    if (searchMatchIndices.length === 0) return;\n    const next = direction === \"next\"\n      ? (searchNavIndex + 1) % searchMatchIndices.length\n      : (searchNavIndex - 1 + searchMatchIndices.length) % searchMatchIndices.length;\n    setSearchNavIndex(next);\n    virtuosoRef.current?.scrollToIndex({ index: searchMatchIndices[next], align: \"center\", behavior: \"smooth\" });\n  };\n\n  // Keyboard: / to focus search\n  useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      const tag = (e.target as HTMLElement)?.tagName;\n      if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n      if (e.key === \"/\") {\n        e.preventDefault();\n        const input = document.querySelector('input[placeholder=\"Search messages...\"]') as HTMLInputElement | null;\n        input?.focus();\n      }\n    };\n    window.addEventListener(\"keydown\", handler);\n    return () => window.removeEventListener(\"keydown\", handler);\n  }, []);\n\n  // Handle incoming scroll-to-tool-call-id\n  useEffect(() => {\n    if (!scrollToToolCallId || visibleMessages.length === 0) return;\n    setHighlightedToolCallId(scrollToToolCallId);\n    const targetIdx = toolCallIdToIndex.get(scrollToToolCallId);\n    if (targetIdx != null && virtuosoRef.current) {\n      setTimeout(() => {\n        virtuosoRef.current?.scrollToIndex({\n          index: targetIdx,\n          align: \"center\",\n          behavior: \"smooth\",\n        });\n      }, 100);\n    }\n    onScrollTargetConsumed?.();\n    const timer = setTimeout(() => setHighlightedToolCallId(null), 3000);\n    return () => clearTimeout(timer);\n  }, [scrollToToolCallId, visibleMessages, toolCallIdToIndex, onScrollTargetConsumed]);\n\n  if (loading) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        Loading context messages...\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-destructive\">\n        Error: {error}\n      </div>\n    );\n  }\n\n  if (allMessages.length === 0) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        No context messages found\n      </div>\n    );\n  }\n\n  return (\n    <RawModeContext.Provider value={rawMode}>\n      <NavigateToWireContext.Provider value={onNavigateToWire ?? null}>\n        <div className=\"h-full flex flex-col overflow-hidden\">\n          {/* Stats bar */}\n          <div className=\"flex items-center gap-2 px-4 py-1.5 border-b text-[11px] text-muted-foreground shrink-0\">\n            <span className=\"shrink-0\">{allMessages.length} messages</span>\n            {internalCount > 0 && (\n              <button\n                onClick={() => setShowInternal(!showInternal)}\n                className=\"flex items-center gap-1 hover:text-foreground shrink-0\"\n              >\n                {showInternal ? <EyeOff size={11} /> : <Eye size={11} />}\n                {showInternal ? \"Hide\" : \"Show\"} {internalCount} internal\n              </button>\n            )}\n\n            <div className=\"h-4 w-px bg-border shrink-0\" />\n\n            {/* Search */}\n            <div className=\"relative flex-1 max-w-xs\">\n              <Search size={12} className=\"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground\" />\n              <input\n                value={searchQuery}\n                onChange={(e) => setSearchQuery(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\") {\n                    e.preventDefault();\n                    navigateSearch(e.shiftKey ? \"prev\" : \"next\");\n                  }\n                  if (e.key === \"Escape\") {\n                    setSearchQuery(\"\");\n                    (e.target as HTMLInputElement).blur();\n                  }\n                }}\n                placeholder=\"Search messages...\"\n                className=\"w-full rounded border bg-background pl-7 pr-7 py-0.5 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring\"\n              />\n              {searchQuery && (\n                <button\n                  onClick={() => setSearchQuery(\"\")}\n                  className=\"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n                >\n                  <X size={11} />\n                </button>\n              )}\n            </div>\n            {searchQuery && (\n              <div className=\"flex items-center gap-1 shrink-0\">\n                <span className=\"text-[10px]\">\n                  {searchMatchIndices.length > 0\n                    ? `${searchNavIndex + 1}/${searchMatchIndices.length}`\n                    : \"0 results\"}\n                </span>\n                <button onClick={() => navigateSearch(\"prev\")} className=\"p-0.5 hover:bg-muted rounded\" title=\"Previous (Shift+Enter)\">\n                  <ChevronUp size={12} />\n                </button>\n                <button onClick={() => navigateSearch(\"next\")} className=\"p-0.5 hover:bg-muted rounded\" title=\"Next (Enter)\">\n                  <ChevronDown size={12} />\n                </button>\n              </div>\n            )}\n\n            <div className=\"ml-auto\" />\n            <button\n              onClick={() => setShowSpaceMap(!showSpaceMap)}\n              className={`flex items-center gap-1 px-2 py-0.5 rounded transition-colors shrink-0 ${\n                showSpaceMap\n                  ? \"bg-primary/10 text-foreground\"\n                  : \"hover:text-foreground\"\n              }`}\n            >\n              <BarChart3 size={11} />\n              Space\n            </button>\n            <button\n              onClick={() => setRawMode(!rawMode)}\n              className={`flex items-center gap-1 px-2 py-0.5 rounded transition-colors shrink-0 ${\n                rawMode\n                  ? \"bg-primary/10 text-foreground\"\n                  : \"hover:text-foreground\"\n              }`}\n            >\n              {rawMode ? <Code size={11} /> : <FileText size={11} />}\n              {rawMode ? \"Raw\" : \"Rendered\"}\n            </button>\n          </div>\n\n          {showSpaceMap && (\n            <ContextSpaceMap messages={allMessages} onScrollToIndex={(idx) => {\n              virtuosoRef.current?.scrollToIndex({ index: idx, align: \"center\", behavior: \"smooth\" });\n            }} />\n          )}\n\n          <Virtuoso\n            ref={virtuosoRef}\n            data={visibleMessages}\n            itemContent={(idx, message) => {\n              const toolCallId = message.tool_call_id ?? null;\n              const assistantToolCallIds = message.tool_calls?.map((tc) => tc.id) ?? [];\n              const isHighlighted =\n                (toolCallId && toolCallId === highlightedToolCallId) ||\n                assistantToolCallIds.includes(highlightedToolCallId ?? \"\");\n\n              const isSearchMatch = searchQuery && searchMatchSet.has(idx);\n\n              return (\n                <div\n                  className={`px-4 py-1 ${isHighlighted ? \"bg-blue-500/10 ring-1 ring-blue-500/30 rounded transition-all\" : \"\"} ${isSearchMatch ? \"bg-yellow-500/10\" : \"\"}`}\n                >\n                  {message.role === \"user\" && <UserMessage message={message} />}\n                  {message.role === \"assistant\" && (\n                    <AssistantMessage message={message} />\n                  )}\n                  {message.role === \"tool\" && <ToolMessage message={message} />}\n                  {message.role === \"system\" && (\n                    <SystemMessage message={message} />\n                  )}\n                  {message.role.startsWith(\"_\") && (\n                    <MetadataRow message={message} />\n                  )}\n                  {![\"user\", \"assistant\", \"tool\", \"system\"].includes(message.role) &&\n                    !message.role.startsWith(\"_\") && (\n                      <UnknownMessage message={message} />\n                    )}\n                </div>\n              );\n            }}\n          />\n        </div>\n      </NavigateToWireContext.Provider>\n    </RawModeContext.Provider>\n  );\n}\n\nfunction SystemMessage({ message }: { message: ContextMessage }) {\n  const [expanded, setExpanded] = useState(false);\n  const rawMode = useRawMode();\n  const text = normalizeContent(message.content)[0]?.text ?? \"\";\n  const preview = text.slice(0, 150);\n\n  return (\n    <div className=\"my-2 rounded-md border border-dashed bg-muted/30 px-3 py-2\">\n      <button\n        onClick={() => setExpanded(!expanded)}\n        className=\"flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground\"\n      >\n        <span className=\"font-medium\">System</span>\n        {message.name && (\n          <span className=\"font-mono text-[10px] bg-muted px-1 py-0.5 rounded\">{message.name}</span>\n        )}\n        {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}\n      </button>\n      {expanded ? (\n        <div className=\"mt-2 max-h-96 overflow-auto text-xs text-muted-foreground\">\n          {text ? (\n            rawMode ? (\n              <pre className=\"whitespace-pre-wrap font-mono text-[11px]\">{text}</pre>\n            ) : (\n              <Markdown>{text}</Markdown>\n            )\n          ) : (\n            <pre className=\"whitespace-pre-wrap font-mono text-[11px]\">\n              {JSON.stringify(message.content, null, 2)}\n            </pre>\n          )}\n        </div>\n      ) : (\n        <div className=\"mt-1 truncate text-xs text-muted-foreground\">\n          {preview}{text.length > 150 ? \"...\" : \"\"}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction UnknownMessage({ message }: { message: ContextMessage }) {\n  const [expanded, setExpanded] = useState(false);\n\n  return (\n    <div className=\"my-1 rounded-md border bg-muted/10 px-3 py-2\">\n      <button\n        onClick={() => setExpanded(!expanded)}\n        className=\"flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground\"\n      >\n        <span className=\"font-mono font-medium\">{message.role}</span>\n        {message.name && (\n          <span className=\"font-mono text-[10px] bg-muted px-1 py-0.5 rounded\">{message.name}</span>\n        )}\n        {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}\n      </button>\n      {expanded && (\n        <pre className=\"mt-2 overflow-auto whitespace-pre-wrap text-[11px] font-mono text-muted-foreground max-h-64\">\n          {JSON.stringify(message, null, 2)}\n        </pre>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/context-viewer/tool-call-block.tsx",
    "content": "import { useState } from \"react\";\nimport type { ContextMessage } from \"@/lib/api\";\nimport { normalizeContent } from \"@/lib/api\";\nimport { useNavigateToWire } from \"./context-viewer\";\nimport { ChevronDown, ChevronRight, Terminal, ArrowRight } from \"lucide-react\";\n\ninterface ToolMessageProps {\n  message: ContextMessage;\n}\n\nexport function ToolMessage({ message }: ToolMessageProps) {\n  const [expanded, setExpanded] = useState(false);\n  const navigateToWire = useNavigateToWire();\n\n  const parts = normalizeContent(message.content);\n\n  const textContent = parts\n    .filter((p) => p.type === \"text\")\n    .map((p) => p.text)\n    .join(\"\\n\");\n\n  // Compute a short preview for collapsed state\n  const preview = textContent\n    ? textContent.slice(0, 80) + (textContent.length > 80 ? \"...\" : \"\")\n    : null;\n\n  return (\n    <div className=\"my-1 ml-10 rounded-md border bg-muted/20 px-3 py-2\">\n      <button\n        onClick={() => setExpanded(!expanded)}\n        className=\"flex items-center gap-1.5 text-xs\"\n      >\n        <Terminal size={12} className=\"text-muted-foreground\" />\n        <span className=\"font-medium text-muted-foreground\">Tool Result</span>\n        {message.name && (\n          <span className=\"font-mono text-[10px] text-purple-600 dark:text-purple-400\">\n            {message.name}\n          </span>\n        )}\n        {message.tool_call_id && (\n          <span className=\"font-mono text-[10px] text-muted-foreground\">\n            {message.tool_call_id.slice(0, 12)}\n          </span>\n        )}\n        {expanded ? (\n          <ChevronDown size={12} className=\"text-muted-foreground\" />\n        ) : (\n          <ChevronRight size={12} className=\"text-muted-foreground\" />\n        )}\n      </button>\n      {/* Cross-reference: navigate to Wire Events */}\n      {navigateToWire && message.tool_call_id && (\n        <span\n          role=\"button\"\n          tabIndex={0}\n          onClick={() => navigateToWire(message.tool_call_id!)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") navigateToWire(message.tool_call_id!);\n          }}\n          className=\"inline-flex items-center gap-0.5 ml-6 mt-0.5 text-[10px] text-blue-600 dark:text-blue-400 hover:underline cursor-pointer\"\n        >\n          <ArrowRight size={9} />\n          Wire\n        </span>\n      )}\n\n      {/* Collapsed preview */}\n      {!expanded && preview && (\n        <div className=\"mt-1 truncate text-[11px] font-mono text-muted-foreground\">\n          {preview}\n        </div>\n      )}\n\n      {expanded && (\n        <div className=\"mt-2 rounded border bg-card p-2\">\n          {textContent ? (\n            <pre className=\"overflow-auto whitespace-pre-wrap text-[11px] font-mono text-card-foreground max-h-96\">\n              {textContent}\n            </pre>\n          ) : (\n            <pre className=\"overflow-auto whitespace-pre-wrap text-[11px] font-mono text-muted-foreground max-h-96\">\n              {JSON.stringify(message.content, null, 2)}\n            </pre>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/context-viewer/user-message.tsx",
    "content": "import { useState } from \"react\";\nimport type { ContextMessage } from \"@/lib/api\";\nimport { normalizeContent } from \"@/lib/api\";\nimport { Markdown } from \"@/components/markdown\";\nimport { useRawMode } from \"./context-viewer\";\nimport { ChevronDown, ChevronRight, User, Image, Music, Video } from \"lucide-react\";\n\ninterface UserMessageProps {\n  message: ContextMessage;\n}\n\nexport function UserMessage({ message }: UserMessageProps) {\n  const [showRaw, setShowRaw] = useState(false);\n\n  const parts = normalizeContent(message.content);\n\n  const textContent = parts\n    .filter((p) => p.type === \"text\")\n    .map((p) => p.text)\n    .join(\"\\n\");\n\n  const images = parts.filter((p) => p.type === \"image_url\");\n  const audios = parts.filter((p) => p.type === \"audio_url\");\n  const videos = parts.filter((p) => p.type === \"video_url\");\n\n  return (\n    <div className=\"my-2 flex gap-3\">\n      {/* Avatar */}\n      <div className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n        <User size={14} />\n      </div>\n\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2 mb-1\">\n          <span className=\"text-sm font-semibold\">User</span>\n          {message.name && (\n            <span className=\"text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded\">\n              {message.name}\n            </span>\n          )}\n          <button\n            onClick={() => setShowRaw(!showRaw)}\n            className=\"text-[10px] text-muted-foreground hover:text-foreground\"\n          >\n            {showRaw ? (\n              <ChevronDown size={12} className=\"inline\" />\n            ) : (\n              <ChevronRight size={12} className=\"inline\" />\n            )}{\" \"}\n            raw\n          </button>\n        </div>\n\n        {/* Text content */}\n        <TextContent text={textContent} />\n\n        {/* Images */}\n        {images.map((img, i) => (\n          <div key={i} className=\"mt-2\">\n            <div className=\"flex items-center gap-1 text-[10px] text-muted-foreground mb-1\">\n              <Image size={10} />\n              <span>Image</span>\n              {img.image_url?.id && <span className=\"font-mono\">({img.image_url.id})</span>}\n            </div>\n            <img\n              src={img.image_url?.url}\n              alt=\"attachment\"\n              className=\"max-w-sm rounded-md border\"\n            />\n          </div>\n        ))}\n\n        {/* Audio */}\n        {audios.map((aud, i) => (\n          <div key={`audio-${i}`} className=\"mt-2\">\n            <div className=\"flex items-center gap-1 text-[10px] text-muted-foreground mb-1\">\n              <Music size={10} />\n              <span>Audio</span>\n              {aud.audio_url?.id && <span className=\"font-mono\">({aud.audio_url.id})</span>}\n            </div>\n            <audio controls src={aud.audio_url?.url} className=\"max-w-sm\" />\n          </div>\n        ))}\n\n        {/* Video */}\n        {videos.map((vid, i) => (\n          <div key={`video-${i}`} className=\"mt-2\">\n            <div className=\"flex items-center gap-1 text-[10px] text-muted-foreground mb-1\">\n              <Video size={10} />\n              <span>Video</span>\n              {vid.video_url?.id && <span className=\"font-mono\">({vid.video_url.id})</span>}\n            </div>\n            <video controls src={vid.video_url?.url} className=\"max-w-sm rounded-md border\" />\n          </div>\n        ))}\n\n        {/* Raw JSON */}\n        {showRaw && (\n          <div className=\"mt-2 rounded-md border bg-card p-2\">\n            <pre className=\"overflow-auto whitespace-pre-wrap text-[11px] font-mono text-muted-foreground max-h-[500px]\">\n              {JSON.stringify(message, null, 2)}\n            </pre>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction TextContent({ text }: { text: string }) {\n  const rawMode = useRawMode();\n  return (\n    <div className=\"rounded-lg bg-primary/10 px-3 py-2\">\n      {text ? (\n        rawMode ? (\n          <pre className=\"whitespace-pre-wrap text-sm font-mono leading-relaxed\">{text}</pre>\n        ) : (\n          <Markdown>{text}</Markdown>\n        )\n      ) : (\n        <span className=\"text-sm text-muted-foreground\">(empty)</span>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/dual-view/dual-view.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  type WireEvent,\n  type ContextMessage,\n  getWireEvents,\n  getContextMessages,\n  normalizeContent,\n} from \"@/lib/api\";\nimport { Virtuoso, type VirtuosoHandle } from \"react-virtuoso\";\nimport { formatTimestamp } from \"@/features/wire-viewer/wire-event-card\";\n\ninterface DualViewProps {\n  sessionId: string;\n  refreshKey?: number;\n}\n\n// ── Type badge colors (simplified from wire-event-card) ──\n\nconst TYPE_COLORS: Record<string, string> = {\n  TurnBegin: \"bg-blue-500/15 text-blue-700 dark:text-blue-300\",\n  TurnEnd: \"bg-blue-500/15 text-blue-700 dark:text-blue-300\",\n  StepBegin: \"bg-green-500/15 text-green-700 dark:text-green-300\",\n  StepInterrupted: \"bg-yellow-500/15 text-yellow-700 dark:text-yellow-300\",\n  CompactionBegin: \"bg-orange-500/15 text-orange-700 dark:text-orange-300\",\n  CompactionEnd: \"bg-orange-500/15 text-orange-700 dark:text-orange-300\",\n  StatusUpdate: \"bg-gray-500/15 text-gray-700 dark:text-gray-300\",\n  TextPart: \"bg-gray-500/15 text-gray-700 dark:text-gray-300\",\n  ThinkPart: \"bg-gray-500/15 text-gray-700 dark:text-gray-300\",\n  ToolCall: \"bg-purple-500/15 text-purple-700 dark:text-purple-300\",\n  ToolResult: \"bg-purple-500/15 text-purple-700 dark:text-purple-300\",\n  ToolCallPart: \"bg-purple-500/15 text-purple-700 dark:text-purple-300\",\n  ApprovalRequest: \"bg-amber-500/15 text-amber-700 dark:text-amber-300\",\n  ApprovalResponse: \"bg-amber-500/15 text-amber-700 dark:text-amber-300\",\n  SubagentEvent: \"bg-indigo-500/15 text-indigo-700 dark:text-indigo-300\",\n};\n\nconst ROLE_COLORS: Record<string, string> = {\n  user: \"bg-blue-500/15 text-blue-700 dark:text-blue-300\",\n  assistant: \"bg-green-500/15 text-green-700 dark:text-green-300\",\n  tool: \"bg-purple-500/15 text-purple-700 dark:text-purple-300\",\n  system: \"bg-gray-500/15 text-gray-700 dark:text-gray-300\",\n};\n\nfunction getWireSummary(event: WireEvent): string {\n  const p = event.payload;\n  switch (event.type) {\n    case \"TurnBegin\": {\n      const input = p.user_input;\n      if (typeof input === \"string\") return input.slice(0, 100);\n      if (Array.isArray(input) && input.length > 0) {\n        const first = input[0] as Record<string, unknown>;\n        return String(first.text ?? \"\").slice(0, 100);\n      }\n      return \"\";\n    }\n    case \"StepBegin\":\n      return `Step ${p.n}`;\n    case \"TextPart\":\n      return String(p.text ?? \"\").slice(0, 100);\n    case \"ThinkPart\":\n      return String(p.thinking ?? p.think ?? \"\").slice(0, 100);\n    case \"ToolCall\": {\n      const fn = p.function as Record<string, unknown> | undefined;\n      return fn ? `${fn.name}()` : \"\";\n    }\n    case \"ToolCallPart\":\n      return String(p.arguments_part ?? \"\").slice(0, 60);\n    case \"ToolResult\": {\n      const rv = p.return_value as Record<string, unknown> | undefined;\n      if (rv) {\n        const isErr = rv.is_error ? \"[error] \" : \"\";\n        const output = rv.output;\n        if (typeof output === \"string\") return `${isErr}${output.slice(0, 100)}`;\n        if (Array.isArray(output)) return `${isErr}${output.length} part(s)`;\n        return isErr || \"result\";\n      }\n      return `tool_call_id: ${p.tool_call_id}`;\n    }\n    case \"StatusUpdate\": {\n      if (p.context_usage != null)\n        return `ctx: ${((p.context_usage as number) * 100).toFixed(1)}%`;\n      return \"\";\n    }\n    case \"ApprovalRequest\":\n      return `${p.sender}: ${p.action}`;\n    case \"ApprovalResponse\":\n      return String(p.response ?? \"\");\n    case \"SubagentEvent\": {\n      const inner = p.event as Record<string, unknown> | undefined;\n      const innerType = inner?.type as string | undefined;\n      return innerType ? `sub:${innerType}` : \"\";\n    }\n    default:\n      return \"\";\n  }\n}\n\nfunction getContextSummary(msg: ContextMessage): string {\n  if (msg.role === \"tool\") {\n    const parts = normalizeContent(msg.content);\n    const text = parts.map((p) => p.text ?? \"\").join(\" \");\n    return text.slice(0, 100) || (msg.name ? `${msg.name} result` : \"tool result\");\n  }\n  if (msg.role === \"assistant\") {\n    if (msg.tool_calls && msg.tool_calls.length > 0) {\n      return msg.tool_calls.map((tc) => `${tc.function.name}()`).join(\", \");\n    }\n    const parts = normalizeContent(msg.content);\n    const text = parts.map((p) => p.text ?? p.think ?? p.thinking ?? \"\").join(\" \");\n    return text.slice(0, 100);\n  }\n  if (msg.role === \"user\" || msg.role === \"system\") {\n    const parts = normalizeContent(msg.content);\n    const text = parts.map((p) => p.text ?? \"\").join(\" \");\n    return text.slice(0, 100);\n  }\n  return msg.role;\n}\n\n/** Extract the tool_call_id from a wire event, if any */\nfunction getWireToolCallId(event: WireEvent): string | null {\n  if (event.type === \"ToolCall\") {\n    return (event.payload.id as string) ?? null;\n  }\n  if (event.type === \"ToolResult\") {\n    return (event.payload.tool_call_id as string) ?? null;\n  }\n  return null;\n}\n\n/** Extract tool_call_ids from a context message */\nfunction getContextToolCallIds(msg: ContextMessage): string[] {\n  const ids: string[] = [];\n  if (msg.tool_call_id) ids.push(msg.tool_call_id);\n  if (msg.tool_calls) {\n    for (const tc of msg.tool_calls) {\n      ids.push(tc.id);\n    }\n  }\n  return ids;\n}\n\nexport function DualView({ sessionId, refreshKey = 0 }: DualViewProps) {\n  const [wireEvents, setWireEvents] = useState<WireEvent[]>([]);\n  const [contextMessages, setContextMessages] = useState<ContextMessage[]>([]);\n  const [wireLoading, setWireLoading] = useState(true);\n  const [contextLoading, setContextLoading] = useState(true);\n  const [wireError, setWireError] = useState<string | null>(null);\n  const [contextError, setContextError] = useState<string | null>(null);\n  const [highlightedToolCallId, setHighlightedToolCallId] = useState<string | null>(null);\n\n  const wireVirtuosoRef = useRef<VirtuosoHandle>(null);\n  const contextVirtuosoRef = useRef<VirtuosoHandle>(null);\n  const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Fetch data\n  useEffect(() => {\n    const forceRefresh = refreshKey > 0;\n    setWireLoading(true);\n    setWireError(null);\n    getWireEvents(sessionId, forceRefresh)\n      .then((res) => setWireEvents(res.events))\n      .catch((err) => setWireError(err.message))\n      .finally(() => setWireLoading(false));\n\n    setContextLoading(true);\n    setContextError(null);\n    getContextMessages(sessionId, forceRefresh)\n      .then((res) => setContextMessages(res.messages.filter((m) => !m.role.startsWith(\"_\"))))\n      .catch((err) => setContextError(err.message))\n      .finally(() => setContextLoading(false));\n  }, [sessionId, refreshKey]);\n\n  // Build bidirectional mapping: tool_call_id -> wire event index\n  const wireToolCallIdToIndex = useMemo(() => {\n    const map = new Map<string, number>();\n    wireEvents.forEach((evt, idx) => {\n      const tcId = getWireToolCallId(evt);\n      if (tcId) map.set(tcId, idx);\n    });\n    return map;\n  }, [wireEvents]);\n\n  // Build mapping: tool_call_id -> context message index\n  const contextToolCallIdToIndex = useMemo(() => {\n    const map = new Map<string, number>();\n    contextMessages.forEach((msg, idx) => {\n      for (const id of getContextToolCallIds(msg)) {\n        map.set(id, idx);\n      }\n    });\n    return map;\n  }, [contextMessages]);\n\n  const handleHighlight = useCallback(\n    (toolCallId: string, source: \"wire\" | \"context\") => {\n      // Clear previous timer\n      if (highlightTimerRef.current) {\n        clearTimeout(highlightTimerRef.current);\n      }\n\n      setHighlightedToolCallId(toolCallId);\n\n      // Scroll the OTHER pane to the matching item\n      if (source === \"wire\") {\n        const contextIdx = contextToolCallIdToIndex.get(toolCallId);\n        if (contextIdx != null) {\n          contextVirtuosoRef.current?.scrollToIndex({\n            index: contextIdx,\n            align: \"center\",\n            behavior: \"smooth\",\n          });\n        }\n      } else {\n        const wireIdx = wireToolCallIdToIndex.get(toolCallId);\n        if (wireIdx != null) {\n          wireVirtuosoRef.current?.scrollToIndex({\n            index: wireIdx,\n            align: \"center\",\n            behavior: \"smooth\",\n          });\n        }\n      }\n\n      // Clear highlight after 2s\n      highlightTimerRef.current = setTimeout(() => {\n        setHighlightedToolCallId(null);\n      }, 2000);\n    },\n    [contextToolCallIdToIndex, wireToolCallIdToIndex],\n  );\n\n  // Cleanup timer\n  useEffect(() => {\n    return () => {\n      if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);\n    };\n  }, []);\n\n  const loading = wireLoading || contextLoading;\n\n  if (loading) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        Loading dual view...\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-full overflow-hidden\">\n      {/* Left pane: Wire Events */}\n      <div className=\"flex w-1/2 flex-col border-r overflow-hidden\">\n        <div className=\"shrink-0 border-b px-3 py-1.5 text-[11px] font-medium text-muted-foreground\">\n          Wire Events\n          <span className=\"ml-1.5 font-mono text-[10px]\">({wireEvents.length})</span>\n        </div>\n        {wireError ? (\n          <div className=\"flex h-full items-center justify-center text-xs text-destructive\">\n            Error: {wireError}\n          </div>\n        ) : wireEvents.length === 0 ? (\n          <div className=\"flex h-full items-center justify-center text-xs text-muted-foreground\">\n            No wire events\n          </div>\n        ) : (\n          <Virtuoso\n            ref={wireVirtuosoRef}\n            data={wireEvents}\n            itemContent={(idx, event) => {\n              const tcId = getWireToolCallId(event);\n              const isHighlighted = tcId != null && tcId === highlightedToolCallId;\n              const isClickable = tcId != null;\n              const summary = getWireSummary(event);\n\n              return (\n                <div\n                  className={`flex items-center gap-1.5 border-b px-3 py-1 transition-all duration-300 ${\n                    isHighlighted\n                      ? \"ring-1 ring-blue-500/30 bg-blue-500/10\"\n                      : \"\"\n                  } ${isClickable ? \"cursor-pointer hover:bg-muted/50\" : \"\"}`}\n                  onClick={() => {\n                    if (tcId) handleHighlight(tcId, \"wire\");\n                  }}\n                >\n                  {/* Timestamp */}\n                  <span className=\"shrink-0 font-mono text-[10px] text-muted-foreground w-[72px]\">\n                    {formatTimestamp(event.timestamp)}\n                  </span>\n\n                  {/* Type badge */}\n                  <span\n                    className={`shrink-0 rounded px-1.5 py-0 text-[10px] font-medium ${\n                      TYPE_COLORS[event.type] ?? \"bg-secondary text-secondary-foreground\"\n                    }`}\n                  >\n                    {event.type}\n                  </span>\n\n                  {/* Summary */}\n                  {summary && (\n                    <span className=\"truncate text-[11px] text-muted-foreground\">\n                      {summary}\n                    </span>\n                  )}\n                </div>\n              );\n            }}\n          />\n        )}\n      </div>\n\n      {/* Right pane: Context Messages */}\n      <div className=\"flex w-1/2 flex-col overflow-hidden\">\n        <div className=\"shrink-0 border-b px-3 py-1.5 text-[11px] font-medium text-muted-foreground\">\n          Context Messages\n          <span className=\"ml-1.5 font-mono text-[10px]\">({contextMessages.length})</span>\n        </div>\n        {contextError ? (\n          <div className=\"flex h-full items-center justify-center text-xs text-destructive\">\n            Error: {contextError}\n          </div>\n        ) : contextMessages.length === 0 ? (\n          <div className=\"flex h-full items-center justify-center text-xs text-muted-foreground\">\n            No context messages\n          </div>\n        ) : (\n          <Virtuoso\n            ref={contextVirtuosoRef}\n            data={contextMessages}\n            itemContent={(_idx, message) => {\n              const tcIds = getContextToolCallIds(message);\n              const isHighlighted = tcIds.some(\n                (id) => id === highlightedToolCallId,\n              );\n              const isClickable = tcIds.length > 0;\n              const summary = getContextSummary(message);\n\n              return (\n                <div\n                  className={`flex items-center gap-1.5 border-b px-3 py-1 transition-all duration-300 ${\n                    isHighlighted\n                      ? \"ring-1 ring-blue-500/30 bg-blue-500/10\"\n                      : \"\"\n                  } ${isClickable ? \"cursor-pointer hover:bg-muted/50\" : \"\"}`}\n                  onClick={() => {\n                    if (tcIds.length > 0) handleHighlight(tcIds[0], \"context\");\n                  }}\n                >\n                  {/* Role badge */}\n                  <span\n                    className={`shrink-0 rounded px-1.5 py-0 text-[10px] font-medium ${\n                      ROLE_COLORS[message.role] ?? \"bg-secondary text-secondary-foreground\"\n                    }`}\n                  >\n                    {message.role}\n                  </span>\n\n                  {/* Tool name for tool messages */}\n                  {message.role === \"tool\" && message.name && (\n                    <span className=\"shrink-0 font-mono text-[10px] text-purple-600 dark:text-purple-400\">\n                      {message.name}\n                    </span>\n                  )}\n\n                  {/* Summary */}\n                  {summary && (\n                    <span className=\"truncate text-[11px] text-muted-foreground\">\n                      {summary}\n                    </span>\n                  )}\n                </div>\n              );\n            }}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/session-picker/session-picker.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { type SessionInfo, listSessions } from \"@/lib/api\";\nimport { Search } from \"lucide-react\";\n\ninterface SessionPickerProps {\n  value: string | null;\n  onChange: (sessionId: string | null) => void;\n}\n\nfunction formatTime(ts: number): string {\n  if (!ts) return \"\";\n  return new Date(ts * 1000).toLocaleString();\n}\n\nfunction shortId(id: string): string {\n  return id.slice(0, 8);\n}\n\nexport function SessionPicker({ value, onChange }: SessionPickerProps) {\n  const [sessions, setSessions] = useState<SessionInfo[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [search, setSearch] = useState(\"\");\n  const [open, setOpen] = useState(false);\n  const [inputValue, setInputValue] = useState(value ?? \"\");\n\n  useEffect(() => {\n    listSessions()\n      .then(setSessions)\n      .catch(console.error)\n      .finally(() => setLoading(false));\n  }, []);\n\n  useEffect(() => {\n    setInputValue(value ?? \"\");\n  }, [value]);\n\n  const filtered = useMemo(() => {\n    if (!search) return sessions;\n    const q = search.toLowerCase();\n    return sessions.filter(\n      (s) =>\n        s.session_id.toLowerCase().includes(q) ||\n        s.title.toLowerCase().includes(q) ||\n        (s.work_dir && s.work_dir.toLowerCase().includes(q)),\n    );\n  }, [sessions, search]);\n\n  const handleSelect = useCallback(\n    (sessionId: string) => {\n      onChange(sessionId);\n      setOpen(false);\n      setSearch(\"\");\n    },\n    [onChange],\n  );\n\n  const handleInputSubmit = useCallback(() => {\n    const trimmed = inputValue.trim();\n    if (trimmed) {\n      onChange(trimmed);\n    }\n    setOpen(false);\n  }, [inputValue, onChange]);\n\n  return (\n    <div className=\"relative\">\n      <div className=\"flex items-center gap-2\">\n        <label className=\"text-sm font-medium text-muted-foreground whitespace-nowrap\">\n          Session:\n        </label>\n        <div className=\"relative flex-1\">\n          <input\n            type=\"text\"\n            value={inputValue}\n            onChange={(e) => {\n              setInputValue(e.target.value);\n              setSearch(e.target.value);\n              setOpen(true);\n            }}\n            onFocus={() => setOpen(true)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                handleInputSubmit();\n              }\n              if (e.key === \"Escape\") {\n                setOpen(false);\n              }\n            }}\n            placeholder=\"Select or paste a session ID...\"\n            className=\"h-8 w-full rounded-md border bg-background px-3 pr-8 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring\"\n          />\n          <Search\n            size={14}\n            className=\"absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground\"\n          />\n        </div>\n      </div>\n\n      {open && (\n        <>\n          <div className=\"fixed inset-0 z-40\" onClick={() => setOpen(false)} />\n          <div className=\"absolute top-full left-0 right-0 z-50 mt-1 max-h-80 overflow-auto rounded-md border bg-popover shadow-lg\">\n            {loading && (\n              <div className=\"p-3 text-sm text-muted-foreground\">\n                Loading sessions...\n              </div>\n            )}\n            {!loading && filtered.length === 0 && (\n              <div className=\"p-3 text-sm text-muted-foreground\">\n                No sessions found\n              </div>\n            )}\n            {filtered.map((session) => (\n              <button\n                key={session.session_id}\n                onClick={() => handleSelect(session.session_id)}\n                className={`w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors ${\n                  session.session_id === value ? \"bg-accent\" : \"\"\n                }`}\n              >\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"font-mono text-xs text-muted-foreground\">\n                    {shortId(session.session_id)}\n                  </span>\n                  <span className=\"truncate flex-1\">\n                    {session.title || \"Untitled Session\"}\n                  </span>\n                  <span className=\"text-xs text-muted-foreground whitespace-nowrap\">\n                    {formatTime(session.last_updated)}\n                  </span>\n                </div>\n                {session.work_dir && (\n                  <div className=\"mt-0.5 truncate text-xs text-muted-foreground\">\n                    {session.work_dir}\n                  </div>\n                )}\n              </button>\n            ))}\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/sessions-explorer/explorer-toolbar.tsx",
    "content": "import { useRef } from \"react\";\nimport {\n  Search,\n  ArrowUpDown,\n  FolderOpen,\n  Import,\n  LayoutGrid,\n  List,\n  Loader2,\n  X,\n} from \"lucide-react\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\nexport type SortMode = \"time\" | \"turns\" | \"name\";\nexport type ViewMode = \"cards\" | \"compact\";\nexport type FilterMode = \"all\" | \"imported\";\n\nconst SORT_OPTIONS: { value: SortMode; label: string }[] = [\n  { value: \"time\", label: \"Recent\" },\n  { value: \"turns\", label: \"Turns\" },\n  { value: \"name\", label: \"Name\" },\n];\n\ninterface ExplorerToolbarProps {\n  search: string;\n  onSearchChange: (q: string) => void;\n  sortMode: SortMode;\n  onSortChange: (mode: SortMode) => void;\n  grouped: boolean;\n  onToggleGrouped: () => void;\n  viewMode: ViewMode;\n  onViewModeChange: (mode: ViewMode) => void;\n  filterMode: FilterMode;\n  onFilterModeChange: (mode: FilterMode) => void;\n  totalCount: number;\n  filteredCount: number;\n  onImport: (file: File) => void;\n  importing?: boolean;\n}\n\nexport function ExplorerToolbar({\n  search,\n  onSearchChange,\n  sortMode,\n  onSortChange,\n  grouped,\n  onToggleGrouped,\n  viewMode,\n  onViewModeChange,\n  filterMode,\n  onFilterModeChange,\n  totalCount,\n  filteredCount,\n  onImport,\n  importing,\n}: ExplorerToolbarProps) {\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  return (\n    <div className=\"border-b px-4 py-2\">\n      <div className=\"flex items-center gap-2\">\n        {/* Search */}\n        <div className=\"relative flex-1 max-w-sm\">\n          <Search\n            size={13}\n            className=\"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground\"\n          />\n          <input\n            value={search}\n            onChange={(e) => onSearchChange(e.target.value)}\n            placeholder=\"Search sessions...\"\n            data-session-search\n            className=\"w-full rounded border bg-background pl-7 pr-7 py-1 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring\"\n          />\n          {search && (\n            <button\n              onClick={() => onSearchChange(\"\")}\n              className=\"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n            >\n              <X size={12} />\n            </button>\n          )}\n        </div>\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        {/* Sort dropdown */}\n        <div className=\"flex items-center gap-1 text-muted-foreground\">\n          <ArrowUpDown size={12} className=\"shrink-0\" />\n          <Select value={sortMode} onValueChange={(v) => onSortChange(v as SortMode)}>\n            <SelectTrigger size=\"sm\" className=\"h-6 min-w-[5rem] border-none shadow-none px-1.5 py-0 text-[11px] gap-1\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {SORT_OPTIONS.map((opt) => (\n                <SelectItem key={opt.value} value={opt.value} className=\"text-xs\">\n                  {opt.label}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        {/* Imported filter toggle */}\n        <button\n          onClick={() => onFilterModeChange(filterMode === \"all\" ? \"imported\" : \"all\")}\n          className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] transition-colors ${\n            filterMode === \"imported\"\n              ? \"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30\"\n              : \"text-muted-foreground hover:bg-muted\"\n          }`}\n          title=\"Show imported sessions only\"\n        >\n          <Import size={12} />\n          Imported\n        </button>\n\n        {/* Group toggle */}\n        <button\n          onClick={onToggleGrouped}\n          className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] transition-colors ${\n            grouped\n              ? \"bg-primary/10 text-primary border-primary/30\"\n              : \"text-muted-foreground hover:bg-muted\"\n          }`}\n          title=\"Group by project\"\n        >\n          <FolderOpen size={12} />\n          Group\n        </button>\n\n        {/* View toggle */}\n        <button\n          onClick={() =>\n            onViewModeChange(viewMode === \"cards\" ? \"compact\" : \"cards\")\n          }\n          className=\"flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted transition-colors\"\n          title={viewMode === \"cards\" ? \"Switch to list\" : \"Switch to cards\"}\n        >\n          {viewMode === \"cards\" ? <List size={12} /> : <LayoutGrid size={12} />}\n        </button>\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        {/* Import button */}\n        <button\n          onClick={() => fileInputRef.current?.click()}\n          disabled={importing}\n          className=\"flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50\"\n          title=\"Import session from ZIP\"\n        >\n          {importing ? <Loader2 size={12} className=\"animate-spin\" /> : <Import size={12} />}\n          {importing ? \"Importing...\" : \"Import\"}\n        </button>\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\".zip\"\n          className=\"hidden\"\n          onChange={(e) => {\n            const file = e.target.files?.[0];\n            if (file) onImport(file);\n            e.target.value = \"\";\n          }}\n        />\n\n        {/* Count */}\n        <span className=\"text-[11px] text-muted-foreground ml-auto shrink-0\">\n          {filteredCount === totalCount\n            ? `${totalCount} sessions`\n            : `${filteredCount} / ${totalCount}`}\n        </span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/sessions-explorer/project-group.tsx",
    "content": "import { useState } from \"react\";\nimport { type SessionInfo } from \"@/lib/api\";\nimport { SessionCard } from \"./session-card\";\nimport { ChevronDown, ChevronRight, FolderOpen } from \"lucide-react\";\n\nfunction shortProjectName(workDir: string): string {\n  if (!workDir) return \"Unknown\";\n  const parts = workDir.replace(/\\/$/, \"\").split(\"/\");\n  return parts[parts.length - 1] || workDir;\n}\n\ninterface ProjectGroupProps {\n  workDir: string;\n  sessions: SessionInfo[];\n  onSelectSession: (sessionId: string) => void;\n  compact?: boolean;\n  searchQuery?: string;\n  onSessionDeleted?: (sessionId: string) => void;\n}\n\nexport function ProjectGroup({\n  workDir,\n  sessions,\n  onSelectSession,\n  compact,\n  searchQuery,\n  onSessionDeleted,\n}: ProjectGroupProps) {\n  const [collapsed, setCollapsed] = useState(false);\n\n  return (\n    <div className=\"mb-4\">\n      <button\n        onClick={() => setCollapsed((v) => !v)}\n        className=\"flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-muted transition-colors\"\n      >\n        {collapsed ? (\n          <ChevronRight size={14} className=\"shrink-0 text-muted-foreground\" />\n        ) : (\n          <ChevronDown size={14} className=\"shrink-0 text-muted-foreground\" />\n        )}\n        <FolderOpen size={14} className=\"shrink-0 text-muted-foreground\" />\n        <span className=\"text-sm font-medium truncate\">\n          {shortProjectName(workDir)}\n        </span>\n        <span className=\"text-[11px] text-muted-foreground shrink-0\">\n          ({sessions.length})\n        </span>\n        <span className=\"text-[10px] font-mono text-muted-foreground ml-auto truncate max-w-[300px] hidden md:block\">\n          {workDir}\n        </span>\n      </button>\n\n      {!collapsed && (\n        <div\n          className={\n            compact\n              ? \"mt-1 ml-6\"\n              : \"mt-2 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 ml-6\"\n          }\n        >\n          {sessions.map((s) => (\n            <SessionCard\n              key={`${s.session_id}-${s.work_dir_hash}`}\n              session={s}\n              onSelect={() => onSelectSession(`${s.work_dir_hash}/${s.session_id}`)}\n              compact={compact}\n              searchQuery={searchQuery}\n              onDeleted={onSessionDeleted}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/sessions-explorer/session-card.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport {\n  type SessionInfo,\n  type SessionSummary,\n  deleteSession,\n  getSessionDownloadUrl,\n  getSessionSummary,\n} from \"@/lib/api\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { AlertCircle, Clock, Download, RefreshCw, Trash2, Zap } from \"lucide-react\";\n\nfunction formatRelativeTime(epochSec: number): string {\n  if (!epochSec) return \"\";\n  const diff = Date.now() / 1000 - epochSec;\n  if (diff < 60) return \"just now\";\n  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;\n  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;\n  if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;\n  return new Date(epochSec * 1000).toLocaleDateString();\n}\n\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return \"0 B\";\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction formatDuration(sec: number): string {\n  if (sec < 1) return `${(sec * 1000).toFixed(0)}ms`;\n  if (sec < 60) return `${sec.toFixed(1)}s`;\n  return `${(sec / 60).toFixed(1)}min`;\n}\n\nfunction formatTokens(n: number): string {\n  if (n === 0) return \"0\";\n  if (n < 1000) return `${n}`;\n  return `${(n / 1000).toFixed(1)}k`;\n}\n\nfunction HighlightText({ text, query }: { text: string; query?: string }) {\n  if (!query || !text) return <>{text}</>;\n  const lowerText = text.toLowerCase();\n  const lowerQuery = query.toLowerCase();\n  const idx = lowerText.indexOf(lowerQuery);\n  if (idx === -1) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, idx)}\n      <mark className=\"bg-yellow-300/40 rounded px-0.5\">{text.slice(idx, idx + query.length)}</mark>\n      {text.slice(idx + query.length)}\n    </>\n  );\n}\n\ninterface SessionCardProps {\n  session: SessionInfo;\n  onSelect: () => void;\n  compact?: boolean;\n  searchQuery?: string;\n  onDeleted?: (sessionId: string) => void;\n}\n\nexport function SessionCard({ session, onSelect, compact, searchQuery, onDeleted }: SessionCardProps) {\n  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n  const [deleting, setDeleting] = useState(false);\n\n  const displayTitle =\n    session.metadata?.title && session.metadata.title !== \"Untitled Session\"\n      ? session.metadata.title\n      : session.title || \"Untitled Session\";\n\n  const sessionPath = `${session.work_dir_hash}/${session.session_id}`;\n  const downloadUrl = getSessionDownloadUrl(sessionPath);\n\n  const handleDownload = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    window.open(downloadUrl, \"_blank\", \"noopener,noreferrer\");\n  };\n\n  const handleDeleteClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    setDeleteDialogOpen(true);\n  };\n\n  const handleDeleteConfirm = () => {\n    setDeleting(true);\n    deleteSession(sessionPath)\n      .then(() => {\n        setDeleteDialogOpen(false);\n        onDeleted?.(session.session_id);\n      })\n      .catch((err) => alert(err instanceof Error ? err.message : \"Delete failed\"))\n      .finally(() => setDeleting(false));\n  };\n\n  const deleteDialog = session.imported ? (\n    <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>\n      <AlertDialogContent onClick={(e) => e.stopPropagation()}>\n        <AlertDialogHeader>\n          <AlertDialogTitle>Delete imported session?</AlertDialogTitle>\n          <AlertDialogDescription>\n            This will permanently delete the imported session\n            &quot;{displayTitle}&quot;. This action cannot be undone.\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>\n          <AlertDialogAction onClick={handleDeleteConfirm} disabled={deleting}>\n            {deleting ? \"Deleting...\" : \"Delete\"}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  ) : null;\n\n  if (compact) {\n    return (\n      <>\n        <button\n          onClick={onSelect}\n          className=\"flex items-center gap-3 w-full border-b px-3 py-2 text-left hover:bg-accent/50 transition-colors\"\n        >\n          <span className=\"font-mono text-[10px] text-muted-foreground w-16 shrink-0\">\n            {session.session_id.slice(0, 8)}\n          </span>\n          {session.imported && (\n            <span className=\"rounded bg-orange-500/10 text-orange-600 dark:text-orange-400 px-1 py-0 text-[9px] border border-orange-500/20 shrink-0\">\n              imported\n            </span>\n          )}\n          <span className=\"text-xs truncate flex-1\"><HighlightText text={displayTitle} query={searchQuery} /></span>\n          <LazyStats sessionId={sessionPath} hasWire={session.has_wire} inline />\n          <span className=\"text-[10px] text-muted-foreground shrink-0 w-14 text-right\">\n            {formatBytes(session.total_size)}\n          </span>\n          <span className=\"text-[10px] text-muted-foreground shrink-0 w-16 text-right\">\n            {formatRelativeTime(session.last_updated)}\n          </span>\n          <span\n            role=\"button\"\n            tabIndex={0}\n            onClick={handleDownload}\n            onKeyDown={(e) => { if (e.key === \"Enter\" || e.key === \" \") handleDownload(e as unknown as React.MouseEvent); }}\n            className=\"rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shrink-0\"\n            title=\"Download session files\"\n          >\n            <Download size={11} />\n          </span>\n          {session.imported && (\n            <span\n              role=\"button\"\n              tabIndex={0}\n              onClick={handleDeleteClick}\n              onKeyDown={(e) => { if (e.key === \"Enter\" || e.key === \" \") handleDeleteClick(e as unknown as React.MouseEvent); }}\n              className=\"rounded p-0.5 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors shrink-0\"\n              title=\"Delete imported session\"\n            >\n              <Trash2 size={11} />\n            </span>\n          )}\n        </button>\n        {deleteDialog}\n      </>\n    );\n  }\n\n  return (\n    <>\n      <button\n        onClick={onSelect}\n        className=\"rounded-lg border bg-card p-3 text-left hover:bg-accent/50 hover:border-primary/30 transition-colors w-full\"\n      >\n        {/* Row 1: ID + time + actions */}\n        <div className=\"flex items-center justify-between mb-1\">\n          <div className=\"flex items-center gap-1.5\">\n            <span className=\"font-mono text-[10px] text-muted-foreground\">\n              {session.session_id.slice(0, 8)}\n            </span>\n            {session.imported && (\n              <span className=\"rounded bg-orange-500/10 text-orange-600 dark:text-orange-400 px-1 py-0 text-[9px] border border-orange-500/20\">\n                imported\n              </span>\n            )}\n          </div>\n          <div className=\"flex items-center gap-1.5\">\n            <span\n              role=\"button\"\n              tabIndex={0}\n              onClick={handleDownload}\n              onKeyDown={(e) => { if (e.key === \"Enter\" || e.key === \" \") handleDownload(e as unknown as React.MouseEvent); }}\n              className=\"rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors\"\n              title=\"Download session files\"\n            >\n              <Download size={12} />\n            </span>\n            {session.imported && (\n              <span\n                role=\"button\"\n                tabIndex={0}\n                onClick={handleDeleteClick}\n                onKeyDown={(e) => { if (e.key === \"Enter\" || e.key === \" \") handleDeleteClick(e as unknown as React.MouseEvent); }}\n                className=\"rounded p-0.5 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors\"\n                title=\"Delete imported session\"\n              >\n                <Trash2 size={12} />\n              </span>\n            )}\n            <span className=\"text-[10px] text-muted-foreground\">\n              {formatRelativeTime(session.last_updated)}\n            </span>\n          </div>\n        </div>\n\n        {/* Row 2: Title */}\n        <div className=\"text-sm font-medium truncate mb-1.5\" title={displayTitle}>\n          <HighlightText text={displayTitle} query={searchQuery} />\n        </div>\n\n        {/* Row 3: Availability badges + file size */}\n        <div className=\"flex items-center gap-1 mb-2\">\n          {session.has_wire && (\n            <span className=\"rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 px-1.5 py-0 text-[10px] border border-blue-500/20\">\n              wire\n            </span>\n          )}\n          {session.has_context && (\n            <span className=\"rounded bg-green-500/10 text-green-600 dark:text-green-400 px-1.5 py-0 text-[10px] border border-green-500/20\">\n              context\n            </span>\n          )}\n          {session.has_state && (\n            <span className=\"rounded bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1.5 py-0 text-[10px] border border-purple-500/20\">\n              state\n            </span>\n          )}\n          <span className=\"text-[10px] text-muted-foreground ml-auto\">\n            {formatBytes(session.total_size)}\n          </span>\n        </div>\n\n        {/* Row 4+: Lazy-loaded stats */}\n        <LazyStats sessionId={sessionPath} hasWire={session.has_wire} />\n      </button>\n      {deleteDialog}\n    </>\n  );\n}\n\nfunction LazyStats({\n  sessionId,\n  hasWire,\n  inline,\n}: {\n  sessionId: string;\n  hasWire: boolean;\n  inline?: boolean;\n}) {\n  const [summary, setSummary] = useState<SessionSummary | null>(null);\n  const [loading, setLoading] = useState(false);\n  const ref = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!hasWire || !ref.current) return;\n\n    const el = ref.current;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setLoading(true);\n          getSessionSummary(sessionId)\n            .then(setSummary)\n            .catch((err) => { console.warn(`Failed to load summary for ${sessionId}:`, err); })\n            .finally(() => setLoading(false));\n          observer.disconnect();\n        }\n      },\n      { threshold: 0.1 },\n    );\n\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [sessionId, hasWire]);\n\n  if (!hasWire) {\n    return (\n      <div ref={ref} className=\"text-[10px] text-muted-foreground\">\n        No wire data\n      </div>\n    );\n  }\n\n  if (!summary) {\n    return (\n      <div ref={ref}>\n        {loading ? (\n          inline ? (\n            <div className=\"flex items-center gap-2 shrink-0\">\n              <div className=\"h-3 w-32 rounded bg-muted animate-pulse\" />\n            </div>\n          ) : (\n            <div className=\"border-t border-dashed pt-2 mt-1 space-y-1\">\n              <div className=\"h-3 w-full rounded bg-muted animate-pulse\" />\n              <div className=\"h-3 w-2/3 rounded bg-muted animate-pulse\" />\n            </div>\n          )\n        ) : (\n          <div className=\"h-4\" />\n        )}\n      </div>\n    );\n  }\n\n  if (inline) {\n    return (\n      <div\n        ref={ref}\n        className=\"flex items-center gap-2 text-[10px] text-muted-foreground shrink-0\"\n      >\n        <span className=\"text-blue-600 dark:text-blue-400\">{summary.turns}T</span>\n        <span className=\"text-green-600 dark:text-green-400\">{summary.steps}S</span>\n        <span className=\"text-purple-600 dark:text-purple-400\">{summary.tool_calls}TC</span>\n        {summary.errors > 0 && (\n          <span className=\"text-red-500 font-medium\">{summary.errors}E</span>\n        )}\n        <span>{formatTokens(summary.input_tokens + summary.output_tokens)}</span>\n        <span>{formatDuration(summary.duration_sec)}</span>\n      </div>\n    );\n  }\n\n  return (\n    <div ref={ref} className=\"border-t border-dashed pt-2 mt-1\">\n      <div className=\"flex items-center gap-1.5 text-[10px] text-muted-foreground flex-wrap\">\n        <span className=\"text-blue-600 dark:text-blue-400\">\n          {summary.turns} turn{summary.turns !== 1 ? \"s\" : \"\"}\n        </span>\n        <span className=\"opacity-30\">·</span>\n        <span className=\"text-green-600 dark:text-green-400\">\n          {summary.steps} step{summary.steps !== 1 ? \"s\" : \"\"}\n        </span>\n        <span className=\"opacity-30\">·</span>\n        <span className=\"text-purple-600 dark:text-purple-400\">\n          {summary.tool_calls} tool{summary.tool_calls !== 1 ? \"s\" : \"\"}\n        </span>\n        {summary.compactions > 0 && (\n          <>\n            <span className=\"opacity-30\">·</span>\n            <span className=\"text-orange-600 dark:text-orange-400 inline-flex items-center gap-0.5\">\n              <RefreshCw size={9} />\n              {summary.compactions}\n            </span>\n          </>\n        )}\n      </div>\n      <div className=\"flex items-center gap-1.5 text-[10px] text-muted-foreground mt-1\">\n        <span className=\"inline-flex items-center gap-0.5\">\n          <Clock size={9} />\n          {formatDuration(summary.duration_sec)}\n        </span>\n        {(summary.input_tokens > 0 || summary.output_tokens > 0) && (\n          <>\n            <span className=\"opacity-30\">·</span>\n            <span className=\"inline-flex items-center gap-0.5\">\n              <Zap size={9} />\n              {formatTokens(summary.input_tokens)} in / {formatTokens(summary.output_tokens)} out\n            </span>\n          </>\n        )}\n        {summary.errors > 0 && (\n          <>\n            <span className=\"opacity-30\">·</span>\n            <span className=\"text-red-500 font-medium inline-flex items-center gap-0.5\">\n              <AlertCircle size={9} />\n              {summary.errors} error{summary.errors !== 1 ? \"s\" : \"\"}\n            </span>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/sessions-explorer/sessions-explorer.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\nimport { type SessionInfo, importSession, listSessions } from \"@/lib/api\";\nimport {\n  ExplorerToolbar,\n  type FilterMode,\n  type SortMode,\n  type ViewMode,\n} from \"./explorer-toolbar\";\nimport { ProjectGroup } from \"./project-group\";\nimport { SessionCard } from \"./session-card\";\n\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return \"0 B\";\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\ninterface SessionsExplorerProps {\n  onSelectSession: (sessionId: string) => void;\n}\n\ninterface ProjectGroupData {\n  workDir: string;\n  sessions: SessionInfo[];\n}\n\nexport function SessionsExplorer({ onSelectSession }: SessionsExplorerProps) {\n  const [sessions, setSessions] = useState<SessionInfo[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [search, setSearch] = useState(\"\");\n  const [sortMode, setSortMode] = useState<SortMode>(\"time\");\n  const [grouped, setGrouped] = useState(true);\n  const [viewMode, setViewMode] = useState<ViewMode>(\"cards\");\n  const [filterMode, setFilterMode] = useState<FilterMode>(\"all\");\n  const [importing, setImporting] = useState(false);\n\n  const refreshSessions = async () => {\n    try {\n      const updated = await listSessions(true);\n      setSessions(updated);\n    } catch (err) {\n      console.error(err);\n    }\n  };\n\n  useEffect(() => {\n    listSessions()\n      .then(setSessions)\n      .catch(console.error)\n      .finally(() => setLoading(false));\n  }, []);\n\n  // Keyboard: / to focus search\n  useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      const tag = (e.target as HTMLElement)?.tagName;\n      if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n      if (e.key === \"/\") {\n        e.preventDefault();\n        const input = document.querySelector(\"[data-session-search]\") as HTMLInputElement | null;\n        input?.focus();\n      }\n    };\n    window.addEventListener(\"keydown\", handler);\n    return () => window.removeEventListener(\"keydown\", handler);\n  }, []);\n\n  const handleImport = async (file: File) => {\n    setImporting(true);\n    try {\n      await importSession(file);\n      await refreshSessions();\n    } catch (err) {\n      console.error(\"Import failed:\", err);\n      alert(err instanceof Error ? err.message : \"Import failed\");\n    } finally {\n      setImporting(false);\n    }\n  };\n\n  const handleSessionDeleted = (deletedSessionId: string) => {\n    // Optimistic removal from local state\n    setSessions((prev) => prev.filter((s) => s.session_id !== deletedSessionId));\n    // Then refresh from server to ensure consistency\n    refreshSessions();\n  };\n\n  const filtered = useMemo(() => {\n    let result = sessions;\n\n    // Apply imported filter\n    if (filterMode === \"imported\") {\n      result = result.filter((s) => s.imported);\n    }\n\n    // Apply search filter\n    if (search) {\n      const q = search.toLowerCase();\n      result = result.filter(\n        (s) =>\n          s.session_id.toLowerCase().includes(q) ||\n          s.title.toLowerCase().includes(q) ||\n          (s.work_dir && s.work_dir.toLowerCase().includes(q)),\n      );\n    }\n\n    return result;\n  }, [sessions, search, filterMode]);\n\n  const sorted = useMemo(() => {\n    const arr = [...filtered];\n    if (sortMode === \"time\") {\n      arr.sort((a, b) => b.last_updated - a.last_updated);\n    } else if (sortMode === \"turns\") {\n      arr.sort((a, b) => b.turns - a.turns);\n    } else if (sortMode === \"name\") {\n      arr.sort((a, b) => (a.title || \"\").localeCompare(b.title || \"\"));\n    }\n    return arr;\n  }, [filtered, sortMode]);\n\n  const groups = useMemo((): ProjectGroupData[] => {\n    if (!grouped) return [];\n    const map = new Map<string, SessionInfo[]>();\n    for (const s of sorted) {\n      const key = s.imported ? \"Imported\" : (s.work_dir ?? \"Unknown\");\n      const list = map.get(key);\n      if (list) {\n        list.push(s);\n      } else {\n        map.set(key, [s]);\n      }\n    }\n    return Array.from(map.entries()).map(([workDir, sessions]) => ({\n      workDir,\n      sessions,\n    }));\n  }, [sorted, grouped]);\n\n  const uniqueProjects = useMemo(() => {\n    const dirs = new Set<string>();\n    for (const s of sessions) dirs.add(s.work_dir ?? \"Unknown\");\n    return dirs.size;\n  }, [sessions]);\n\n  const totalSize = useMemo(\n    () => sessions.reduce((sum, s) => sum + s.total_size, 0),\n    [sessions],\n  );\n\n  if (loading) {\n    return (\n      <div className=\"flex h-full flex-col\">\n        {/* Skeleton toolbar */}\n        <div className=\"border-b px-4 py-2\">\n          <div className=\"flex items-center gap-2\">\n            <div className=\"h-6 w-48 rounded bg-muted animate-pulse\" />\n            <div className=\"h-4 w-px bg-border\" />\n            <div className=\"h-6 w-20 rounded bg-muted animate-pulse\" />\n            <div className=\"h-6 w-16 rounded bg-muted animate-pulse\" />\n          </div>\n        </div>\n        {/* Skeleton cards */}\n        <div className=\"flex-1 overflow-auto p-4\">\n          {/* Skeleton group header */}\n          <div className=\"flex items-center gap-2 px-2 py-1.5 mb-2\">\n            <div className=\"h-4 w-4 rounded bg-muted animate-pulse\" />\n            <div className=\"h-4 w-32 rounded bg-muted animate-pulse\" />\n            <div className=\"h-3 w-16 rounded bg-muted animate-pulse\" />\n          </div>\n          <div className=\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 ml-6\">\n            {[0, 1, 2, 3, 4, 5].map((i) => (\n              <SkeletonCard key={i} delay={i * 75} />\n            ))}\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <ExplorerToolbar\n        search={search}\n        onSearchChange={setSearch}\n        sortMode={sortMode}\n        onSortChange={setSortMode}\n        grouped={grouped}\n        onToggleGrouped={() => setGrouped((v) => !v)}\n        viewMode={viewMode}\n        onViewModeChange={setViewMode}\n        filterMode={filterMode}\n        onFilterModeChange={setFilterMode}\n        totalCount={sessions.length}\n        filteredCount={filtered.length}\n        onImport={handleImport}\n        importing={importing}\n      />\n\n      <div className=\"flex-1 overflow-auto p-4\">\n        {grouped ? (\n          groups.map((g) => (\n            <ProjectGroup\n              key={g.workDir}\n              workDir={g.workDir}\n              sessions={g.sessions}\n              onSelectSession={onSelectSession}\n              compact={viewMode === \"compact\"}\n              searchQuery={search}\n              onSessionDeleted={handleSessionDeleted}\n            />\n          ))\n        ) : viewMode === \"compact\" ? (\n          <div>\n            {sorted.map((s) => (\n              <SessionCard\n                key={`${s.session_id}-${s.work_dir_hash}`}\n                session={s}\n                onSelect={() => onSelectSession(`${s.work_dir_hash}/${s.session_id}`)}\n                compact\n                searchQuery={search}\n                onDeleted={handleSessionDeleted}\n              />\n            ))}\n          </div>\n        ) : (\n          <div className=\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3\">\n            {sorted.map((s) => (\n              <SessionCard\n                key={`${s.session_id}-${s.work_dir_hash}`}\n                session={s}\n                onSelect={() => onSelectSession(`${s.work_dir_hash}/${s.session_id}`)}\n                searchQuery={search}\n                onDeleted={handleSessionDeleted}\n              />\n            ))}\n          </div>\n        )}\n\n        {filtered.length === 0 && search && (\n          <div className=\"flex items-center justify-center text-muted-foreground text-sm py-12\">\n            No sessions matching &quot;{search}&quot;\n          </div>\n        )}\n\n        {filtered.length === 0 && !search && filterMode === \"imported\" && (\n          <div className=\"flex flex-col items-center justify-center text-muted-foreground text-sm py-12 gap-2\">\n            <span>No imported sessions</span>\n            <span className=\"text-xs\">Import a session ZIP to get started.</span>\n          </div>\n        )}\n\n        {sessions.length === 0 && !search && filterMode === \"all\" && (\n          <div className=\"flex flex-col items-center justify-center text-muted-foreground py-12 gap-2\">\n            <span className=\"text-lg\">No sessions found</span>\n            <span className=\"text-sm\">\n              Run <code className=\"font-mono bg-muted px-1.5 py-0.5 rounded\">kimi</code> to create your first session, or import a session ZIP.\n            </span>\n          </div>\n        )}\n      </div>\n\n      {/* Footer */}\n      <div className=\"border-t px-4 py-1.5 text-[11px] text-muted-foreground flex items-center gap-2\">\n        <span>{sessions.length} sessions</span>\n        <span className=\"opacity-30\">·</span>\n        <span>{uniqueProjects} project{uniqueProjects !== 1 ? \"s\" : \"\"}</span>\n        {totalSize > 0 && (\n          <>\n            <span className=\"opacity-30\">·</span>\n            <span>{formatBytes(totalSize)} total</span>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction SkeletonCard({ delay = 0 }: { delay?: number }) {\n  return (\n    <div\n      className=\"rounded-lg border bg-card p-3 space-y-2\"\n      style={{ animationDelay: `${delay}ms` }}\n    >\n      {/* ID + time */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"h-3 w-14 rounded bg-muted animate-pulse\" />\n        <div className=\"h-3 w-12 rounded bg-muted animate-pulse\" />\n      </div>\n      {/* Title */}\n      <div className=\"h-4 w-3/4 rounded bg-muted animate-pulse\" />\n      {/* Badges */}\n      <div className=\"flex items-center gap-1\">\n        <div className=\"h-4 w-10 rounded bg-muted animate-pulse\" />\n        <div className=\"h-4 w-14 rounded bg-muted animate-pulse\" />\n        <div className=\"h-4 w-10 rounded bg-muted animate-pulse\" />\n      </div>\n      {/* Stats */}\n      <div className=\"border-t border-dashed pt-2\">\n        <div className=\"h-3 w-full rounded bg-muted animate-pulse\" />\n        <div className=\"h-3 w-2/3 rounded bg-muted animate-pulse mt-1\" />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/state-viewer/state-viewer.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { getSessionState } from \"@/lib/api\";\nimport {\n  ChevronDown,\n  ChevronRight,\n  Shield,\n  Users,\n  FolderOpen,\n} from \"lucide-react\";\n\ninterface StateViewerProps {\n  sessionId: string;\n  refreshKey?: number;\n}\n\nfunction JsonValue({\n  value,\n  depth = 0,\n}: {\n  value: unknown;\n  depth?: number;\n}) {\n  const [expanded, setExpanded] = useState(depth < 2);\n\n  if (value === null) {\n    return <span className=\"text-muted-foreground italic\">null</span>;\n  }\n\n  if (typeof value === \"boolean\") {\n    return (\n      <span className={value ? \"text-green-600 dark:text-green-400\" : \"text-red-500 dark:text-red-400\"}>\n        {String(value)}\n      </span>\n    );\n  }\n\n  if (typeof value === \"number\") {\n    return <span className=\"text-blue-600 dark:text-blue-400\">{value}</span>;\n  }\n\n  if (typeof value === \"string\") {\n    return (\n      <span className=\"text-amber-700 dark:text-amber-300\">\n        &quot;{value}&quot;\n      </span>\n    );\n  }\n\n  if (Array.isArray(value)) {\n    if (value.length === 0) {\n      return <span className=\"text-muted-foreground\">[]</span>;\n    }\n    return (\n      <div>\n        <button\n          onClick={() => setExpanded(!expanded)}\n          className=\"inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground\"\n        >\n          {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}\n          <span className=\"text-xs\">[{value.length}]</span>\n        </button>\n        {expanded && (\n          <div className=\"ml-4 border-l border-border pl-3\">\n            {value.map((item, i) => (\n              <div key={i} className=\"py-0.5\">\n                <span className=\"text-muted-foreground text-[11px] mr-2\">\n                  {i}:\n                </span>\n                <JsonValue value={item} depth={depth + 1} />\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  if (typeof value === \"object\") {\n    const entries = Object.entries(value as Record<string, unknown>);\n    if (entries.length === 0) {\n      return <span className=\"text-muted-foreground\">{\"{}\"}</span>;\n    }\n    return (\n      <div>\n        <button\n          onClick={() => setExpanded(!expanded)}\n          className=\"inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground\"\n        >\n          {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}\n          <span className=\"text-xs\">{`{${entries.length}}`}</span>\n        </button>\n        {expanded && (\n          <div className=\"ml-4 border-l border-border pl-3\">\n            {entries.map(([key, val]) => (\n              <div key={key} className=\"py-0.5\">\n                <span className=\"font-medium text-xs\">{key}: </span>\n                <JsonValue value={val} depth={depth + 1} />\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  return <span>{String(value)}</span>;\n}\n\nexport function StateViewer({ sessionId, refreshKey = 0 }: StateViewerProps) {\n  const [state, setState] = useState<Record<string, unknown> | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(null);\n    getSessionState(sessionId, refreshKey > 0)\n      .then(setState)\n      .catch((err) => setError(err.message))\n      .finally(() => setLoading(false));\n  }, [sessionId, refreshKey]);\n\n  if (loading) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        Loading state...\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-destructive\">\n        Error: {error}\n      </div>\n    );\n  }\n\n  if (!state || Object.keys(state).length === 0) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        No state data found\n      </div>\n    );\n  }\n\n  const approval = state.approval as Record<string, unknown> | undefined;\n  const subagents = state.dynamic_subagents as unknown[] | undefined;\n  const additionalDirs = state.additional_dirs as string[] | undefined;\n\n  return (\n    <div className=\"h-full overflow-auto p-4 space-y-4\">\n      {/* Summary cards */}\n      <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3\">\n        {/* Approval */}\n        <div className=\"rounded-lg border bg-card p-4\">\n          <div className=\"flex items-center gap-2 mb-2\">\n            <Shield size={16} className=\"text-muted-foreground\" />\n            <h3 className=\"text-sm font-semibold\">Approval</h3>\n          </div>\n          {approval && (\n            <div className=\"space-y-1.5 text-xs\">\n              <div className=\"flex items-center justify-between\">\n                <span className=\"text-muted-foreground\">YOLO Mode</span>\n                <span\n                  className={`font-medium ${approval.yolo ? \"text-green-600 dark:text-green-400\" : \"text-muted-foreground\"}`}\n                >\n                  {approval.yolo ? \"ON\" : \"OFF\"}\n                </span>\n              </div>\n              <div>\n                <span className=\"text-muted-foreground\">\n                  Auto-approved actions:\n                </span>\n                {Array.isArray(approval.auto_approve_actions) &&\n                approval.auto_approve_actions.length > 0 ? (\n                  <div className=\"mt-1 flex flex-wrap gap-1\">\n                    {(approval.auto_approve_actions as string[]).map((a) => (\n                      <span\n                        key={a}\n                        className=\"rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono\"\n                      >\n                        {a}\n                      </span>\n                    ))}\n                  </div>\n                ) : (\n                  <span className=\"ml-1 text-muted-foreground\">none</span>\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n\n        {/* Subagents */}\n        <div className=\"rounded-lg border bg-card p-4\">\n          <div className=\"flex items-center gap-2 mb-2\">\n            <Users size={16} className=\"text-muted-foreground\" />\n            <h3 className=\"text-sm font-semibold\">Dynamic Subagents</h3>\n          </div>\n          <div className=\"text-xs\">\n            {subagents && subagents.length > 0 ? (\n              <div className=\"space-y-1\">\n                {subagents.map((sa, i) => {\n                  const agent = sa as Record<string, unknown>;\n                  return (\n                    <div\n                      key={i}\n                      className=\"rounded bg-secondary px-2 py-1 font-mono\"\n                    >\n                      {String(agent.name ?? \"unnamed\")}\n                    </div>\n                  );\n                })}\n              </div>\n            ) : (\n              <span className=\"text-muted-foreground\">No subagents</span>\n            )}\n          </div>\n        </div>\n\n        {/* Additional Dirs */}\n        <div className=\"rounded-lg border bg-card p-4\">\n          <div className=\"flex items-center gap-2 mb-2\">\n            <FolderOpen size={16} className=\"text-muted-foreground\" />\n            <h3 className=\"text-sm font-semibold\">Additional Dirs</h3>\n          </div>\n          <div className=\"text-xs\">\n            {additionalDirs && additionalDirs.length > 0 ? (\n              <div className=\"space-y-1\">\n                {additionalDirs.map((d) => (\n                  <div key={d} className=\"truncate font-mono text-muted-foreground\">\n                    {d}\n                  </div>\n                ))}\n              </div>\n            ) : (\n              <span className=\"text-muted-foreground\">None</span>\n            )}\n          </div>\n        </div>\n      </div>\n\n      {/* Full JSON tree */}\n      <div className=\"rounded-lg border bg-card p-4\">\n        <h3 className=\"text-sm font-semibold mb-3\">Raw State</h3>\n        <div className=\"font-mono text-xs\">\n          <JsonValue value={state} />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/statistics/statistics-view.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { type AggregateStats, getAggregateStats } from \"@/lib/api\";\n\n/* ------------------------------------------------------------------ */\n/*  Formatting helpers                                                 */\n/* ------------------------------------------------------------------ */\n\nfunction formatTokens(n: number): string {\n  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;\n  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;\n  return String(n);\n}\n\nfunction formatDuration(sec: number): string {\n  if (sec < 60) return `${sec.toFixed(0)}s`;\n  if (sec < 3600) return `${(sec / 60).toFixed(1)}min`;\n  return `${(sec / 3600).toFixed(1)}h`;\n}\n\n/* ------------------------------------------------------------------ */\n/*  Summary Cards                                                      */\n/* ------------------------------------------------------------------ */\n\nfunction SummaryCard({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"rounded-lg border p-4 flex flex-col gap-1\">\n      <span className=\"text-2xl font-bold tabular-nums\">{value}</span>\n      <span className=\"text-xs text-muted-foreground\">{label}</span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  Daily Usage Chart (SVG line chart)                                 */\n/* ------------------------------------------------------------------ */\n\nconst CHART_WIDTH = 600;\nconst CHART_HEIGHT = 120;\nconst CHART_PAD_X = 40;\nconst CHART_PAD_TOP = 12;\nconst CHART_PAD_BOTTOM = 24;\n\nfunction DailyUsageChart({\n  daily,\n}: {\n  daily: AggregateStats[\"daily_usage\"];\n}) {\n  if (daily.length === 0) return null;\n\n  const maxSessions = Math.max(1, ...daily.map((d) => d.sessions));\n  const maxTurns = Math.max(1, ...daily.map((d) => d.turns));\n\n  const innerW = CHART_WIDTH - CHART_PAD_X * 2;\n  const innerH = CHART_HEIGHT - CHART_PAD_TOP - CHART_PAD_BOTTOM;\n\n  const toX = (i: number) =>\n    CHART_PAD_X + (daily.length > 1 ? (i / (daily.length - 1)) * innerW : innerW / 2);\n  const toYSessions = (v: number) =>\n    CHART_PAD_TOP + (1 - v / maxSessions) * innerH;\n  const toYTurns = (v: number) =>\n    CHART_PAD_TOP + (1 - v / maxTurns) * innerH;\n\n  // Sessions line\n  const sessionsPath = daily\n    .map((d, i) => `${i === 0 ? \"M\" : \"L\"} ${toX(i)} ${toYSessions(d.sessions)}`)\n    .join(\" \");\n\n  // Turns line\n  const turnsPath = daily\n    .map((d, i) => `${i === 0 ? \"M\" : \"L\"} ${toX(i)} ${toYTurns(d.turns)}`)\n    .join(\" \");\n\n  // Sessions area fill\n  const sessionsArea =\n    sessionsPath +\n    ` L ${toX(daily.length - 1)} ${toYSessions(0)}` +\n    ` L ${toX(0)} ${toYSessions(0)} Z`;\n\n  // X-axis labels: show ~5 evenly spaced dates\n  const labelCount = Math.min(5, daily.length);\n  const labelIndices: number[] = [];\n  if (labelCount <= 1) {\n    if (daily.length > 0) labelIndices.push(0);\n  } else {\n    for (let i = 0; i < labelCount; i++) {\n      labelIndices.push(\n        Math.round((i / (labelCount - 1)) * (daily.length - 1)),\n      );\n    }\n  }\n\n  return (\n    <div className=\"rounded-lg border p-4\">\n      <div className=\"flex items-center gap-2 mb-2\">\n        <span className=\"text-sm font-medium\">Daily Usage (Last 30 Days)</span>\n      </div>\n\n      <svg\n        viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}\n        className=\"w-full\"\n        style={{ maxHeight: CHART_HEIGHT }}\n      >\n        {/* Sessions area fill */}\n        <path d={sessionsArea} className=\"fill-blue-500/10\" />\n\n        {/* Sessions line */}\n        <path\n          d={sessionsPath}\n          className=\"stroke-blue-500\"\n          strokeWidth={1.5}\n          fill=\"none\"\n        />\n\n        {/* Turns line */}\n        <path\n          d={turnsPath}\n          className=\"stroke-green-500\"\n          strokeWidth={1.5}\n          fill=\"none\"\n          strokeDasharray=\"4 2\"\n        />\n\n        {/* Y-axis labels */}\n        <text\n          x={CHART_PAD_X - 4}\n          y={CHART_PAD_TOP + 4}\n          className=\"fill-muted-foreground\"\n          fontSize={8}\n          textAnchor=\"end\"\n        >\n          {maxSessions}\n        </text>\n        <text\n          x={CHART_PAD_X - 4}\n          y={CHART_PAD_TOP + innerH + 3}\n          className=\"fill-muted-foreground\"\n          fontSize={8}\n          textAnchor=\"end\"\n        >\n          0\n        </text>\n\n        {/* X-axis date labels */}\n        {labelIndices.map((idx) => (\n          <text\n            key={idx}\n            x={toX(idx)}\n            y={CHART_HEIGHT - 4}\n            className=\"fill-muted-foreground\"\n            fontSize={8}\n            textAnchor=\"middle\"\n          >\n            {daily[idx].date.slice(5)}\n          </text>\n        ))}\n\n        {/* Data dots for sessions */}\n        {daily.map((d, i) =>\n          d.sessions > 0 ? (\n            <circle\n              key={i}\n              cx={toX(i)}\n              cy={toYSessions(d.sessions)}\n              r={2}\n              className=\"fill-blue-500\"\n            />\n          ) : null,\n        )}\n      </svg>\n\n      {/* Legend */}\n      <div className=\"flex items-center gap-4 mt-2\">\n        <div className=\"flex items-center gap-1\">\n          <div className=\"w-3 h-0.5 bg-blue-500 rounded\" />\n          <span className=\"text-[10px] text-muted-foreground\">Sessions</span>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <div className=\"w-3 h-0.5 bg-green-500 rounded border-dashed\" />\n          <span className=\"text-[10px] text-muted-foreground\">Turns</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  Tool Usage Bar Chart                                               */\n/* ------------------------------------------------------------------ */\n\nfunction ToolUsageChart({\n  tools,\n}: {\n  tools: AggregateStats[\"tool_usage\"];\n}) {\n  if (tools.length === 0) return null;\n\n  const maxCount = tools[0].count;\n\n  return (\n    <div className=\"rounded-lg border p-4\">\n      <div className=\"flex items-center gap-2 mb-2\">\n        <span className=\"text-sm font-medium\">Tool Usage (Top 20)</span>\n      </div>\n\n      <div className=\"flex flex-col gap-1\">\n        {tools.map((tool) => {\n          const barPct = maxCount > 0 ? (tool.count / maxCount) * 100 : 0;\n          const errorPct =\n            tool.count > 0 ? (tool.error_count / tool.count) * 100 : 0;\n          const successPct = 100 - errorPct;\n\n          return (\n            <div key={tool.name} className=\"flex items-center gap-2\">\n              <div className=\"w-[140px] shrink-0 text-right pr-1\">\n                <span className=\"text-[11px] text-foreground truncate\">\n                  {tool.name}\n                </span>\n              </div>\n\n              <div className=\"flex-1 h-3 bg-muted/30 rounded-sm overflow-hidden\">\n                <div\n                  className=\"h-full flex rounded-sm\"\n                  style={{ width: `${barPct}%` }}\n                >\n                  <div\n                    className=\"h-full bg-blue-500/70\"\n                    style={{ width: `${successPct}%` }}\n                  />\n                  {tool.error_count > 0 && (\n                    <div\n                      className=\"h-full bg-red-500/70\"\n                      style={{ width: `${errorPct}%` }}\n                    />\n                  )}\n                </div>\n              </div>\n\n              <span className=\"w-[64px] shrink-0 text-[10px] text-muted-foreground text-right tabular-nums\">\n                {tool.count}\n                {tool.error_count > 0 && (\n                  <span className=\"text-red-500 ml-1\">\n                    ({tool.error_count})\n                  </span>\n                )}\n              </span>\n            </div>\n          );\n        })}\n      </div>\n\n      {/* Legend */}\n      <div className=\"flex items-center gap-3 mt-2\">\n        <div className=\"flex items-center gap-1\">\n          <div className=\"w-2 h-2 rounded-sm bg-blue-500/70\" />\n          <span className=\"text-[10px] text-muted-foreground\">Success</span>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <div className=\"w-2 h-2 rounded-sm bg-red-500/70\" />\n          <span className=\"text-[10px] text-muted-foreground\">Error</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  Per-Project Table                                                  */\n/* ------------------------------------------------------------------ */\n\nfunction ProjectTable({\n  projects,\n}: {\n  projects: AggregateStats[\"per_project\"];\n}) {\n  if (projects.length === 0) return null;\n\n  return (\n    <div className=\"rounded-lg border p-4\">\n      <div className=\"flex items-center gap-2 mb-2\">\n        <span className=\"text-sm font-medium\">Top Projects</span>\n      </div>\n\n      <table className=\"w-full text-sm\">\n        <thead>\n          <tr className=\"border-b text-xs text-muted-foreground\">\n            <th className=\"text-left py-1.5 font-medium\">Project</th>\n            <th className=\"text-right py-1.5 font-medium w-[80px]\">Sessions</th>\n            <th className=\"text-right py-1.5 font-medium w-[80px]\">Turns</th>\n          </tr>\n        </thead>\n        <tbody>\n          {projects.map((p) => {\n            const segments = p.work_dir.split(\"/\");\n            const shortName = segments[segments.length - 1] || p.work_dir;\n            return (\n              <tr key={p.work_dir} className=\"border-b last:border-b-0\">\n                <td\n                  className=\"py-1.5 truncate max-w-[300px]\"\n                  title={p.work_dir}\n                >\n                  {shortName}\n                </td>\n                <td className=\"py-1.5 text-right tabular-nums\">\n                  {p.sessions}\n                </td>\n                <td className=\"py-1.5 text-right tabular-nums\">{p.turns}</td>\n              </tr>\n            );\n          })}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  Main StatisticsView                                                */\n/* ------------------------------------------------------------------ */\n\nexport function StatisticsView() {\n  const [stats, setStats] = useState<AggregateStats | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(null);\n    getAggregateStats()\n      .then(setStats)\n      .catch((err) => setError(err instanceof Error ? err.message : String(err)))\n      .finally(() => setLoading(false));\n  }, []);\n\n  if (loading) {\n    return (\n      <div className=\"flex-1 overflow-auto p-4 space-y-4\">\n        {/* Skeleton cards */}\n        <div className=\"grid grid-cols-2 md:grid-cols-4 gap-3\">\n          {[0, 1, 2, 3].map((i) => (\n            <div key={i} className=\"rounded-lg border p-4 space-y-2\">\n              <div className=\"h-7 w-20 rounded bg-muted animate-pulse\" />\n              <div className=\"h-3 w-16 rounded bg-muted animate-pulse\" />\n            </div>\n          ))}\n        </div>\n        <div className=\"h-[160px] rounded-lg border bg-muted/30 animate-pulse\" />\n        <div className=\"h-[200px] rounded-lg border bg-muted/30 animate-pulse\" />\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <div className=\"flex-1 flex items-center justify-center text-red-500 text-sm\">\n        Failed to load statistics: {error}\n      </div>\n    );\n  }\n\n  if (!stats) return null;\n\n  const totalTokens = stats.total_tokens.input + stats.total_tokens.output;\n\n  return (\n    <div className=\"flex-1 overflow-auto p-4 space-y-4\">\n      {/* Summary Cards */}\n      <div className=\"grid grid-cols-2 md:grid-cols-4 gap-3\">\n        <SummaryCard label=\"Total Sessions\" value={String(stats.total_sessions)} />\n        <SummaryCard label=\"Total Turns\" value={String(stats.total_turns)} />\n        <SummaryCard\n          label=\"Total Tokens\"\n          value={formatTokens(totalTokens)}\n        />\n        <SummaryCard\n          label=\"Total Duration\"\n          value={formatDuration(stats.total_duration_sec)}\n        />\n      </div>\n\n      {/* Token detail */}\n      <div className=\"text-xs text-muted-foreground px-1\">\n        Tokens: {formatTokens(stats.total_tokens.input)} input / {formatTokens(stats.total_tokens.output)} output\n      </div>\n\n      {/* Daily Usage Chart */}\n      <DailyUsageChart daily={stats.daily_usage} />\n\n      {/* Tool Usage + Project Table side by side on wide screens */}\n      <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n        <ToolUsageChart tools={stats.tool_usage} />\n        <ProjectTable projects={stats.per_project} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/decision-path.tsx",
    "content": "import { useMemo, useState } from \"react\";\nimport { type WireEvent } from \"@/lib/api\";\nimport {\n  Brain,\n  Wrench,\n  CheckCircle,\n  XCircle,\n  ChevronDown,\n  ChevronRight,\n} from \"lucide-react\";\n\ninterface DecisionStep {\n  thinkingEventIndices: number[];\n  thinkingSummary: string;\n  toolCallEvent: WireEvent;\n  toolCallName: string;\n  toolCallArgsSummary: string;\n  toolResultEvent: WireEvent | null;\n  isError: boolean;\n  durationSec: number;\n}\n\ninterface DecisionChain {\n  turnNumber: number;\n  turnEventIndex: number;\n  userInput: string;\n  steps: DecisionStep[];\n}\n\ninterface DecisionPathProps {\n  events: WireEvent[];\n  onScrollToIndex: (idx: number) => void;\n}\n\nfunction extractDecisionChains(events: WireEvent[]): DecisionChain[] {\n  const chains: DecisionChain[] = [];\n\n  // Build a map from tool_call_id -> ToolResult event\n  const resultMap = new Map<string, WireEvent>();\n  for (const ev of events) {\n    if (ev.type === \"ToolResult\") {\n      const tcId = ev.payload.tool_call_id as string | undefined;\n      if (tcId) resultMap.set(tcId, ev);\n    }\n  }\n\n  let currentChain: DecisionChain | null = null;\n  let turnCounter = 0;\n  let thinkingBuffer: { index: number; text: string }[] = [];\n\n  for (const ev of events) {\n    if (ev.type === \"TurnBegin\") {\n      // Flush previous chain\n      if (currentChain && currentChain.steps.length > 0) {\n        chains.push(currentChain);\n      }\n      turnCounter++;\n\n      // Extract user input preview (TurnBegin uses \"user_input\", not \"message\")\n      let userInput = \"\";\n      const rawInput = ev.payload.user_input;\n      if (typeof rawInput === \"string\") {\n        userInput = rawInput;\n      } else if (Array.isArray(rawInput)) {\n        for (const part of rawInput) {\n          if (typeof part === \"object\" && part !== null) {\n            const p = part as Record<string, unknown>;\n            if (typeof p.text === \"string\") {\n              userInput += p.text;\n            }\n          }\n        }\n      }\n\n      currentChain = {\n        turnNumber: turnCounter,\n        turnEventIndex: ev.index,\n        userInput,\n        steps: [],\n      };\n      thinkingBuffer = [];\n      continue;\n    }\n\n    if (ev.type === \"ThinkPart\") {\n      const text = (ev.payload.text as string) ?? (ev.payload.think as string) ?? \"\";\n      if (text) {\n        thinkingBuffer.push({ index: ev.index, text });\n      }\n      continue;\n    }\n\n    if (ev.type === \"ToolCall\") {\n      const fn = ev.payload.function as Record<string, unknown> | undefined;\n      const name = (fn?.name as string) ?? \"unknown\";\n      const args = (fn?.arguments as string) ?? \"\";\n      const tcId = ev.payload.id as string | undefined;\n\n      // Find matching result\n      const resultEvent = tcId ? resultMap.get(tcId) ?? null : null;\n\n      // Determine error state\n      let isError = false;\n      if (resultEvent) {\n        const rv = resultEvent.payload.return_value as Record<string, unknown> | undefined;\n        isError = rv?.is_error === true;\n      }\n\n      // Duration\n      let durationSec = 0;\n      if (resultEvent) {\n        durationSec = Math.max(0, resultEvent.timestamp - ev.timestamp);\n      }\n\n      // Summarize thinking\n      const thinkingSummary = thinkingBuffer.map((t) => t.text).join(\" \");\n\n      // Summarize args\n      let argsSummary = \"\";\n      try {\n        argsSummary = JSON.stringify(JSON.parse(args));\n      } catch {\n        argsSummary = args;\n      }\n\n      const step: DecisionStep = {\n        thinkingEventIndices: thinkingBuffer.map((t) => t.index),\n        thinkingSummary,\n        toolCallEvent: ev,\n        toolCallName: name,\n        toolCallArgsSummary: argsSummary,\n        toolResultEvent: resultEvent,\n        isError,\n        durationSec,\n      };\n\n      if (currentChain) {\n        currentChain.steps.push(step);\n      }\n\n      thinkingBuffer = [];\n      continue;\n    }\n  }\n\n  // Flush last chain\n  if (currentChain && currentChain.steps.length > 0) {\n    chains.push(currentChain);\n  }\n\n  return chains;\n}\n\nexport function DecisionPath({ events, onScrollToIndex }: DecisionPathProps) {\n  const chains = useMemo(() => extractDecisionChains(events), [events]);\n\n  if (chains.length === 0) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground text-sm\">\n        No decision chains found\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex-1 overflow-y-auto p-4 space-y-4\">\n      {chains.map((chain) => (\n        <ChainGroup key={chain.turnEventIndex} chain={chain} onScrollToIndex={onScrollToIndex} />\n      ))}\n    </div>\n  );\n}\n\nfunction ChainGroup({\n  chain,\n  onScrollToIndex,\n}: {\n  chain: DecisionChain;\n  onScrollToIndex: (idx: number) => void;\n}) {\n  const [collapsed, setCollapsed] = useState(false);\n\n  return (\n    <div className=\"rounded-lg border bg-card\">\n      {/* Turn header */}\n      <button\n        onClick={() => setCollapsed((v) => !v)}\n        className=\"flex items-center gap-2 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors rounded-t-lg\"\n      >\n        {collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}\n        <span className=\"text-xs font-semibold text-foreground\">\n          Turn {chain.turnNumber}\n        </span>\n        {chain.userInput && (\n          <span className=\"text-[11px] text-muted-foreground flex-1 break-words\">\n            {chain.userInput}\n          </span>\n        )}\n        <span className=\"text-[10px] text-muted-foreground shrink-0\">\n          {chain.steps.length} step{chain.steps.length !== 1 ? \"s\" : \"\"}\n        </span>\n      </button>\n\n      {!collapsed && (\n        <div className=\"px-3 pb-3 space-y-0\">\n          {chain.steps.map((step, i) => (\n            <StepView\n              key={step.toolCallEvent.index}\n              step={step}\n              isLast={i === chain.steps.length - 1}\n              onScrollToIndex={onScrollToIndex}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction StepView({\n  step,\n  isLast,\n  onScrollToIndex,\n}: {\n  step: DecisionStep;\n  isLast: boolean;\n  onScrollToIndex: (idx: number) => void;\n}) {\n\n  return (\n    <div className={`relative ${!isLast ? \"pb-2\" : \"\"}`}>\n      {/* Vertical connecting line */}\n      <div className=\"absolute left-[11px] top-0 bottom-0 w-px bg-border\" />\n\n      {/* Thinking block */}\n      {step.thinkingEventIndices.length > 0 && (\n        <div className=\"relative ml-6 mb-1\">\n          {/* Dot on the connecting line */}\n          <div className=\"absolute -left-[17px] top-[7px] w-[7px] h-[7px] rounded-full bg-cyan-500 border border-background z-10\" />\n          <button\n            onClick={() => {\n              if (step.thinkingEventIndices.length > 0) {\n                onScrollToIndex(step.thinkingEventIndices[0]);\n              }\n            }}\n            className=\"group flex items-start gap-1.5 w-full text-left border-l-2 border-cyan-500/40 pl-2 py-1 hover:bg-cyan-500/5 rounded-r transition-colors\"\n          >\n            <Brain size={12} className=\"text-cyan-500 shrink-0 mt-0.5\" />\n            <div className=\"flex-1 min-w-0\">\n              <div className=\"text-[10px] font-medium text-cyan-600 dark:text-cyan-400\">\n                Thinking\n              </div>\n              <div className=\"text-[11px] text-muted-foreground leading-tight whitespace-pre-wrap break-words\">\n                {step.thinkingSummary}\n              </div>\n            </div>\n          </button>\n        </div>\n      )}\n\n      {/* Arrow connector */}\n      {step.thinkingEventIndices.length > 0 && (\n        <div className=\"relative ml-6 flex items-center h-3\">\n          <div className=\"absolute -left-[14px] w-px h-full bg-border\" />\n        </div>\n      )}\n\n      {/* Tool call block */}\n      <div className=\"relative ml-6 mb-1\">\n        <div className=\"absolute -left-[17px] top-[7px] w-[7px] h-[7px] rounded-full bg-purple-500 border border-background z-10\" />\n        <button\n          onClick={() => onScrollToIndex(step.toolCallEvent.index)}\n          className=\"group flex items-start gap-1.5 w-full text-left border-l-2 border-purple-500/40 pl-2 py-1 hover:bg-purple-500/5 rounded-r transition-colors\"\n        >\n          <Wrench size={12} className=\"text-purple-500 shrink-0 mt-0.5\" />\n          <div className=\"flex-1 min-w-0\">\n            <div className=\"text-[10px] font-medium text-purple-600 dark:text-purple-400\">\n              {step.toolCallName}\n            </div>\n            <div className=\"text-[11px] text-muted-foreground font-mono whitespace-pre-wrap break-all leading-tight\">\n              {step.toolCallArgsSummary}\n            </div>\n          </div>\n        </button>\n      </div>\n\n      {/* Arrow connector */}\n      <div className=\"relative ml-6 flex items-center h-3\">\n        <div className=\"absolute -left-[14px] w-px h-full bg-border\" />\n      </div>\n\n      {/* Result block */}\n      <div className=\"relative ml-6\">\n        <div\n          className={`absolute -left-[17px] top-[7px] w-[7px] h-[7px] rounded-full border border-background z-10 ${\n            step.isError ? \"bg-red-500\" : \"bg-green-500\"\n          }`}\n        />\n        <button\n          onClick={() => {\n            if (step.toolResultEvent) {\n              onScrollToIndex(step.toolResultEvent.index);\n            }\n          }}\n          disabled={!step.toolResultEvent}\n          className={`group flex items-start gap-1.5 w-full text-left border-l-2 pl-2 py-1 rounded-r transition-colors ${\n            step.isError\n              ? \"border-red-500/40 hover:bg-red-500/5\"\n              : \"border-green-500/40 hover:bg-green-500/5\"\n          } ${!step.toolResultEvent ? \"opacity-50 cursor-default\" : \"\"}`}\n        >\n          {step.isError ? (\n            <XCircle size={12} className=\"text-red-500 shrink-0 mt-0.5\" />\n          ) : (\n            <CheckCircle size={12} className=\"text-green-500 shrink-0 mt-0.5\" />\n          )}\n          <div className=\"flex-1 min-w-0\">\n            <div className={`text-[10px] font-medium ${step.isError ? \"text-red-600 dark:text-red-400\" : \"text-green-600 dark:text-green-400\"}`}>\n              {step.isError ? \"Error\" : \"Success\"}\n              {step.durationSec > 0 && (\n                <span className=\"ml-1 text-muted-foreground font-normal\">\n                  {step.durationSec.toFixed(1)}s\n                </span>\n              )}\n            </div>\n            {step.toolResultEvent && (\n              <div className=\"text-[11px] text-muted-foreground whitespace-pre-wrap break-words leading-tight\">\n                {(() => {\n                  const rv = step.toolResultEvent.payload.return_value;\n                  if (typeof rv === \"string\") return rv;\n                  if (rv && typeof rv === \"object\") return JSON.stringify(rv);\n                  return \"\";\n                })()}\n              </div>\n            )}\n          </div>\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/integrity-check.tsx",
    "content": "import { type WireEvent } from \"@/lib/api\";\nimport { ShieldCheck, ShieldAlert, AlertCircle } from \"lucide-react\";\n\n// ---------- Data structures ----------\n\nexport interface OrphanedEvent {\n  event: WireEvent;\n  reason: string;\n}\n\nexport interface IntegrityResult {\n  score: number; // 0-100\n  totalPairable: number;\n  orphans: OrphanedEvent[];\n}\n\n// ---------- Pure detection logic ----------\n\nexport function computeIntegrity(events: WireEvent[]): IntegrityResult {\n  const orphans: OrphanedEvent[] = [];\n  let totalPairable = 0;\n\n  // --- TurnBegin / TurnEnd counter ---\n  let turnCounter = 0;\n  const turnBeginStack: WireEvent[] = [];\n  for (const ev of events) {\n    if (ev.type === \"TurnBegin\") {\n      totalPairable++;\n      turnCounter++;\n      turnBeginStack.push(ev);\n    } else if (ev.type === \"TurnEnd\") {\n      totalPairable++;\n      turnCounter--;\n      if (turnCounter < 0) {\n        orphans.push({ event: ev, reason: \"TurnEnd without matching TurnBegin\" });\n        turnCounter = 0;\n      } else {\n        turnBeginStack.pop();\n      }\n    }\n  }\n  // Remaining unpaired TurnBegins\n  for (const ev of turnBeginStack) {\n    orphans.push({ event: ev, reason: \"TurnBegin without matching TurnEnd\" });\n  }\n\n  // --- CompactionBegin / CompactionEnd counter ---\n  let compactionCounter = 0;\n  const compactionBeginStack: WireEvent[] = [];\n  for (const ev of events) {\n    if (ev.type === \"CompactionBegin\") {\n      totalPairable++;\n      compactionCounter++;\n      compactionBeginStack.push(ev);\n    } else if (ev.type === \"CompactionEnd\") {\n      totalPairable++;\n      compactionCounter--;\n      if (compactionCounter < 0) {\n        orphans.push({ event: ev, reason: \"CompactionEnd without matching CompactionBegin\" });\n        compactionCounter = 0;\n      } else {\n        compactionBeginStack.pop();\n      }\n    }\n  }\n  for (const ev of compactionBeginStack) {\n    orphans.push({ event: ev, reason: \"CompactionBegin without matching CompactionEnd\" });\n  }\n\n  // --- ToolCall / ToolResult by id ---\n  const toolCallMap = new Map<string, WireEvent>();\n  for (const ev of events) {\n    if (ev.type === \"ToolCall\") {\n      totalPairable++;\n      const fn = ev.payload.function as Record<string, unknown> | undefined;\n      const id = (ev.payload.id ?? fn?.id ?? \"\") as string;\n      if (id) {\n        toolCallMap.set(id, ev);\n      }\n    } else if (ev.type === \"ToolResult\") {\n      totalPairable++;\n      const callId = ev.payload.tool_call_id as string | undefined;\n      if (callId && toolCallMap.has(callId)) {\n        toolCallMap.delete(callId);\n      } else {\n        orphans.push({ event: ev, reason: `ToolResult references unknown tool_call_id: ${callId ?? \"(none)\"}` });\n      }\n    }\n  }\n  for (const ev of toolCallMap.values()) {\n    orphans.push({ event: ev, reason: \"ToolCall without matching ToolResult\" });\n  }\n\n  // --- ApprovalRequest / ApprovalResponse by id ---\n  const approvalMap = new Map<string, WireEvent>();\n  for (const ev of events) {\n    if (ev.type === \"ApprovalRequest\") {\n      totalPairable++;\n      const id = ev.payload.id as string | undefined;\n      if (id) {\n        approvalMap.set(id, ev);\n      }\n    } else if (ev.type === \"ApprovalResponse\") {\n      totalPairable++;\n      const id = ev.payload.request_id as string | undefined;\n      if (id && approvalMap.has(id)) {\n        approvalMap.delete(id);\n      } else {\n        orphans.push({ event: ev, reason: `ApprovalResponse references unknown request id: ${id ?? \"(none)\"}` });\n      }\n    }\n  }\n  for (const ev of approvalMap.values()) {\n    orphans.push({ event: ev, reason: \"ApprovalRequest without matching ApprovalResponse\" });\n  }\n\n  // --- Score ---\n  const score = Math.round(\n    (1 - orphans.length / Math.max(totalPairable, 1)) * 100,\n  );\n\n  return { score, totalPairable, orphans };\n}\n\n// ---------- UI component ----------\n\nconst TYPE_BADGE_COLORS: Record<string, string> = {\n  TurnBegin: \"bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30\",\n  TurnEnd: \"bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30\",\n  CompactionBegin: \"bg-orange-500/15 text-orange-700 dark:text-orange-300 border-orange-500/30\",\n  CompactionEnd: \"bg-orange-500/15 text-orange-700 dark:text-orange-300 border-orange-500/30\",\n  ToolCall: \"bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30\",\n  ToolResult: \"bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30\",\n  ApprovalRequest: \"bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30\",\n  ApprovalResponse: \"bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30\",\n};\n\nfunction getTypeBadgeColor(type: string): string {\n  return TYPE_BADGE_COLORS[type] ?? \"bg-secondary text-secondary-foreground border-border\";\n}\n\ninterface IntegrityPanelProps {\n  result: IntegrityResult;\n  onScrollToIndex: (idx: number) => void;\n}\n\nexport function IntegrityPanel({ result, onScrollToIndex }: IntegrityPanelProps) {\n  const { score, totalPairable, orphans } = result;\n\n  const ScoreIcon = score === 100 ? ShieldCheck : ShieldAlert;\n  const scoreColor =\n    score === 100\n      ? \"text-green-600 dark:text-green-400\"\n      : score >= 80\n        ? \"text-amber-600 dark:text-amber-400\"\n        : \"text-red-600 dark:text-red-400\";\n\n  return (\n    <div className=\"border rounded-md bg-card p-3 space-y-3\">\n      {/* Header */}\n      <div className=\"flex items-center gap-2\">\n        <ScoreIcon size={16} className={scoreColor} />\n        <span className={`text-sm font-semibold ${scoreColor}`}>\n          {score}%\n        </span>\n        <span className=\"text-xs text-muted-foreground\">\n          integrity &middot; {totalPairable} pairable events &middot; {orphans.length} orphan{orphans.length !== 1 ? \"s\" : \"\"}\n        </span>\n      </div>\n\n      {/* Orphan list */}\n      {orphans.length > 0 && (\n        <div className=\"space-y-1 max-h-48 overflow-y-auto\">\n          {orphans.map((o, i) => (\n            <button\n              key={i}\n              onClick={() => onScrollToIndex(o.event.index)}\n              className=\"flex items-center gap-2 w-full text-left rounded px-2 py-1 hover:bg-muted/60 transition-colors group\"\n            >\n              {/* Type badge */}\n              <span\n                className={`shrink-0 rounded border px-1.5 py-0 text-[10px] font-medium ${getTypeBadgeColor(o.event.type)}`}\n              >\n                {o.event.type}\n              </span>\n\n              {/* Reason */}\n              <span className=\"truncate text-[11px] text-muted-foreground group-hover:text-foreground\">\n                {o.reason}\n              </span>\n\n              {/* Event index */}\n              <span className=\"ml-auto shrink-0 text-[10px] font-mono text-muted-foreground\">\n                #{o.event.index}\n              </span>\n            </button>\n          ))}\n        </div>\n      )}\n\n      {orphans.length === 0 && (\n        <div className=\"flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400\">\n          <ShieldCheck size={13} />\n          All pairable events are properly matched.\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/timeline-view.tsx",
    "content": "import { useCallback, useMemo, useRef, useState } from \"react\";\nimport * as Tooltip from \"@radix-ui/react-tooltip\";\nimport {\n  Layers,\n  ArrowDownNarrowWide,\n  ZoomIn,\n  ZoomOut,\n  Activity,\n  Timer,\n} from \"lucide-react\";\nimport type { WireEvent } from \"@/lib/api\";\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\ninterface TimelineViewProps {\n  events: WireEvent[];\n  onScrollToIndex: (eventIndex: number) => void;\n}\n\ntype BarColor = \"blue\" | \"purple\" | \"amber\" | \"green\" | \"cyan\" | \"indigo\";\n\ntype TooltipPayload =\n  | {\n      kind: \"turn\";\n      turnNumber: number;\n      userInput: string;\n      stepCount: number;\n      toolCallCount: number;\n    }\n  | { kind: \"step\"; stepNumber: number; turnNumber: number; toolCallCount: number }\n  | {\n      kind: \"tool\";\n      toolName: string;\n      toolCallId: string;\n      hasError: boolean;\n      argsSummary: string;\n    }\n  | { kind: \"thinking\"; isThinking: boolean; charCount: number }\n  | {\n      kind: \"approval\";\n      sender: string;\n      action: string;\n      response: string;\n    }\n  | { kind: \"subagent\"; taskToolCallId: string; eventCount: number };\n\ninterface TimelineBar {\n  label: string;\n  eventIndex: number;\n  startSec: number;\n  endSec: number;\n  durationSec: number;\n  depth: number;\n  color: BarColor;\n  hasError?: boolean;\n  dashed?: boolean;\n  striped?: boolean;\n  tooltipData?: TooltipPayload;\n}\n\ninterface CompactionMarker {\n  startSec: number;\n  endSec: number;\n  eventIndex: number;\n}\n\ninterface GapIndicator {\n  startSec: number;\n  endSec: number;\n  durationSec: number;\n  depth: number;\n}\n\ninterface TokenDataPoint {\n  timeSec: number;\n  inputTokens: number;\n  outputTokens: number;\n  contextUsage: number;\n  eventIndex: number;\n}\n\ninterface BuildTimelineResult {\n  bars: TimelineBar[];\n  totalSec: number;\n  compactionMarkers: CompactionMarker[];\n  tokenData: TokenDataPoint[];\n  gaps: GapIndicator[];\n}\n\ntype SortMode = \"hierarchy\" | \"chronological\";\n\n// ─── Build Timeline ─────────────────────────────────────────────────────────\n\nfunction buildTimeline(events: WireEvent[]): BuildTimelineResult {\n  if (events.length === 0)\n    return { bars: [], totalSec: 0, compactionMarkers: [], tokenData: [], gaps: [] };\n\n  const bars: TimelineBar[] = [];\n  const compactionMarkers: CompactionMarker[] = [];\n  const tokenData: TokenDataPoint[] = [];\n  const t0 = events[0].timestamp;\n\n  // ── Track turns and steps ──\n  let currentTurnStart: number | null = null;\n  let currentTurnIndex = 0;\n  let turnNumber = 0;\n  let currentStepStart: number | null = null;\n  let currentStepIndex = 0;\n  let stepNumber = 0;\n\n  // Counters for tooltip enrichment\n  let turnStepCount = 0;\n  let turnToolCallCount = 0;\n  let stepToolCallCount = 0;\n  let turnUserInput = \"\";\n\n  // ── Track tool calls ──\n  const openToolCalls = new Map<\n    string,\n    { name: string; startTime: number; eventIndex: number; argsSummary: string }\n  >();\n\n  // ── Track approvals ──\n  const openApprovals = new Map<\n    string,\n    { startTime: number; eventIndex: number; sender: string; action: string }\n  >();\n\n  // ── Track compaction ──\n  let compactionStart: number | null = null;\n  let compactionIndex = 0;\n\n  // ── Track generation/thinking ──\n  let genStart: number | null = null;\n  let genIndex = 0;\n  let genCharCount = 0;\n  let genIsThinking = false;\n\n  // ── Track sub-agents ──\n  const subagentData = new Map<string, { startTime: number; endTime: number; eventIndex: number; eventCount: number }>();\n\n  const closeGeneration = (t: number) => {\n    if (genStart !== null) {\n      const dur = t - genStart;\n      if (dur > 0.001) {\n        bars.push({\n          label: genIsThinking ? \"Thinking\" : \"Generation\",\n          eventIndex: genIndex,\n          startSec: genStart,\n          endSec: t,\n          durationSec: dur,\n          depth: 2,\n          color: \"cyan\",\n          dashed: true,\n          tooltipData: {\n            kind: \"thinking\",\n            isThinking: genIsThinking,\n            charCount: genCharCount,\n          },\n        });\n      }\n      genStart = null;\n      genCharCount = 0;\n    }\n  };\n\n  const closeTurn = (t: number) => {\n    if (currentTurnStart != null) {\n      bars.push({\n        label: `Turn ${turnNumber}`,\n        eventIndex: currentTurnIndex,\n        startSec: currentTurnStart,\n        endSec: t,\n        durationSec: t - currentTurnStart,\n        depth: 0,\n        color: \"blue\",\n        tooltipData: {\n          kind: \"turn\",\n          turnNumber,\n          userInput: turnUserInput,\n          stepCount: turnStepCount,\n          toolCallCount: turnToolCallCount,\n        },\n      });\n      currentTurnStart = null;\n    }\n  };\n\n  const closeStep = (t: number) => {\n    if (currentStepStart != null) {\n      bars.push({\n        label: `Step ${stepNumber}`,\n        eventIndex: currentStepIndex,\n        startSec: currentStepStart,\n        endSec: t,\n        durationSec: t - currentStepStart,\n        depth: 1,\n        color: \"green\",\n        tooltipData: {\n          kind: \"step\",\n          stepNumber,\n          turnNumber,\n          toolCallCount: stepToolCallCount,\n        },\n      });\n    }\n  };\n\n  for (const e of events) {\n    const t = e.timestamp - t0;\n\n    if (e.type === \"TurnBegin\") {\n      closeGeneration(t);\n      turnNumber++;\n      currentTurnStart = t;\n      currentTurnIndex = e.index;\n      stepNumber = 0;\n      turnStepCount = 0;\n      turnToolCallCount = 0;\n      // Extract user input for tooltip\n      const input = e.payload.user_input;\n      if (typeof input === \"string\") {\n        turnUserInput = input.slice(0, 120);\n      } else if (Array.isArray(input)) {\n        const textPart = input.find(\n          (p: Record<string, unknown>) => p.type === \"text\",\n        );\n        turnUserInput = textPart\n          ? String(textPart.text ?? \"\").slice(0, 120)\n          : \"(multipart)\";\n      } else {\n        turnUserInput = \"\";\n      }\n    } else if (e.type === \"TurnEnd\") {\n      closeGeneration(t);\n      closeStep(t);\n      closeTurn(t);\n      currentStepStart = null;\n    } else if (e.type === \"StepBegin\") {\n      closeGeneration(t);\n      closeStep(t);\n      stepNumber++;\n      turnStepCount++;\n      currentStepStart = t;\n      currentStepIndex = e.index;\n      stepToolCallCount = 0;\n    } else if (e.type === \"ToolCall\") {\n      closeGeneration(t);\n      const id = e.payload.id as string | undefined;\n      const fn = e.payload.function as Record<string, unknown> | undefined;\n      const name = (fn?.name as string) ?? \"tool\";\n      const args = (fn?.arguments as string) ?? \"\";\n      let argsSummary = \"\";\n      try {\n        const parsed = JSON.parse(args);\n        const keys = Object.keys(parsed);\n        if (keys.length > 0) {\n          const firstVal = String(parsed[keys[0]] ?? \"\");\n          argsSummary = `${keys[0]}=${firstVal.slice(0, 60)}`;\n        }\n      } catch {\n        argsSummary = args.slice(0, 60);\n      }\n      if (id) {\n        openToolCalls.set(id, { name, startTime: t, eventIndex: e.index, argsSummary });\n        turnToolCallCount++;\n        stepToolCallCount++;\n      }\n    } else if (e.type === \"ToolResult\") {\n      closeGeneration(t);\n      const tcId = e.payload.tool_call_id as string | undefined;\n      if (tcId && openToolCalls.has(tcId)) {\n        const call = openToolCalls.get(tcId)!;\n        const rv = e.payload.return_value as Record<string, unknown> | undefined;\n        const hasError = rv?.is_error === true;\n        bars.push({\n          label: call.name,\n          eventIndex: call.eventIndex,\n          startSec: call.startTime,\n          endSec: t,\n          durationSec: t - call.startTime,\n          depth: 2,\n          color: \"purple\",\n          hasError,\n          tooltipData: {\n            kind: \"tool\",\n            toolName: call.name,\n            toolCallId: tcId,\n            hasError,\n            argsSummary: call.argsSummary,\n          },\n        });\n        openToolCalls.delete(tcId);\n      }\n    } else if (e.type === \"ApprovalRequest\") {\n      const id = e.payload.id as string | undefined;\n      if (id) {\n        openApprovals.set(id, {\n          startTime: t,\n          eventIndex: e.index,\n          sender: (e.payload.sender as string) ?? \"\",\n          action: (e.payload.action as string) ?? \"\",\n        });\n      }\n    } else if (e.type === \"ApprovalResponse\") {\n      const reqId = e.payload.request_id as string | undefined;\n      if (reqId && openApprovals.has(reqId)) {\n        const req = openApprovals.get(reqId)!;\n        const response = (e.payload.response as string) ?? \"\";\n        bars.push({\n          label: `Approval: ${req.action || req.sender || \"wait\"}`,\n          eventIndex: req.eventIndex,\n          startSec: req.startTime,\n          endSec: t,\n          durationSec: t - req.startTime,\n          depth: 2,\n          color: \"amber\",\n          hasError: response === \"reject\",\n          striped: true,\n          tooltipData: {\n            kind: \"approval\",\n            sender: req.sender,\n            action: req.action,\n            response,\n          },\n        });\n        openApprovals.delete(reqId);\n      }\n    } else if (e.type === \"CompactionBegin\") {\n      compactionStart = t;\n      compactionIndex = e.index;\n    } else if (e.type === \"CompactionEnd\") {\n      if (compactionStart !== null) {\n        compactionMarkers.push({\n          startSec: compactionStart,\n          endSec: t,\n          eventIndex: compactionIndex,\n        });\n        compactionStart = null;\n      }\n    } else if (e.type === \"StatusUpdate\") {\n      const cu = e.payload.context_usage as number | undefined;\n      const tu = e.payload.token_usage as\n        | Record<string, number>\n        | undefined;\n      if (cu !== undefined || tu) {\n        tokenData.push({\n          timeSec: t,\n          inputTokens: tu ? (tu.input_other ?? 0) + (tu.input_cache_read ?? 0) + (tu.input_cache_creation ?? 0) : 0,\n          outputTokens: tu?.output ?? 0,\n          contextUsage: cu ?? 0,\n          eventIndex: e.index,\n        });\n      }\n    } else if (e.type === \"TextPart\" || e.type === \"ThinkPart\") {\n      if (genStart === null) {\n        genStart = t;\n        genIndex = e.index;\n        genCharCount = 0;\n        genIsThinking = e.type === \"ThinkPart\";\n      }\n      const text =\n        e.type === \"TextPart\"\n          ? ((e.payload.text as string) ?? \"\")\n          : ((e.payload.thinking as string) ?? (e.payload.think as string) ?? \"\");\n      genCharCount += text.length;\n    } else if (e.type === \"SubagentEvent\") {\n      const taskId = e.payload.task_tool_call_id as string | undefined;\n      if (taskId) {\n        if (!subagentData.has(taskId)) {\n          subagentData.set(taskId, { startTime: t, endTime: t, eventIndex: e.index, eventCount: 0 });\n        }\n        const sa = subagentData.get(taskId)!;\n        sa.endTime = Math.max(sa.endTime, t);\n        sa.startTime = Math.min(sa.startTime, t);\n        sa.eventCount++;\n      }\n    }\n  }\n\n  // ── Close remaining open items ──\n  const lastT = events.length > 0 ? events[events.length - 1].timestamp - t0 : 0;\n\n  closeGeneration(lastT);\n  closeStep(lastT);\n  closeTurn(lastT);\n\n  // Close unclosed compaction\n  if (compactionStart !== null) {\n    compactionMarkers.push({\n      startSec: compactionStart,\n      endSec: lastT,\n      eventIndex: compactionIndex,\n    });\n  }\n\n  // Close unclosed approvals\n  for (const [, req] of openApprovals) {\n    bars.push({\n      label: `Approval: ${req.action || req.sender || \"wait\"}`,\n      eventIndex: req.eventIndex,\n      startSec: req.startTime,\n      endSec: lastT,\n      durationSec: lastT - req.startTime,\n      depth: 2,\n      color: \"amber\",\n      striped: true,\n      tooltipData: {\n        kind: \"approval\",\n        sender: req.sender,\n        action: req.action,\n        response: \"(pending)\",\n      },\n    });\n  }\n\n  // ── Build sub-agent bars ──\n  for (const [taskId, sa] of subagentData) {\n    if (sa.eventCount === 0) continue;\n    bars.push({\n      label: `Sub-agent`,\n      eventIndex: sa.eventIndex,\n      startSec: sa.startTime,\n      endSec: sa.endTime,\n      durationSec: sa.endTime - sa.startTime,\n      depth: 3,\n      color: \"indigo\",\n      tooltipData: {\n        kind: \"subagent\",\n        taskToolCallId: taskId.slice(0, 12),\n        eventCount: sa.eventCount,\n      },\n    });\n  }\n\n  const totalSec =\n    events.length >= 2\n      ? events[events.length - 1].timestamp - events[0].timestamp\n      : 0;\n\n  // ── Compute gaps ──\n  const gaps: GapIndicator[] = [];\n  if (totalSec > 0) {\n    const toolBars = bars\n      .filter((b) => b.depth === 2)\n      .sort((a, b) => a.startSec - b.startSec);\n    for (let i = 1; i < toolBars.length; i++) {\n      const prev = toolBars[i - 1];\n      const curr = toolBars[i];\n      const gap = curr.startSec - prev.endSec;\n      if (gap > 2.0) {\n        gaps.push({\n          startSec: prev.endSec,\n          endSec: curr.startSec,\n          durationSec: gap,\n          depth: 2,\n        });\n      }\n    }\n  }\n\n  // Default sort: hierarchy\n  bars.sort((a, b) => a.depth - b.depth || a.startSec - b.startSec);\n\n  return { bars, totalSec, compactionMarkers, tokenData, gaps };\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────────\n\nfunction formatDuration(sec: number): string {\n  if (sec < 0.001) return \"<1ms\";\n  if (sec < 1) return `${(sec * 1000).toFixed(0)}ms`;\n  if (sec < 60) return `${sec.toFixed(2)}s`;\n  return `${(sec / 60).toFixed(1)}min`;\n}\n\nfunction computeTicks(\n  rangeStart: number,\n  rangeEnd: number,\n): { sec: number; label: string }[] {\n  const duration = rangeEnd - rangeStart;\n  if (duration <= 0) return [];\n\n  let interval: number;\n  if (duration < 5) interval = 0.5;\n  else if (duration < 15) interval = 1;\n  else if (duration < 60) interval = 5;\n  else if (duration < 300) interval = 30;\n  else if (duration < 1800) interval = 60;\n  else interval = 300;\n\n  const ticks: { sec: number; label: string }[] = [];\n  const start = Math.ceil(rangeStart / interval) * interval;\n  for (let t = start; t <= rangeEnd; t += interval) {\n    ticks.push({ sec: t, label: formatDuration(t) });\n  }\n  return ticks;\n}\n\n// ─── Color Map ──────────────────────────────────────────────────────────────\n\nconst COLOR_MAP: Record<string, { bg: string; text: string; border: string }> = {\n  blue: {\n    bg: \"bg-blue-500/20\",\n    text: \"text-blue-700 dark:text-blue-300\",\n    border: \"border-blue-500/30\",\n  },\n  purple: {\n    bg: \"bg-purple-500/20\",\n    text: \"text-purple-700 dark:text-purple-300\",\n    border: \"border-purple-500/30\",\n  },\n  amber: {\n    bg: \"bg-amber-500/20\",\n    text: \"text-amber-700 dark:text-amber-300\",\n    border: \"border-amber-500/30\",\n  },\n  green: {\n    bg: \"bg-green-500/20\",\n    text: \"text-green-700 dark:text-green-300\",\n    border: \"border-green-500/30\",\n  },\n  cyan: {\n    bg: \"bg-cyan-500/20\",\n    text: \"text-cyan-700 dark:text-cyan-300\",\n    border: \"border-cyan-500/30\",\n  },\n  indigo: {\n    bg: \"bg-indigo-500/20\",\n    text: \"text-indigo-700 dark:text-indigo-300\",\n    border: \"border-indigo-500/30\",\n  },\n};\n\n// ─── Tooltip Content ────────────────────────────────────────────────────────\n\nfunction BarTooltipContent({ bar }: { bar: TimelineBar }) {\n  const d = bar.tooltipData;\n  if (!d) return null;\n\n  return (\n    <div className=\"space-y-1\">\n      <div className=\"font-medium text-foreground\">{bar.label}</div>\n      <div className=\"flex items-center gap-1.5 text-muted-foreground\">\n        <Timer className=\"w-3 h-3\" />\n        <span>{formatDuration(bar.durationSec)}</span>\n      </div>\n\n      {d.kind === \"turn\" && (\n        <>\n          <div className=\"text-muted-foreground\">\n            {d.stepCount} step{d.stepCount !== 1 ? \"s\" : \"\"}, {d.toolCallCount}{\" \"}\n            tool call{d.toolCallCount !== 1 ? \"s\" : \"\"}\n          </div>\n          {d.userInput && (\n            <div className=\"text-muted-foreground/80 italic truncate max-w-[240px]\">\n              &ldquo;{d.userInput}&rdquo;\n            </div>\n          )}\n        </>\n      )}\n\n      {d.kind === \"step\" && (\n        <div className=\"text-muted-foreground\">\n          Turn {d.turnNumber} &middot; {d.toolCallCount} tool call\n          {d.toolCallCount !== 1 ? \"s\" : \"\"}\n        </div>\n      )}\n\n      {d.kind === \"tool\" && (\n        <>\n          <div className=\"font-mono text-[10px] text-muted-foreground/70\">\n            {d.toolCallId.slice(0, 16)}\n          </div>\n          {d.argsSummary && (\n            <div className=\"text-muted-foreground/80 truncate max-w-[240px] font-mono text-[10px]\">\n              {d.argsSummary}\n            </div>\n          )}\n          {d.hasError && <div className=\"text-red-500 font-medium\">Error</div>}\n        </>\n      )}\n\n      {d.kind === \"thinking\" && (\n        <div className=\"text-muted-foreground\">\n          {d.isThinking ? \"Extended thinking\" : \"Text generation\"} &middot;{\" \"}\n          {d.charCount.toLocaleString()} chars\n        </div>\n      )}\n\n      {d.kind === \"approval\" && (\n        <>\n          <div className=\"text-muted-foreground\">\n            {d.sender}: {d.action}\n          </div>\n          <div\n            className={\n              d.response === \"reject\"\n                ? \"text-red-500 font-medium\"\n                : \"text-green-500 font-medium\"\n            }\n          >\n            {d.response === \"approve\"\n              ? \"Approved\"\n              : d.response === \"approve_for_session\"\n                ? \"Approved (session)\"\n                : d.response === \"reject\"\n                  ? \"Rejected\"\n                  : d.response}\n          </div>\n        </>\n      )}\n\n      {d.kind === \"subagent\" && (\n        <>\n          <div className=\"font-mono text-[10px] text-muted-foreground/70\">\n            task: {d.taskToolCallId}\n          </div>\n          <div className=\"text-muted-foreground\">\n            {d.eventCount} event{d.eventCount !== 1 ? \"s\" : \"\"}\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\n// ─── Token Sparkline ────────────────────────────────────────────────────────\n\nfunction TokenSparkline({\n  tokenData,\n  rangeStart,\n  rangeDuration,\n}: {\n  tokenData: TokenDataPoint[];\n  rangeStart: number;\n  rangeDuration: number;\n}) {\n  if (tokenData.length < 2) return null;\n\n  const W = 1000;\n  const H = 24;\n  const PAD_Y = 2;\n\n  const maxTokens = Math.max(\n    ...tokenData.map((d) => d.inputTokens + d.outputTokens),\n    1,\n  );\n\n  const points = tokenData\n    .filter((d) => d.timeSec >= rangeStart && d.timeSec <= rangeStart + rangeDuration)\n    .map((d) => {\n      const x = ((d.timeSec - rangeStart) / rangeDuration) * W;\n      const y =\n        H - PAD_Y - ((d.inputTokens + d.outputTokens) / maxTokens) * (H - PAD_Y * 2);\n      return { x, y };\n    });\n\n  if (points.length < 2) return null;\n\n  const linePath = points\n    .map((p, i) => `${i === 0 ? \"M\" : \"L\"}${p.x},${p.y}`)\n    .join(\" \");\n  const areaPath = `${linePath} L${points[points.length - 1].x},${H} L${points[0].x},${H} Z`;\n\n  // Context usage line\n  const ctxPoints = tokenData\n    .filter(\n      (d) =>\n        d.timeSec >= rangeStart &&\n        d.timeSec <= rangeStart + rangeDuration &&\n        d.contextUsage > 0,\n    )\n    .map((d) => {\n      const x = ((d.timeSec - rangeStart) / rangeDuration) * W;\n      const y = H - PAD_Y - d.contextUsage * (H - PAD_Y * 2);\n      return { x, y };\n    });\n\n  const ctxPath =\n    ctxPoints.length >= 2\n      ? ctxPoints.map((p, i) => `${i === 0 ? \"M\" : \"L\"}${p.x},${p.y}`).join(\" \")\n      : null;\n\n  return (\n    <div className=\"flex items-center gap-2 h-6 mb-1\">\n      <div className=\"shrink-0 w-32\" />\n      <div className=\"flex-1 relative\">\n        <svg\n          viewBox={`0 0 ${W} ${H}`}\n          className=\"w-full h-6\"\n          preserveAspectRatio=\"none\"\n        >\n          <path d={areaPath} className=\"fill-primary/8\" />\n          <path\n            d={linePath}\n            className=\"stroke-primary/40 fill-none\"\n            strokeWidth={1.5}\n          />\n          {ctxPath && (\n            <path\n              d={ctxPath}\n              className=\"stroke-orange-400/50 fill-none\"\n              strokeWidth={1}\n              strokeDasharray=\"3,2\"\n            />\n          )}\n        </svg>\n      </div>\n      <span className=\"text-[10px] font-mono text-muted-foreground w-16 shrink-0 text-right\">\n        tokens\n      </span>\n    </div>\n  );\n}\n\n// ─── Main Component ─────────────────────────────────────────────────────────\n\nexport function TimelineView({ events, onScrollToIndex }: TimelineViewProps) {\n  const result = useMemo(() => buildTimeline(events), [events]);\n  const { totalSec, compactionMarkers, tokenData, gaps } = result;\n\n  const [sortMode, setSortMode] = useState<SortMode>(\"hierarchy\");\n  const [viewRange, setViewRange] = useState<[number, number] | null>(null);\n  const [showGaps, setShowGaps] = useState(false);\n  const [showTokens, setShowTokens] = useState(false);\n\n  // Zoom drag\n  const [dragStart, setDragStart] = useState<number | null>(null);\n  const [dragCurrent, setDragCurrent] = useState<number | null>(null);\n  const trackContainerRef = useRef<HTMLDivElement>(null);\n\n  const rangeStart = viewRange ? viewRange[0] : 0;\n  const rangeEnd = viewRange ? viewRange[1] : totalSec;\n  const rangeDuration = rangeEnd - rangeStart;\n  const isZoomed = viewRange !== null;\n  const LABEL_W = 128;\n\n  const sortedBars = useMemo(() => {\n    const b = [...result.bars];\n    if (sortMode === \"chronological\") {\n      b.sort((a, c) => a.startSec - c.startSec || a.depth - c.depth);\n    }\n    return b;\n  }, [result.bars, sortMode]);\n\n  const visibleBars = useMemo(() => {\n    if (!isZoomed) return sortedBars;\n    return sortedBars.filter(\n      (bar) => bar.endSec > rangeStart && bar.startSec < rangeEnd,\n    );\n  }, [sortedBars, isZoomed, rangeStart, rangeEnd]);\n\n  const ticks = useMemo(\n    () => computeTicks(rangeStart, rangeEnd),\n    [rangeStart, rangeEnd],\n  );\n\n  // ── Zoom handlers ──\n  const getTimeFromMouseEvent = useCallback(\n    (e: React.MouseEvent) => {\n      const container = trackContainerRef.current;\n      if (!container) return null;\n      // Find the first bar track element to get the correct offset\n      const barTrack = container.querySelector(\"[data-bar-track]\");\n      if (!barTrack) return null;\n      const rect = barTrack.getBoundingClientRect();\n      const xPct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));\n      return rangeStart + xPct * rangeDuration;\n    },\n    [rangeStart, rangeDuration],\n  );\n\n  const handleMouseDown = useCallback(\n    (e: React.MouseEvent) => {\n      if (e.button !== 0) return;\n      const t = getTimeFromMouseEvent(e);\n      if (t !== null) {\n        setDragStart(t);\n        setDragCurrent(t);\n      }\n    },\n    [getTimeFromMouseEvent],\n  );\n\n  const handleMouseMove = useCallback(\n    (e: React.MouseEvent) => {\n      if (dragStart === null) return;\n      const t = getTimeFromMouseEvent(e);\n      if (t !== null) setDragCurrent(t);\n    },\n    [dragStart, getTimeFromMouseEvent],\n  );\n\n  const handleMouseUp = useCallback(() => {\n    if (dragStart !== null && dragCurrent !== null) {\n      const lo = Math.min(dragStart, dragCurrent);\n      const hi = Math.max(dragStart, dragCurrent);\n      if (hi - lo > rangeDuration * 0.01) {\n        setViewRange([lo, hi]);\n      }\n    }\n    setDragStart(null);\n    setDragCurrent(null);\n  }, [dragStart, dragCurrent, rangeDuration]);\n\n  const zoomBy = useCallback(\n    (factor: number) => {\n      const center = rangeStart + rangeDuration / 2;\n      const newDuration = Math.min(totalSec, rangeDuration * factor);\n      if (newDuration < 0.01) return;\n\n      const newStart = Math.max(0, center - newDuration / 2);\n      const newEnd = Math.min(totalSec, newStart + newDuration);\n\n      if (newEnd - newStart >= totalSec * 0.99) {\n        setViewRange(null);\n      } else {\n        setViewRange([newStart, newEnd]);\n      }\n    },\n    [rangeStart, rangeDuration, totalSec],\n  );\n\n  const resetZoom = useCallback(() => setViewRange(null), []);\n\n  if (result.bars.length === 0 || totalSec === 0) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground text-sm\">\n        Not enough data for timeline\n      </div>\n    );\n  }\n\n  // Drag selection\n  const dragLeftPct =\n    dragStart !== null && dragCurrent !== null\n      ? ((Math.min(dragStart, dragCurrent) - rangeStart) / rangeDuration) * 100\n      : 0;\n  const dragWidthPct =\n    dragStart !== null && dragCurrent !== null\n      ? (Math.abs(dragCurrent - dragStart) / rangeDuration) * 100\n      : 0;\n\n  return (\n    <Tooltip.Provider delayDuration={200}>\n      <div className=\"h-full overflow-auto p-4\">\n        {/* ── Header ── */}\n        <div className=\"flex items-center gap-2 mb-3 flex-wrap\">\n          <span className=\"text-xs font-medium text-muted-foreground\">\n            Total: {formatDuration(totalSec)}\n          </span>\n\n          {/* Sort mode toggle */}\n          <div className=\"flex items-center border rounded-md overflow-hidden ml-3\">\n            <button\n              onClick={() => setSortMode(\"hierarchy\")}\n              className={`px-2 py-1 text-xs flex items-center gap-1 transition-colors ${\n                sortMode === \"hierarchy\"\n                  ? \"bg-primary/10 text-primary\"\n                  : \"text-muted-foreground hover:bg-muted\"\n              }`}\n              title=\"Group by hierarchy (Turn > Step > Tool)\"\n            >\n              <Layers className=\"w-3 h-3\" />\n              Hierarchy\n            </button>\n            <button\n              onClick={() => setSortMode(\"chronological\")}\n              className={`px-2 py-1 text-xs flex items-center gap-1 transition-colors ${\n                sortMode === \"chronological\"\n                  ? \"bg-primary/10 text-primary\"\n                  : \"text-muted-foreground hover:bg-muted\"\n              }`}\n              title=\"Sort by start time\"\n            >\n              <ArrowDownNarrowWide className=\"w-3 h-3\" />\n              Timeline\n            </button>\n          </div>\n\n          {/* Gap toggle */}\n          {gaps.length > 0 && (\n            <button\n              onClick={() => setShowGaps((v) => !v)}\n              className={`px-2 py-1 text-xs rounded-md border flex items-center gap-1 transition-colors ${\n                showGaps\n                  ? \"bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30\"\n                  : \"text-muted-foreground hover:bg-muted\"\n              }`}\n              title={`Show idle gaps (${gaps.length})`}\n            >\n              <Timer className=\"w-3 h-3\" />\n              Gaps ({gaps.length})\n            </button>\n          )}\n\n          {/* Token sparkline toggle */}\n          {tokenData.length >= 2 && (\n            <button\n              onClick={() => setShowTokens((v) => !v)}\n              className={`px-2 py-1 text-xs rounded-md border flex items-center gap-1 transition-colors ${\n                showTokens\n                  ? \"bg-primary/10 text-primary border-primary/30\"\n                  : \"text-muted-foreground hover:bg-muted\"\n              }`}\n              title=\"Show token usage\"\n            >\n              <Activity className=\"w-3 h-3\" />\n              Tokens\n            </button>\n          )}\n\n          {/* Zoom controls */}\n          <div className=\"flex items-center border rounded-md overflow-hidden ml-1\">\n            <button\n              onClick={() => zoomBy(0.5)}\n              className=\"px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors\"\n              title=\"Zoom in\"\n            >\n              <ZoomIn className=\"w-3 h-3\" />\n            </button>\n            <button\n              onClick={() => zoomBy(2)}\n              className=\"px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors border-l\"\n              title=\"Zoom out\"\n            >\n              <ZoomOut className=\"w-3 h-3\" />\n            </button>\n            {isZoomed && (\n              <button\n                onClick={resetZoom}\n                className=\"px-2 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors border-l\"\n                title=\"Reset zoom\"\n              >\n                Reset\n              </button>\n            )}\n          </div>\n\n          {/* Legend */}\n          <div className=\"flex items-center gap-3 ml-auto\">\n            <Legend color=\"blue\" label=\"Turn\" />\n            <Legend color=\"green\" label=\"Step\" />\n            <Legend color=\"purple\" label=\"Tool\" />\n            <Legend color=\"cyan\" label=\"Generation\" />\n            <Legend color=\"amber\" label=\"Approval\" />\n            {result.bars.some((b) => b.color === \"indigo\") && (\n              <Legend color=\"indigo\" label=\"Sub-agent\" />\n            )}\n          </div>\n        </div>\n\n        {/* ── Token Sparkline ── */}\n        {showTokens && (\n          <TokenSparkline\n            tokenData={tokenData}\n            rangeStart={rangeStart}\n            rangeDuration={rangeDuration}\n          />\n        )}\n\n        {/* ── Time Ruler ── */}\n        <div className=\"flex items-center h-4 mb-1\">\n          <div className=\"shrink-0 w-32\" />\n          <div className=\"flex-1 relative h-full border-b border-muted/30\">\n            {ticks.map((tick) => {\n              const leftPct = ((tick.sec - rangeStart) / rangeDuration) * 100;\n              if (leftPct < 0 || leftPct > 100) return null;\n              return (\n                <div\n                  key={tick.sec}\n                  className=\"absolute top-0 h-full\"\n                  style={{ left: `${leftPct}%` }}\n                >\n                  <div className=\"w-px h-full bg-muted-foreground/15\" />\n                  <span className=\"absolute top-0 ml-1 text-[8px] text-muted-foreground/60 whitespace-nowrap\">\n                    {tick.label}\n                  </span>\n                </div>\n              );\n            })}\n          </div>\n          <div className=\"shrink-0 w-16\" />\n        </div>\n\n        {/* ── Bar Area ── */}\n        <div\n          ref={trackContainerRef}\n          className=\"relative select-none\"\n          onMouseDown={handleMouseDown}\n          onMouseMove={handleMouseMove}\n          onMouseUp={handleMouseUp}\n          onMouseLeave={() => {\n            if (dragStart !== null) handleMouseUp();\n          }}\n        >\n          {/* Compaction marker overlays */}\n          {compactionMarkers.map((c) => {\n            const leftPct = ((c.startSec - rangeStart) / rangeDuration) * 100;\n            const widthPct = Math.max(\n              ((c.endSec - c.startSec) / rangeDuration) * 100,\n              0.3,\n            );\n            if (leftPct > 100 || leftPct + widthPct < 0) return null;\n            return (\n              <Tooltip.Root key={`c-${c.eventIndex}`}>\n                <Tooltip.Trigger asChild>\n                  <div\n                    className=\"absolute top-0 bottom-0 bg-orange-500/8 border-l border-dashed border-orange-500/40 z-10 cursor-pointer hover:bg-orange-500/15 transition-colors\"\n                    style={{\n                      left: `calc(${LABEL_W}px + (100% - ${LABEL_W}px - 72px) * ${leftPct / 100})`,\n                      width: `calc((100% - ${LABEL_W}px - 72px) * ${widthPct / 100})`,\n                    }}\n                    onClick={() => onScrollToIndex(c.eventIndex)}\n                  />\n                </Tooltip.Trigger>\n                <Tooltip.Portal>\n                  <Tooltip.Content\n                    className=\"rounded-md border bg-popover px-3 py-2 text-xs shadow-md z-50\"\n                    sideOffset={5}\n                  >\n                    <div className=\"font-medium text-orange-600 dark:text-orange-400\">\n                      Compaction\n                    </div>\n                    <div className=\"text-muted-foreground\">\n                      {formatDuration(c.endSec - c.startSec)}\n                    </div>\n                  </Tooltip.Content>\n                </Tooltip.Portal>\n              </Tooltip.Root>\n            );\n          })}\n\n          {/* Tick gridlines */}\n          {ticks.map((tick) => {\n            const leftPct = ((tick.sec - rangeStart) / rangeDuration) * 100;\n            if (leftPct < 0 || leftPct > 100) return null;\n            return (\n              <div\n                key={`grid-${tick.sec}`}\n                className=\"absolute top-0 bottom-0 pointer-events-none z-0\"\n                style={{\n                  left: `calc(${LABEL_W}px + (100% - ${LABEL_W}px - 72px) * ${leftPct / 100})`,\n                }}\n              >\n                <div className=\"w-px h-full bg-muted-foreground/5\" />\n              </div>\n            );\n          })}\n\n          {/* Drag selection overlay */}\n          {dragStart !== null && dragCurrent !== null && dragWidthPct > 0.5 && (\n            <div\n              className=\"absolute top-0 bottom-0 bg-blue-500/10 border border-blue-500/30 rounded-sm pointer-events-none z-20\"\n              style={{\n                left: `calc(${LABEL_W}px + (100% - ${LABEL_W}px - 72px) * ${dragLeftPct / 100})`,\n                width: `calc((100% - ${LABEL_W}px - 72px) * ${dragWidthPct / 100})`,\n              }}\n            />\n          )}\n\n          {/* Bars */}\n          <div className=\"space-y-0.5\">\n            {visibleBars.map((bar, i) => {\n              const leftPct = Math.max(\n                0,\n                ((bar.startSec - rangeStart) / rangeDuration) * 100,\n              );\n              const rightClamp = Math.min(bar.endSec, rangeEnd);\n              const leftClamp = Math.max(bar.startSec, rangeStart);\n              const widthPct = Math.max(\n                ((rightClamp - leftClamp) / rangeDuration) * 100,\n                0.5,\n              );\n              const colors = COLOR_MAP[bar.color] ?? COLOR_MAP.blue;\n              const indent =\n                sortMode === \"hierarchy\" ? bar.depth * 16 : bar.depth * 8;\n\n              return (\n                <Tooltip.Root key={`${bar.eventIndex}-${i}`}>\n                  <Tooltip.Trigger asChild>\n                    <div\n                      className=\"flex items-center gap-2 h-6 group cursor-pointer\"\n                      style={{ paddingLeft: `${indent}px` }}\n                      onClick={() => onScrollToIndex(bar.eventIndex)}\n                    >\n                      {/* Label */}\n                      <span\n                        className={`text-[11px] font-mono shrink-0 truncate ${colors.text} ${bar.hasError ? \"text-red-600 dark:text-red-400\" : \"\"}`}\n                        style={{ width: `${LABEL_W - indent}px` }}\n                      >\n                        {bar.label}\n                      </span>\n\n                      {/* Bar track */}\n                      <div\n                        className=\"flex-1 relative h-4 bg-muted/20 rounded-sm overflow-hidden\"\n                        data-bar-track\n                      >\n                        <div\n                          className={`absolute top-0 h-full rounded-sm border ${colors.bg} ${colors.border} ${\n                            bar.hasError\n                              ? \"bg-red-500/20 border-red-500/30\"\n                              : \"\"\n                          } ${bar.dashed ? \"border-dashed\" : \"\"} group-hover:brightness-125 transition-all`}\n                          style={{\n                            left: `${leftPct}%`,\n                            width: `${widthPct}%`,\n                            minWidth: \"2px\",\n                            ...(bar.striped\n                              ? {\n                                  backgroundImage:\n                                    \"repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(245,158,11,0.15) 3px, rgba(245,158,11,0.15) 6px)\",\n                                }\n                              : {}),\n                          }}\n                        />\n                      </div>\n\n                      {/* Duration */}\n                      <span className=\"text-[10px] font-mono text-muted-foreground w-16 shrink-0 text-right\">\n                        {formatDuration(bar.durationSec)}\n                      </span>\n                    </div>\n                  </Tooltip.Trigger>\n                  <Tooltip.Portal>\n                    <Tooltip.Content\n                      className=\"rounded-md border bg-popover px-3 py-2 text-xs shadow-md z-50 max-w-xs\"\n                      sideOffset={5}\n                    >\n                      <BarTooltipContent bar={bar} />\n                    </Tooltip.Content>\n                  </Tooltip.Portal>\n                </Tooltip.Root>\n              );\n            })}\n          </div>\n\n          {/* Gap indicators */}\n          {showGaps &&\n            gaps.map((gap, i) => {\n              const leftPct = Math.max(\n                0,\n                ((gap.startSec - rangeStart) / rangeDuration) * 100,\n              );\n              const widthPct = Math.max(\n                (gap.durationSec / rangeDuration) * 100,\n                0.5,\n              );\n              if (leftPct > 100) return null;\n              const indent =\n                sortMode === \"hierarchy\" ? gap.depth * 16 : gap.depth * 8;\n              return (\n                <div\n                  key={`gap-${i}`}\n                  className=\"flex items-center gap-2 h-5\"\n                  style={{ paddingLeft: `${indent}px` }}\n                >\n                  <div\n                    className=\"shrink-0\"\n                    style={{ width: `${LABEL_W - indent}px` }}\n                  />\n                  <div className=\"flex-1 relative h-3\">\n                    <div\n                      className=\"absolute top-0 h-full rounded-sm border border-dashed border-red-400/30 bg-red-500/5 flex items-center justify-center\"\n                      style={{\n                        left: `${leftPct}%`,\n                        width: `${widthPct}%`,\n                        minWidth: \"20px\",\n                      }}\n                    >\n                      <span className=\"text-[8px] font-mono text-red-400/70 px-1\">\n                        idle {formatDuration(gap.durationSec)}\n                      </span>\n                    </div>\n                  </div>\n                  <div className=\"shrink-0 w-16\" />\n                </div>\n              );\n            })}\n        </div>\n\n        {/* ── Zoom hint ── */}\n        {!isZoomed && (\n          <div className=\"text-[10px] text-muted-foreground/40 mt-2 text-center\">\n            Drag to zoom &middot; Click bar to jump to event\n          </div>\n        )}\n      </div>\n    </Tooltip.Provider>\n  );\n}\n\n// ─── Legend ──────────────────────────────────────────────────────────────────\n\nfunction Legend({ color, label }: { color: string; label: string }) {\n  const colors = COLOR_MAP[color] ?? COLOR_MAP.blue;\n  return (\n    <div className=\"flex items-center gap-1\">\n      <div\n        className={`w-3 h-2 rounded-sm ${colors.bg} border ${colors.border}`}\n      />\n      <span className=\"text-[10px] text-muted-foreground\">{label}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/tool-call-detail.tsx",
    "content": "import { useMemo, useState } from \"react\";\nimport type { WireEvent } from \"@/lib/api\";\nimport {\n  X,\n  Copy,\n  Check,\n  Clock,\n  AlertCircle,\n  ArrowRight,\n} from \"lucide-react\";\nimport { formatTimestamp } from \"./wire-event-card\";\n\ninterface ToolCallPair {\n  toolCall: WireEvent;\n  toolResult: WireEvent | null;\n  toolName: string;\n  toolCallId: string;\n  durationSec: number | null;\n  isError: boolean;\n}\n\ninterface ToolCallDetailProps {\n  /** The ToolCall or ToolResult event that was selected */\n  selectedEvent: WireEvent;\n  /** All events to find the matching pair */\n  allEvents: WireEvent[];\n  onClose: () => void;\n  /** Navigate to context messages for this tool_call_id */\n  onNavigateToContext?: (toolCallId: string) => void;\n}\n\nfunction findPair(\n  selected: WireEvent,\n  events: WireEvent[],\n): ToolCallPair | null {\n  let toolCallId: string | undefined;\n  let toolCall: WireEvent | undefined;\n  let toolResult: WireEvent | undefined;\n\n  if (selected.type === \"ToolCall\") {\n    toolCallId = selected.payload.id as string | undefined;\n    toolCall = selected;\n  } else if (selected.type === \"ToolResult\") {\n    toolCallId = selected.payload.tool_call_id as string | undefined;\n    toolResult = selected;\n  }\n\n  if (!toolCallId) return null;\n\n  // Find the matching pair\n  for (const e of events) {\n    if (\n      e.type === \"ToolCall\" &&\n      e.payload.id === toolCallId &&\n      !toolCall\n    ) {\n      toolCall = e;\n    }\n    if (\n      e.type === \"ToolResult\" &&\n      e.payload.tool_call_id === toolCallId &&\n      !toolResult\n    ) {\n      toolResult = e;\n    }\n  }\n\n  if (!toolCall) return null;\n\n  const fn = toolCall.payload.function as\n    | Record<string, unknown>\n    | undefined;\n  const toolName = (fn?.name as string) ?? \"unknown\";\n\n  const durationSec =\n    toolCall && toolResult\n      ? toolResult.timestamp - toolCall.timestamp\n      : null;\n\n  const rv = toolResult?.payload.return_value as\n    | Record<string, unknown>\n    | undefined;\n  const isError = rv?.is_error === true;\n\n  return {\n    toolCall,\n    toolResult: toolResult ?? null,\n    toolName,\n    toolCallId,\n    durationSec,\n    isError,\n  };\n}\n\nfunction formatDuration(sec: number): string {\n  if (sec < 0.001) return \"<1ms\";\n  if (sec < 1) return `${(sec * 1000).toFixed(0)}ms`;\n  if (sec < 60) return `${sec.toFixed(2)}s`;\n  return `${(sec / 60).toFixed(1)}min`;\n}\n\nexport function ToolCallDetail({\n  selectedEvent,\n  allEvents,\n  onClose,\n  onNavigateToContext,\n}: ToolCallDetailProps) {\n  const pair = useMemo(\n    () => findPair(selectedEvent, allEvents),\n    [selectedEvent, allEvents],\n  );\n\n  if (!pair) return null;\n\n  return (\n    <div className=\"border-t bg-background flex flex-col max-h-[40vh] shrink-0\">\n      {/* Title bar */}\n      <div className=\"flex items-center gap-2 px-3 py-1.5 border-b bg-muted/20 shrink-0\">\n        <span className=\"text-xs font-medium text-purple-600 dark:text-purple-400\">\n          {pair.toolName}\n        </span>\n        <span className=\"text-[10px] font-mono text-muted-foreground bg-purple-500/10 px-1 rounded\">\n          {pair.toolCallId.slice(0, 16)}\n        </span>\n        {pair.durationSec != null && (\n          <span className=\"flex items-center gap-0.5 text-[10px] text-muted-foreground\">\n            <Clock size={10} />\n            {formatDuration(pair.durationSec)}\n          </span>\n        )}\n        {pair.isError && (\n          <span className=\"flex items-center gap-0.5 text-[10px] text-red-500\">\n            <AlertCircle size={10} />\n            Error\n          </span>\n        )}\n        {onNavigateToContext && (\n          <button\n            onClick={() => onNavigateToContext(pair.toolCallId)}\n            className=\"flex items-center gap-0.5 text-[10px] text-blue-600 dark:text-blue-400 hover:underline ml-1\"\n            title=\"View in Context Messages\"\n          >\n            <ArrowRight size={10} />\n            Context\n          </button>\n        )}\n        <button\n          onClick={onClose}\n          className=\"ml-auto rounded p-0.5 hover:bg-muted text-muted-foreground\"\n        >\n          <X size={14} />\n        </button>\n      </div>\n\n      {/* Split view */}\n      <div className=\"flex flex-1 overflow-hidden min-h-0\">\n        {/* Left: ToolCall args */}\n        <div className=\"flex-1 flex flex-col overflow-hidden border-r\">\n          <div className=\"px-2 py-1 border-b text-[10px] font-medium text-muted-foreground bg-muted/10 shrink-0\">\n            ToolCall · {formatTimestamp(pair.toolCall.timestamp)}\n          </div>\n          <CopyableJson\n            data={getCallArgs(pair.toolCall)}\n          />\n        </div>\n\n        {/* Right: ToolResult */}\n        <div className=\"flex-1 flex flex-col overflow-hidden\">\n          <div\n            className={`px-2 py-1 border-b text-[10px] font-medium bg-muted/10 shrink-0 ${\n              pair.isError\n                ? \"text-red-600 dark:text-red-400\"\n                : \"text-muted-foreground\"\n            }`}\n          >\n            {pair.toolResult\n              ? `ToolResult · ${formatTimestamp(pair.toolResult.timestamp)}`\n              : \"ToolResult · (pending)\"}\n          </div>\n          {pair.toolResult ? (\n            <CopyableJson\n              data={getResultOutput(pair.toolResult)}\n            />\n          ) : (\n            <div className=\"flex items-center justify-center flex-1 text-xs text-muted-foreground\">\n              No result yet\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction getCallArgs(event: WireEvent): unknown {\n  const fn = event.payload.function as Record<string, unknown> | undefined;\n  if (!fn) return event.payload;\n  const argsStr = fn.arguments as string | undefined;\n  if (argsStr) {\n    try {\n      return JSON.parse(argsStr);\n    } catch {\n      return argsStr;\n    }\n  }\n  return fn;\n}\n\nfunction getResultOutput(event: WireEvent): unknown {\n  const rv = event.payload.return_value as Record<string, unknown> | undefined;\n  if (!rv) return event.payload;\n  return rv;\n}\n\nfunction CopyableJson({ data }: { data: unknown }) {\n  const [copied, setCopied] = useState(false);\n  const text =\n    typeof data === \"string\" ? data : JSON.stringify(data, null, 2);\n\n  const handleCopy = async () => {\n    await navigator.clipboard.writeText(text);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  return (\n    <div className=\"relative flex-1 overflow-auto group/json\">\n      <button\n        onClick={handleCopy}\n        className=\"absolute top-1 right-1 rounded p-1 hover:bg-muted text-muted-foreground opacity-0 group-hover/json:opacity-100 transition-opacity z-10\"\n        title=\"Copy\"\n      >\n        {copied ? <Check size={12} /> : <Copy size={12} />}\n      </button>\n      <pre className=\"p-2 text-[11px] font-mono leading-relaxed whitespace-pre-wrap break-all\">\n        {text}\n      </pre>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/tool-stats-dashboard.tsx",
    "content": "import { useMemo, useState } from \"react\";\nimport type { WireEvent } from \"@/lib/api\";\n\ninterface ToolStat {\n  name: string;\n  totalCalls: number;\n  successCount: number;\n  failureCount: number;\n  successRate: number;\n  avgDurationSec: number;\n  minDurationSec: number;\n  maxDurationSec: number;\n}\n\ninterface ToolStatsDashboardProps {\n  events: WireEvent[];\n  onScrollToIndex: (idx: number) => void;\n}\n\ntype SortMode = \"failureRate\" | \"callCount\";\n\nfunction formatDuration(sec: number): string {\n  if (sec < 0.001) return \"<1ms\";\n  if (sec < 1) return `${Math.round(sec * 1000)}ms`;\n  if (sec < 60) return `${sec.toFixed(1)}s`;\n  return `${(sec / 60).toFixed(1)}min`;\n}\n\nfunction rateColorClass(rate: number): string {\n  if (rate > 80) return \"text-green-500\";\n  if (rate >= 50) return \"text-amber-500\";\n  return \"text-red-500\";\n}\n\nexport function ToolStatsDashboard({ events, onScrollToIndex }: ToolStatsDashboardProps) {\n  const [sortMode, setSortMode] = useState<SortMode>(\"failureRate\");\n\n  const stats = useMemo(() => {\n    // Track open tool calls: toolCallId -> { name, startTimestamp, eventIndex }\n    const openCalls = new Map<\n      string,\n      { name: string; startTimestamp: number; eventIndex: number }\n    >();\n\n    // Aggregate per tool name\n    const agg = new Map<\n      string,\n      {\n        totalCalls: number;\n        successCount: number;\n        failureCount: number;\n        durations: number[];\n      }\n    >();\n\n    for (const e of events) {\n      if (e.type === \"ToolCall\") {\n        const id = e.payload.id as string | undefined;\n        const fn = e.payload.function as\n          | { name: string; arguments?: string }\n          | undefined;\n        if (id && fn?.name) {\n          openCalls.set(id, {\n            name: fn.name,\n            startTimestamp: e.timestamp,\n            eventIndex: e.index,\n          });\n        }\n      } else if (e.type === \"ToolResult\") {\n        const tcId = e.payload.tool_call_id as string | undefined;\n        if (!tcId) continue;\n        const open = openCalls.get(tcId);\n        if (!open) continue;\n\n        const duration = Math.max(0, e.timestamp - open.startTimestamp);\n        const returnValue = e.payload.return_value as\n          | { is_error?: boolean }\n          | undefined;\n        const isError = returnValue?.is_error === true;\n\n        let entry = agg.get(open.name);\n        if (!entry) {\n          entry = { totalCalls: 0, successCount: 0, failureCount: 0, durations: [] };\n          agg.set(open.name, entry);\n        }\n        entry.totalCalls++;\n        if (isError) {\n          entry.failureCount++;\n        } else {\n          entry.successCount++;\n        }\n        entry.durations.push(duration);\n\n        openCalls.delete(tcId);\n      }\n    }\n\n    const result: ToolStat[] = [];\n    for (const [name, data] of agg) {\n      const durations = data.durations;\n      const avg =\n        durations.length > 0\n          ? durations.reduce((s, d) => s + d, 0) / durations.length\n          : 0;\n      const min = durations.length > 0 ? Math.min(...durations) : 0;\n      const max = durations.length > 0 ? Math.max(...durations) : 0;\n\n      result.push({\n        name,\n        totalCalls: data.totalCalls,\n        successCount: data.successCount,\n        failureCount: data.failureCount,\n        successRate:\n          data.totalCalls > 0\n            ? (data.successCount / data.totalCalls) * 100\n            : 0,\n        avgDurationSec: avg,\n        minDurationSec: min,\n        maxDurationSec: max,\n      });\n    }\n\n    return result;\n  }, [events]);\n\n  const sorted = useMemo(() => {\n    const copy = [...stats];\n    if (sortMode === \"failureRate\") {\n      // Failure rate descending (higher failure rate first)\n      copy.sort((a, b) => {\n        const aFailRate = a.totalCalls > 0 ? a.failureCount / a.totalCalls : 0;\n        const bFailRate = b.totalCalls > 0 ? b.failureCount / b.totalCalls : 0;\n        return bFailRate - aFailRate;\n      });\n    } else {\n      copy.sort((a, b) => b.totalCalls - a.totalCalls);\n    }\n    return copy;\n  }, [stats, sortMode]);\n\n  if (sorted.length === 0) return null;\n\n  return (\n    <div className=\"border-b px-4 py-2 shrink-0\">\n      <div className=\"flex items-center gap-2 mb-1.5\">\n        <span className=\"text-[10px] font-medium text-muted-foreground\">\n          Tool Call Success Rates\n        </span>\n        <span className=\"text-[10px] text-muted-foreground\">\n          ({sorted.length} tools)\n        </span>\n        <div className=\"ml-auto flex items-center gap-1\">\n          <button\n            onClick={() => setSortMode(\"failureRate\")}\n            className={`text-[10px] px-1.5 py-0.5 rounded border transition-colors ${\n              sortMode === \"failureRate\"\n                ? \"bg-primary/10 text-primary border-primary/30\"\n                : \"text-muted-foreground border-transparent hover:text-foreground\"\n            }`}\n          >\n            By Failure\n          </button>\n          <button\n            onClick={() => setSortMode(\"callCount\")}\n            className={`text-[10px] px-1.5 py-0.5 rounded border transition-colors ${\n              sortMode === \"callCount\"\n                ? \"bg-primary/10 text-primary border-primary/30\"\n                : \"text-muted-foreground border-transparent hover:text-foreground\"\n            }`}\n          >\n            By Count\n          </button>\n        </div>\n      </div>\n\n      <div className=\"overflow-x-auto\">\n        <table className=\"w-full text-[11px]\">\n          <thead>\n            <tr className=\"text-muted-foreground text-left\">\n              <th className=\"font-medium pr-3 py-0.5\">Tool Name</th>\n              <th className=\"font-medium px-2 py-0.5 text-right\">Calls</th>\n              <th className=\"font-medium px-2 py-0.5 text-right\">Success</th>\n              <th className=\"font-medium px-2 py-0.5 text-right\">Fail</th>\n              <th className=\"font-medium px-2 py-0.5 text-right\">Rate</th>\n              <th className=\"font-medium px-2 py-0.5 text-right\">Avg</th>\n              <th className=\"font-medium px-2 py-0.5 text-right\">Min</th>\n              <th className=\"font-medium pl-2 py-0.5 text-right\">Max</th>\n            </tr>\n          </thead>\n          <tbody>\n            {sorted.map((tool) => {\n              const failureRate =\n                tool.totalCalls > 0\n                  ? (tool.failureCount / tool.totalCalls) * 100\n                  : 0;\n              const rowHighlight =\n                failureRate > 20 ? \"bg-red-500/10\" : \"\";\n\n              return (\n                <tr\n                  key={tool.name}\n                  className={`${rowHighlight} hover:bg-muted/30 transition-colors`}\n                >\n                  <td className=\"pr-3 py-0.5 text-foreground truncate max-w-[180px]\">\n                    {tool.name}\n                  </td>\n                  <td className=\"px-2 py-0.5 text-right tabular-nums text-muted-foreground\">\n                    {tool.totalCalls}\n                  </td>\n                  <td className=\"px-2 py-0.5 text-right tabular-nums text-green-500\">\n                    {tool.successCount}\n                  </td>\n                  <td className=\"px-2 py-0.5 text-right tabular-nums text-red-500\">\n                    {tool.failureCount}\n                  </td>\n                  <td\n                    className={`px-2 py-0.5 text-right tabular-nums font-medium ${rateColorClass(tool.successRate)}`}\n                  >\n                    {tool.successRate.toFixed(0)}%\n                  </td>\n                  <td className=\"px-2 py-0.5 text-right tabular-nums text-muted-foreground\">\n                    {formatDuration(tool.avgDurationSec)}\n                  </td>\n                  <td className=\"px-2 py-0.5 text-right tabular-nums text-muted-foreground\">\n                    {formatDuration(tool.minDurationSec)}\n                  </td>\n                  <td className=\"pl-2 py-0.5 text-right tabular-nums text-muted-foreground\">\n                    {formatDuration(tool.maxDurationSec)}\n                  </td>\n                </tr>\n              );\n            })}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/turn-efficiency.tsx",
    "content": "import { useMemo } from \"react\";\nimport { AlertTriangle } from \"lucide-react\";\nimport type { WireEvent } from \"@/lib/api\";\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\ninterface TurnEfficiencyProps {\n  events: WireEvent[];\n  onScrollToIndex: (idx: number) => void;\n}\n\ninterface TurnMetrics {\n  turnNumber: number;\n  eventIndex: number;\n  stepCount: number;\n  toolCallCount: number;\n  inputTokensDelta: number;\n  outputTokensDelta: number;\n  durationSec: number;\n  isAnomaly: boolean;\n  anomalyReasons: string[];\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────────\n\nfunction formatDuration(sec: number): string {\n  if (sec < 0.001) return \"<1ms\";\n  if (sec < 1) return `${(sec * 1000).toFixed(0)}ms`;\n  if (sec < 60) return `${sec.toFixed(2)}s`;\n  return `${(sec / 60).toFixed(1)}min`;\n}\n\nfunction mean(values: number[]): number {\n  if (values.length === 0) return 0;\n  return values.reduce((a, b) => a + b, 0) / values.length;\n}\n\nfunction stddev(values: number[], avg: number): number {\n  if (values.length === 0) return 0;\n  const variance =\n    values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length;\n  return Math.sqrt(variance);\n}\n\n// ─── Computation ────────────────────────────────────────────────────────────\n\nfunction computeTurnMetrics(events: WireEvent[]): TurnMetrics[] {\n  const raw: Omit<TurnMetrics, \"isAnomaly\" | \"anomalyReasons\">[] = [];\n\n  let turnNumber = 0;\n  let turnStartIndex = -1;\n  let turnStartTimestamp = 0;\n  let stepCount = 0;\n  let toolCallCount = 0;\n  let turnInputTokens = 0;\n  let turnOutputTokens = 0;\n  let inTurn = false;\n\n  for (const event of events) {\n    // Accumulate token usage within the current turn\n    if (event.type === \"StatusUpdate\" && inTurn) {\n      const usage = event.payload.token_usage as Record<string, number> | undefined;\n      if (usage) {\n        turnInputTokens += (usage.input_other ?? 0) + (usage.input_cache_read ?? 0) + (usage.input_cache_creation ?? 0);\n        turnOutputTokens += usage.output ?? 0;\n      }\n    }\n\n    if (event.type === \"TurnBegin\") {\n      turnNumber++;\n      turnStartIndex = event.index;\n      turnStartTimestamp = event.timestamp;\n      stepCount = 0;\n      toolCallCount = 0;\n      turnInputTokens = 0;\n      turnOutputTokens = 0;\n      inTurn = true;\n    } else if (event.type === \"StepBegin\" && inTurn) {\n      stepCount++;\n    } else if (event.type === \"ToolCall\" && inTurn) {\n      toolCallCount++;\n    } else if (event.type === \"TurnEnd\" && inTurn) {\n      const durationSec = event.timestamp - turnStartTimestamp;\n      raw.push({\n        turnNumber,\n        eventIndex: turnStartIndex,\n        stepCount,\n        toolCallCount,\n        inputTokensDelta: turnInputTokens,\n        outputTokensDelta: turnOutputTokens,\n        durationSec: durationSec > 0 ? durationSec : 0,\n      });\n      inTurn = false;\n    }\n  }\n\n  // Handle unclosed turn\n  if (inTurn) {\n    const lastEvent = events[events.length - 1];\n    const durationSec = lastEvent\n      ? lastEvent.timestamp - turnStartTimestamp\n      : 0;\n    raw.push({\n      turnNumber,\n      eventIndex: turnStartIndex,\n      stepCount,\n      toolCallCount,\n      inputTokensDelta: turnInputTokens,\n      outputTokensDelta: turnOutputTokens,\n      durationSec: durationSec > 0 ? durationSec : 0,\n    });\n  }\n\n  if (raw.length === 0) return [];\n\n  // Anomaly detection: mean + 2*stddev for stepCount and totalTokens\n  const stepCounts = raw.map((t) => t.stepCount);\n  const totalTokens = raw.map(\n    (t) => t.inputTokensDelta + t.outputTokensDelta,\n  );\n\n  const stepMean = mean(stepCounts);\n  const stepStd = stddev(stepCounts, stepMean);\n  const stepThreshold = stepMean + 2 * stepStd;\n\n  const tokenMean = mean(totalTokens);\n  const tokenStd = stddev(totalTokens, tokenMean);\n  const tokenThreshold = tokenMean + 2 * tokenStd;\n\n  return raw.map((t) => {\n    const reasons: string[] = [];\n    const total = t.inputTokensDelta + t.outputTokensDelta;\n\n    if (stepStd > 0 && t.stepCount > stepThreshold) {\n      reasons.push(\n        `Steps (${t.stepCount}) > μ+2σ (${stepThreshold.toFixed(1)})`,\n      );\n    }\n    if (tokenStd > 0 && total > tokenThreshold) {\n      reasons.push(\n        `Tokens (${total.toLocaleString()}) > μ+2σ (${tokenThreshold.toFixed(0)})`,\n      );\n    }\n\n    return {\n      ...t,\n      isAnomaly: reasons.length > 0,\n      anomalyReasons: reasons,\n    };\n  });\n}\n\n// ─── Component ──────────────────────────────────────────────────────────────\n\nexport function TurnEfficiency({ events, onScrollToIndex }: TurnEfficiencyProps) {\n  const turns = useMemo(() => computeTurnMetrics(events), [events]);\n\n  if (turns.length === 0) {\n    return (\n      <div className=\"border-t px-3 py-2 text-xs text-muted-foreground\">\n        No turn data available.\n      </div>\n    );\n  }\n\n  const anomalyCount = turns.filter((t) => t.isAnomaly).length;\n\n  return (\n    <div className=\"border-t\">\n      <div className=\"flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground border-b bg-muted/30\">\n        <span className=\"font-medium text-foreground\">Turn Efficiency</span>\n        <span>\n          {turns.length} turn{turns.length !== 1 && \"s\"}\n        </span>\n        {anomalyCount > 0 && (\n          <span className=\"flex items-center gap-1 text-amber-500\">\n            <AlertTriangle className=\"h-3 w-3\" />\n            {anomalyCount} anomal{anomalyCount !== 1 ? \"ies\" : \"y\"}\n          </span>\n        )}\n      </div>\n      <div className=\"max-h-[240px] overflow-auto\">\n        <table className=\"w-full text-[11px]\">\n          <thead className=\"sticky top-0 bg-background\">\n            <tr className=\"text-left text-muted-foreground\">\n              <th className=\"px-2 py-1 font-medium\">Turn</th>\n              <th className=\"px-2 py-1 font-medium text-right\">Steps</th>\n              <th className=\"px-2 py-1 font-medium text-right\">Tools</th>\n              <th className=\"px-2 py-1 font-medium text-right\">Input Tokens</th>\n              <th className=\"px-2 py-1 font-medium text-right\">Output Tokens</th>\n              <th className=\"px-2 py-1 font-medium text-right\">Duration</th>\n              <th className=\"px-2 py-1 font-medium\">Status</th>\n            </tr>\n          </thead>\n          <tbody>\n            {turns.map((turn) => (\n              <tr\n                key={turn.turnNumber}\n                className={`cursor-pointer border-t border-border/50 hover:bg-muted/50 transition-colors ${\n                  turn.isAnomaly ? \"bg-amber-500/10\" : \"\"\n                }`}\n                onClick={() => onScrollToIndex(turn.eventIndex)}\n              >\n                <td className=\"px-2 py-1 font-mono text-xs\">\n                  #{turn.turnNumber}\n                </td>\n                <td className=\"px-2 py-1 text-right tabular-nums\">\n                  {turn.stepCount}\n                </td>\n                <td className=\"px-2 py-1 text-right tabular-nums\">\n                  {turn.toolCallCount}\n                </td>\n                <td className=\"px-2 py-1 text-right tabular-nums\">\n                  {turn.inputTokensDelta.toLocaleString()}\n                </td>\n                <td className=\"px-2 py-1 text-right tabular-nums\">\n                  {turn.outputTokensDelta.toLocaleString()}\n                </td>\n                <td className=\"px-2 py-1 text-right tabular-nums\">\n                  {formatDuration(turn.durationSec)}\n                </td>\n                <td className=\"px-2 py-1\">\n                  {turn.isAnomaly ? (\n                    <span className=\"group relative inline-flex items-center gap-1 text-amber-500\">\n                      <AlertTriangle className=\"h-3 w-3\" />\n                      <span className=\"text-[10px]\">anomaly</span>\n                      <span className=\"pointer-events-none absolute bottom-full left-0 z-10 mb-1 hidden w-48 rounded border bg-popover p-1.5 text-[10px] text-popover-foreground shadow-md group-hover:block\">\n                        {turn.anomalyReasons.map((r, i) => (\n                          <div key={i}>{r}</div>\n                        ))}\n                      </span>\n                    </span>\n                  ) : (\n                    <span className=\"text-muted-foreground\">ok</span>\n                  )}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/turn-tree.tsx",
    "content": "import { useMemo, useState } from \"react\";\nimport { type WireEvent } from \"@/lib/api\";\nimport { isErrorEvent } from \"./wire-event-card\";\nimport {\n  ChevronDown,\n  ChevronRight,\n  AlertCircle,\n  RefreshCw,\n  PanelLeftClose,\n  PanelLeft,\n  Bot,\n} from \"lucide-react\";\n\ninterface ToolCallNode {\n  eventIndex: number;\n  name: string;\n  hasError: boolean;\n}\n\ninterface SubagentNode {\n  eventIndex: number;\n  taskToolCallId: string;\n  innerEvents: { type: string; summary: string }[];\n}\n\ninterface StepNode {\n  eventIndex: number;\n  stepNumber: number;\n  toolCalls: ToolCallNode[];\n  subagents: SubagentNode[];\n  hasError: boolean;\n}\n\ninterface TurnNode {\n  eventIndex: number;\n  turnNumber: number;\n  userInput: string;\n  steps: StepNode[];\n  hasCompaction: boolean;\n  hasError: boolean;\n}\n\nfunction buildTree(events: WireEvent[]): TurnNode[] {\n  const turns: TurnNode[] = [];\n  const toolCallIdMap = new Map<string, ToolCallNode>();\n  // Track subagent events grouped by task_tool_call_id per step\n  const subagentMap = new Map<string, SubagentNode>();\n  let currentTurn: TurnNode | null = null;\n  let currentStep: StepNode | null = null;\n\n  for (const event of events) {\n    if (event.type === \"TurnBegin\") {\n      const p = event.payload;\n      let input = \"\";\n      if (typeof p.user_input === \"string\") {\n        input = p.user_input.slice(0, 60);\n      } else if (Array.isArray(p.user_input) && p.user_input.length > 0) {\n        const first = p.user_input[0] as Record<string, unknown>;\n        input = String(first.text ?? \"\").slice(0, 60);\n      }\n\n      currentTurn = {\n        eventIndex: event.index,\n        turnNumber: turns.length + 1,\n        userInput: input,\n        steps: [],\n        hasCompaction: false,\n        hasError: false,\n      };\n      turns.push(currentTurn);\n      currentStep = null;\n    } else if (event.type === \"StepBegin\" && currentTurn) {\n      currentStep = {\n        eventIndex: event.index,\n        stepNumber: currentTurn.steps.length + 1,\n        toolCalls: [],\n        subagents: [],\n        hasError: false,\n      };\n      currentTurn.steps.push(currentStep);\n      subagentMap.clear();\n    } else if (event.type === \"ToolCall\" && currentStep) {\n      const fn = event.payload.function as Record<string, unknown> | undefined;\n      const name = (fn?.name as string) ?? \"tool\";\n      const id = event.payload.id as string | undefined;\n      currentStep.toolCalls.push({\n        eventIndex: event.index,\n        name,\n        hasError: false,\n      });\n      if (id) toolCallIdMap.set(id, currentStep.toolCalls[currentStep.toolCalls.length - 1]);\n    } else if (event.type === \"ToolResult\") {\n      const tcId = event.payload.tool_call_id as string | undefined;\n      const rv = event.payload.return_value as Record<string, unknown> | undefined;\n      if (tcId && rv?.is_error === true) {\n        const tc = toolCallIdMap.get(tcId);\n        if (tc) tc.hasError = true;\n      }\n    } else if (event.type === \"SubagentEvent\" && currentStep) {\n      const taskId = event.payload.task_tool_call_id as string ?? \"\";\n      const inner = event.payload.event as Record<string, unknown> | undefined;\n      const innerType = (inner?.type as string) ?? \"\";\n      const innerPayload = (inner?.payload as Record<string, unknown>) ?? {};\n      let summary = innerType;\n      if (innerType === \"ToolCall\") {\n        const fn = innerPayload.function as Record<string, unknown> | undefined;\n        summary = fn?.name as string ?? \"tool\";\n      } else if (innerType === \"TurnBegin\") {\n        summary = \"TurnBegin\";\n      }\n\n      let node = subagentMap.get(taskId);\n      if (!node) {\n        node = { eventIndex: event.index, taskToolCallId: taskId, innerEvents: [] };\n        subagentMap.set(taskId, node);\n        currentStep.subagents.push(node);\n      }\n      node.innerEvents.push({ type: innerType, summary });\n    } else if (event.type === \"CompactionBegin\" && currentTurn) {\n      currentTurn.hasCompaction = true;\n    } else if (isErrorEvent(event)) {\n      if (currentStep) currentStep.hasError = true;\n      if (currentTurn) currentTurn.hasError = true;\n    }\n  }\n\n  return turns;\n}\n\ninterface TurnTreeProps {\n  events: WireEvent[];\n  collapsed: boolean;\n  onToggleCollapse: () => void;\n  onScrollToIndex: (eventIndex: number) => void;\n  /** Currently visible event index range for highlighting */\n  visibleRange?: [number, number];\n}\n\nexport function TurnTree({\n  events,\n  collapsed,\n  onToggleCollapse,\n  onScrollToIndex,\n  visibleRange,\n}: TurnTreeProps) {\n  const tree = useMemo(() => buildTree(events), [events]);\n\n  if (collapsed) {\n    return (\n      <div className=\"flex flex-col items-center border-r py-2 px-1\">\n        <button\n          onClick={onToggleCollapse}\n          className=\"rounded p-1 hover:bg-muted text-muted-foreground\"\n          title=\"Show navigation\"\n        >\n          <PanelLeft size={14} />\n        </button>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col border-r w-56 shrink-0 overflow-hidden\">\n      <div className=\"flex items-center justify-between px-2 py-1.5 border-b\">\n        <span className=\"text-[11px] font-medium text-muted-foreground\">\n          Navigation\n        </span>\n        <button\n          onClick={onToggleCollapse}\n          className=\"rounded p-0.5 hover:bg-muted text-muted-foreground\"\n          title=\"Hide navigation\"\n        >\n          <PanelLeftClose size={13} />\n        </button>\n      </div>\n      <div className=\"flex-1 overflow-auto py-1\">\n        {tree.map((turn) => (\n          <TurnNodeItem\n            key={turn.eventIndex}\n            turn={turn}\n            onScrollToIndex={onScrollToIndex}\n            visibleRange={visibleRange}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction TurnNodeItem({\n  turn,\n  onScrollToIndex,\n  visibleRange,\n}: {\n  turn: TurnNode;\n  onScrollToIndex: (idx: number) => void;\n  visibleRange?: [number, number];\n}) {\n  const [expanded, setExpanded] = useState(true);\n\n  const isActive =\n    visibleRange &&\n    turn.eventIndex >= visibleRange[0] &&\n    turn.eventIndex <= visibleRange[1];\n\n  return (\n    <div className=\"text-[11px]\">\n      <button\n        onClick={() => setExpanded((v) => !v)}\n        className={`flex items-center gap-1 w-full px-2 py-1 text-left hover:bg-muted/50 transition-colors ${\n          isActive ? \"bg-muted/40 text-foreground\" : \"text-muted-foreground\"\n        } ${turn.hasError ? \"text-red-600 dark:text-red-400\" : \"\"}`}\n      >\n        {expanded ? (\n          <ChevronDown size={10} className=\"shrink-0 opacity-60\" />\n        ) : (\n          <ChevronRight size={10} className=\"shrink-0 opacity-60\" />\n        )}\n        <span className=\"font-medium shrink-0\">Turn {turn.turnNumber}</span>\n        {turn.hasError && <AlertCircle size={10} className=\"shrink-0 text-red-500\" />}\n        {turn.hasCompaction && (\n          <RefreshCw size={9} className=\"shrink-0 text-orange-500\" />\n        )}\n      </button>\n      {expanded && (\n        <>\n          {turn.userInput && (\n            <div\n              className=\"pl-6 pr-2 py-0.5 text-[10px] text-muted-foreground truncate cursor-pointer hover:bg-muted/30\"\n              onClick={() => onScrollToIndex(turn.eventIndex)}\n              title={turn.userInput}\n            >\n              &quot;{turn.userInput}&quot;\n            </div>\n          )}\n          {turn.steps.map((step) => (\n            <StepNodeItem\n              key={step.eventIndex}\n              step={step}\n              onScrollToIndex={onScrollToIndex}\n              visibleRange={visibleRange}\n            />\n          ))}\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction StepNodeItem({\n  step,\n  onScrollToIndex,\n  visibleRange,\n}: {\n  step: StepNode;\n  onScrollToIndex: (idx: number) => void;\n  visibleRange?: [number, number];\n}) {\n  const [expanded, setExpanded] = useState(false);\n  const hasChildren = step.toolCalls.length > 0 || step.subagents.length > 0;\n\n  const isActive =\n    visibleRange &&\n    step.eventIndex >= visibleRange[0] &&\n    step.eventIndex <= visibleRange[1];\n\n  return (\n    <div>\n      <button\n        onClick={() => {\n          if (hasChildren) {\n            setExpanded((v) => !v);\n          } else {\n            onScrollToIndex(step.eventIndex);\n          }\n        }}\n        className={`flex items-center gap-1 w-full pl-6 pr-2 py-0.5 text-left hover:bg-muted/50 transition-colors text-[11px] ${\n          isActive ? \"bg-muted/30 text-foreground\" : \"text-muted-foreground\"\n        } ${step.hasError ? \"text-red-600 dark:text-red-400\" : \"\"}`}\n      >\n        {hasChildren ? (\n          expanded ? (\n            <ChevronDown size={9} className=\"shrink-0 opacity-60\" />\n          ) : (\n            <ChevronRight size={9} className=\"shrink-0 opacity-60\" />\n          )\n        ) : (\n          <span className=\"shrink-0 w-[9px]\" />\n        )}\n        <span>Step {step.stepNumber}</span>\n        {hasChildren && (\n          <span className=\"text-[10px] opacity-60\">\n            ({step.toolCalls.length} tool{step.toolCalls.length > 1 ? \"s\" : \"\"})\n          </span>\n        )}\n        {step.hasError && <AlertCircle size={9} className=\"shrink-0 text-red-500\" />}\n      </button>\n      {expanded && (\n        <>\n          {step.toolCalls.map((tc) => (\n            <button\n              key={tc.eventIndex}\n              onClick={() => onScrollToIndex(tc.eventIndex)}\n              className={`flex items-center gap-1 w-full pl-10 pr-2 py-0.5 text-left hover:bg-muted/50 transition-colors text-[10px] text-muted-foreground ${\n                tc.hasError ? \"text-red-600 dark:text-red-400\" : \"\"\n              }`}\n            >\n              <span className=\"truncate\">{tc.name}</span>\n              {tc.hasError && <AlertCircle size={8} className=\"shrink-0 text-red-500\" />}\n            </button>\n          ))}\n          {step.subagents.map((sa) => (\n            <SubagentNodeItem key={sa.eventIndex} node={sa} onScrollToIndex={onScrollToIndex} />\n          ))}\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction SubagentNodeItem({\n  node,\n  onScrollToIndex,\n}: {\n  node: SubagentNode;\n  onScrollToIndex: (idx: number) => void;\n}) {\n  const [expanded, setExpanded] = useState(false);\n  const toolCalls = node.innerEvents.filter((e) => e.type === \"ToolCall\");\n  const turns = node.innerEvents.filter((e) => e.type === \"TurnBegin\").length;\n\n  return (\n    <div>\n      <button\n        onClick={() => {\n          if (toolCalls.length > 0) {\n            setExpanded((v) => !v);\n          } else {\n            onScrollToIndex(node.eventIndex);\n          }\n        }}\n        className=\"flex items-center gap-1 w-full pl-10 pr-2 py-0.5 text-left hover:bg-muted/50 transition-colors text-[10px] text-indigo-600 dark:text-indigo-400\"\n      >\n        {toolCalls.length > 0 ? (\n          expanded ? <ChevronDown size={8} className=\"shrink-0 opacity-60\" /> : <ChevronRight size={8} className=\"shrink-0 opacity-60\" />\n        ) : (\n          <span className=\"shrink-0 w-[8px]\" />\n        )}\n        <Bot size={9} className=\"shrink-0\" />\n        <span className=\"truncate\">task:{node.taskToolCallId.slice(0, 8)}</span>\n        <span className=\"opacity-60 shrink-0\">\n          {turns > 0 && `${turns}T `}{toolCalls.length > 0 && `${toolCalls.length}TC`}\n        </span>\n      </button>\n      {expanded && toolCalls.map((tc, i) => (\n        <button\n          key={i}\n          onClick={() => onScrollToIndex(node.eventIndex)}\n          className=\"flex items-center gap-1 w-full pl-14 pr-2 py-0.5 text-left hover:bg-muted/50 transition-colors text-[9px] text-muted-foreground truncate\"\n        >\n          {tc.summary}\n        </button>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/usage-chart.tsx",
    "content": "import { useMemo, useRef } from \"react\";\nimport type { WireEvent } from \"@/lib/api\";\n\n/* ------------------------------------------------------------------ */\n/*  ToolTokenBreakdown – horizontal bar chart of tokens per tool      */\n/* ------------------------------------------------------------------ */\n\ninterface ToolTokenBreakdownProps {\n  events: WireEvent[];\n}\n\ninterface ToolStats {\n  input: number;\n  output: number;\n  calls: number;\n}\n\nfunction formatTokens(n: number): string {\n  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;\n  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;\n  return String(n);\n}\n\nexport function ToolTokenBreakdown({ events }: ToolTokenBreakdownProps) {\n  const breakdown = useMemo(() => {\n    const byTool = new Map<string, ToolStats>();\n\n    // Track cumulative token totals from the most recent StatusUpdate\n    let lastInputTotal = 0;\n    let lastOutputTotal = 0;\n\n    // Open tool calls: id -> { name, inputAtStart, outputAtStart }\n    const openCalls = new Map<\n      string,\n      { name: string; inputAtStart: number; outputAtStart: number }\n    >();\n\n    for (const e of events) {\n      if (e.type === \"StatusUpdate\") {\n        const tu = e.payload.token_usage as Record<string, number> | undefined;\n        if (tu) {\n          lastInputTotal += (tu.input_other ?? 0) + (tu.input_cache_read ?? 0) + (tu.input_cache_creation ?? 0);\n          lastOutputTotal += tu.output ?? 0;\n        }\n      } else if (e.type === \"ToolCall\") {\n        const id = e.payload.id as string;\n        const fn = e.payload.function as\n          | { name: string; arguments: string }\n          | undefined;\n        if (id && fn?.name) {\n          openCalls.set(id, {\n            name: fn.name,\n            inputAtStart: lastInputTotal,\n            outputAtStart: lastOutputTotal,\n          });\n        }\n      } else if (e.type === \"ToolResult\") {\n        const toolCallId = e.payload.tool_call_id as string;\n        const open = openCalls.get(toolCallId);\n        if (open) {\n          const inputDelta = Math.max(0, lastInputTotal - open.inputAtStart);\n          const outputDelta = Math.max(0, lastOutputTotal - open.outputAtStart);\n\n          const existing = byTool.get(open.name) || {\n            input: 0,\n            output: 0,\n            calls: 0,\n          };\n          existing.input += inputDelta;\n          existing.output += outputDelta;\n          existing.calls += 1;\n          byTool.set(open.name, existing);\n\n          openCalls.delete(toolCallId);\n        }\n      }\n    }\n\n    // Sort by total tokens descending\n    const sorted = Array.from(byTool.entries())\n      .map(([name, stats]) => ({ name, ...stats, total: stats.input + stats.output }))\n      .sort((a, b) => b.total - a.total);\n\n    return sorted;\n  }, [events]);\n\n  if (breakdown.length === 0) return null;\n\n  const grandTotal = breakdown.reduce((sum, t) => sum + t.total, 0);\n  const maxTotal = breakdown[0].total;\n\n  return (\n    <div className=\"border-b px-4 py-2 shrink-0\">\n      <div className=\"flex items-center gap-2 mb-1.5\">\n        <span className=\"text-[10px] font-medium text-muted-foreground\">\n          Token Usage by Tool\n        </span>\n        <span className=\"text-[10px] text-muted-foreground\">\n          ({formatTokens(grandTotal)} total)\n        </span>\n      </div>\n\n      <div className=\"flex flex-col gap-1\">\n        {breakdown.map((tool) => {\n          const barWidthPct = maxTotal > 0 ? (tool.total / maxTotal) * 100 : 0;\n          const inputPct = tool.total > 0 ? (tool.input / tool.total) * 100 : 0;\n          const outputPct = tool.total > 0 ? (tool.output / tool.total) * 100 : 0;\n\n          return (\n            <div key={tool.name} className=\"flex items-center gap-2\">\n              {/* Tool name + call count */}\n              <div className=\"w-[140px] shrink-0 text-right pr-1\">\n                <span className=\"text-[11px] text-foreground truncate\">\n                  {tool.name}\n                </span>\n                <span className=\"text-[10px] text-muted-foreground ml-1\">\n                  x{tool.calls}\n                </span>\n              </div>\n\n              {/* Bar */}\n              <div className=\"flex-1 h-3 bg-muted/30 rounded-sm overflow-hidden\">\n                <div\n                  className=\"h-full flex rounded-sm\"\n                  style={{ width: `${barWidthPct}%` }}\n                >\n                  {/* Input tokens – blue */}\n                  <div\n                    className=\"h-full bg-blue-500/70\"\n                    style={{ width: `${inputPct}%` }}\n                  />\n                  {/* Output tokens – green */}\n                  <div\n                    className=\"h-full bg-green-500/70\"\n                    style={{ width: `${outputPct}%` }}\n                  />\n                </div>\n              </div>\n\n              {/* Token count */}\n              <span className=\"w-[56px] shrink-0 text-[10px] text-muted-foreground text-right tabular-nums\">\n                {formatTokens(tool.total)}\n              </span>\n            </div>\n          );\n        })}\n      </div>\n\n      {/* Legend */}\n      <div className=\"flex items-center gap-3 mt-1.5\">\n        <div className=\"flex items-center gap-1\">\n          <div className=\"w-2 h-2 rounded-sm bg-blue-500/70\" />\n          <span className=\"text-[10px] text-muted-foreground\">Input</span>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <div className=\"w-2 h-2 rounded-sm bg-green-500/70\" />\n          <span className=\"text-[10px] text-muted-foreground\">Output</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/*  UsageChart – existing SVG line chart (unchanged)                  */\n/* ------------------------------------------------------------------ */\n\ninterface UsageChartProps {\n  events: WireEvent[];\n  onScrollToIndex: (eventIndex: number) => void;\n}\n\ninterface DataPoint {\n  eventIndex: number;\n  x: number; // normalized 0-1\n  contextUsage: number; // 0-1\n}\n\ninterface CompactionMark {\n  eventIndex: number;\n  x: number;\n}\n\nconst CHART_HEIGHT = 80;\nconst CHART_PADDING_X = 32;\nconst CHART_PADDING_TOP = 8;\nconst CHART_PADDING_BOTTOM = 16;\n\nexport function UsageChart({ events, onScrollToIndex }: UsageChartProps) {\n  const svgRef = useRef<SVGSVGElement>(null);\n\n  const { dataPoints, compactions } = useMemo(() => {\n    const points: DataPoint[] = [];\n    const comps: CompactionMark[] = [];\n    const total = events.length;\n    if (total === 0) return { dataPoints: points, compactions: comps };\n\n    for (let i = 0; i < events.length; i++) {\n      const e = events[i];\n      if (e.type === \"StatusUpdate\" && e.payload.context_usage != null) {\n        points.push({\n          eventIndex: e.index,\n          x: total > 1 ? i / (total - 1) : 0.5,\n          contextUsage: e.payload.context_usage as number,\n        });\n      }\n      if (e.type === \"CompactionBegin\") {\n        comps.push({\n          eventIndex: e.index,\n          x: total > 1 ? i / (total - 1) : 0.5,\n        });\n      }\n    }\n    return { dataPoints: points, compactions: comps };\n  }, [events]);\n\n  if (dataPoints.length < 2) return null;\n\n  const chartWidth = 600;\n  const innerWidth = chartWidth - CHART_PADDING_X * 2;\n  const innerHeight =\n    CHART_HEIGHT - CHART_PADDING_TOP - CHART_PADDING_BOTTOM;\n\n  const toSvgX = (x: number) => CHART_PADDING_X + x * innerWidth;\n  const toSvgY = (usage: number) =>\n    CHART_PADDING_TOP + (1 - usage) * innerHeight;\n\n  // Build polyline path\n  const linePath = dataPoints\n    .map((p, i) => {\n      const x = toSvgX(p.x);\n      const y = toSvgY(p.contextUsage);\n      return `${i === 0 ? \"M\" : \"L\"} ${x} ${y}`;\n    })\n    .join(\" \");\n\n  // Build fill area (under the line, above the baseline)\n  const areaPath =\n    linePath +\n    ` L ${toSvgX(dataPoints[dataPoints.length - 1].x)} ${toSvgY(0)}` +\n    ` L ${toSvgX(dataPoints[0].x)} ${toSvgY(0)} Z`;\n\n  // 80% danger zone\n  const dangerY = toSvgY(0.8);\n\n  const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {\n    const svg = svgRef.current;\n    if (!svg) return;\n    const rect = svg.getBoundingClientRect();\n    const svgWidth = rect.width;\n    const clickX = e.clientX - rect.left;\n    // Convert to normalized x\n    const scale = chartWidth / svgWidth;\n    const scaledX = clickX * scale;\n    const normalizedX = (scaledX - CHART_PADDING_X) / innerWidth;\n    if (normalizedX < 0 || normalizedX > 1) return;\n\n    // Find closest data point\n    let closest = dataPoints[0];\n    let minDist = Math.abs(closest.x - normalizedX);\n    for (const p of dataPoints) {\n      const dist = Math.abs(p.x - normalizedX);\n      if (dist < minDist) {\n        minDist = dist;\n        closest = p;\n      }\n    }\n    onScrollToIndex(closest.eventIndex);\n  };\n\n  return (\n    <div className=\"border-b px-4 py-2 shrink-0\">\n      <div className=\"flex items-center gap-2 mb-1\">\n        <span className=\"text-[10px] font-medium text-muted-foreground\">\n          Context Usage\n        </span>\n        <span className=\"text-[10px] text-muted-foreground\">\n          ({dataPoints.length} data points)\n        </span>\n      </div>\n      <svg\n        ref={svgRef}\n        viewBox={`0 0 ${chartWidth} ${CHART_HEIGHT}`}\n        className=\"w-full cursor-crosshair\"\n        style={{ maxHeight: CHART_HEIGHT }}\n        onClick={handleClick}\n      >\n        {/* Danger zone background */}\n        <rect\n          x={CHART_PADDING_X}\n          y={CHART_PADDING_TOP}\n          width={innerWidth}\n          height={dangerY - CHART_PADDING_TOP}\n          className=\"fill-red-500/5\"\n        />\n\n        {/* 80% threshold line */}\n        <line\n          x1={CHART_PADDING_X}\n          y1={dangerY}\n          x2={CHART_PADDING_X + innerWidth}\n          y2={dangerY}\n          className=\"stroke-red-500/30\"\n          strokeDasharray=\"4 3\"\n          strokeWidth={0.5}\n        />\n\n        {/* 50% line */}\n        <line\n          x1={CHART_PADDING_X}\n          y1={toSvgY(0.5)}\n          x2={CHART_PADDING_X + innerWidth}\n          y2={toSvgY(0.5)}\n          className=\"stroke-border\"\n          strokeDasharray=\"2 3\"\n          strokeWidth={0.3}\n        />\n\n        {/* Area fill */}\n        <path d={areaPath} className=\"fill-primary/10\" />\n\n        {/* Line */}\n        <path\n          d={linePath}\n          className=\"stroke-primary\"\n          strokeWidth={1.5}\n          fill=\"none\"\n        />\n\n        {/* Compaction markers */}\n        {compactions.map((c) => (\n          <line\n            key={c.eventIndex}\n            x1={toSvgX(c.x)}\n            y1={CHART_PADDING_TOP}\n            x2={toSvgX(c.x)}\n            y2={CHART_HEIGHT - CHART_PADDING_BOTTOM}\n            className=\"stroke-orange-500\"\n            strokeWidth={1.5}\n            strokeDasharray=\"3 2\"\n          />\n        ))}\n\n        {/* Y-axis labels */}\n        <text\n          x={CHART_PADDING_X - 4}\n          y={CHART_PADDING_TOP + 4}\n          className=\"fill-muted-foreground\"\n          fontSize={8}\n          textAnchor=\"end\"\n        >\n          100%\n        </text>\n        <text\n          x={CHART_PADDING_X - 4}\n          y={dangerY + 3}\n          className=\"fill-red-500/60\"\n          fontSize={8}\n          textAnchor=\"end\"\n        >\n          80%\n        </text>\n        <text\n          x={CHART_PADDING_X - 4}\n          y={toSvgY(0) + 3}\n          className=\"fill-muted-foreground\"\n          fontSize={8}\n          textAnchor=\"end\"\n        >\n          0%\n        </text>\n\n        {/* Data point dots on hover via CSS - show all small dots */}\n        {dataPoints.map((p) => (\n          <circle\n            key={p.eventIndex}\n            cx={toSvgX(p.x)}\n            cy={toSvgY(p.contextUsage)}\n            r={1.5}\n            className={`${p.contextUsage > 0.8 ? \"fill-red-500\" : \"fill-primary\"} opacity-40 hover:opacity-100 hover:r-3`}\n          />\n        ))}\n      </svg>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/wire-event-card.tsx",
    "content": "import { useState } from \"react\";\nimport { type WireEvent } from \"@/lib/api\";\nimport {\n  ChevronRight,\n  ChevronDown,\n  Link,\n  Copy,\n  Check,\n  WrapText,\n  AlertCircle,\n  Bot,\n  Terminal,\n  FileEdit,\n  ListTodo,\n  Clock,\n} from \"lucide-react\";\n\ninterface WireEventCardProps {\n  event: WireEvent;\n  expanded: boolean;\n  onToggle: () => void;\n  /** Click handler for selecting this event (e.g. to show tool detail) */\n  onSelect?: () => void;\n  /** Whether this event is currently selected */\n  selected?: boolean;\n  prevEvent?: WireEvent;\n  /** Indent level for nested tool events */\n  nestLevel?: number;\n  /** Tool name from the parent ToolCall */\n  linkedToolName?: string;\n  /** Short tool call ID for display */\n  linkedToolCallId?: string;\n  /** Whether this event matches the current search */\n  searchMatch?: boolean;\n}\n\n/** Check if an event represents an error condition */\nexport function isErrorEvent(event: WireEvent): boolean {\n  if (event.type === \"ToolResult\") {\n    const rv = event.payload.return_value as Record<string, unknown> | undefined;\n    return rv?.is_error === true;\n  }\n  if (event.type === \"StepInterrupted\") return true;\n  if (event.type === \"ApprovalResponse\") {\n    return event.payload.response === \"reject\";\n  }\n  return false;\n}\n\nconst TYPE_COLORS: Record<string, string> = {\n  TurnBegin:\n    \"bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30\",\n  TurnEnd: \"bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30\",\n  StepBegin:\n    \"bg-green-500/15 text-green-700 dark:text-green-300 border-green-500/30\",\n  StepInterrupted:\n    \"bg-yellow-500/15 text-yellow-700 dark:text-yellow-300 border-yellow-500/30\",\n  CompactionBegin:\n    \"bg-orange-500/15 text-orange-700 dark:text-orange-300 border-orange-500/30\",\n  CompactionEnd:\n    \"bg-orange-500/15 text-orange-700 dark:text-orange-300 border-orange-500/30\",\n  StatusUpdate:\n    \"bg-gray-500/15 text-gray-700 dark:text-gray-300 border-gray-500/30\",\n  TextPart: \"bg-gray-500/15 text-gray-700 dark:text-gray-300 border-gray-500/30\",\n  ThinkPart:\n    \"bg-gray-500/15 text-gray-700 dark:text-gray-300 border-gray-500/30\",\n  ToolCall:\n    \"bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30\",\n  ToolResult:\n    \"bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30\",\n  ToolCallPart:\n    \"bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30\",\n  ApprovalRequest:\n    \"bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30\",\n  ApprovalResponse:\n    \"bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30\",\n  SubagentEvent:\n    \"bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30\",\n  ImageURLPart:\n    \"bg-pink-500/15 text-pink-700 dark:text-pink-300 border-pink-500/30\",\n  VideoURLPart:\n    \"bg-pink-500/15 text-pink-700 dark:text-pink-300 border-pink-500/30\",\n  AudioURLPart:\n    \"bg-pink-500/15 text-pink-700 dark:text-pink-300 border-pink-500/30\",\n};\n\nfunction getTypeColor(type: string): string {\n  return (\n    TYPE_COLORS[type] ??\n    \"bg-secondary text-secondary-foreground border-border\"\n  );\n}\n\nexport function formatTimestamp(ts: number): string {\n  const date = new Date(ts * 1000);\n  return date.toLocaleTimeString(undefined, {\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n    second: \"2-digit\",\n    fractionalSecondDigits: 3,\n  });\n}\n\nexport function formatTimeDelta(current: number, prev: number): string {\n  const delta = current - prev;\n  if (delta < 0.001) return \"\";\n  if (delta < 1) return `+${(delta * 1000).toFixed(0)}ms`;\n  if (delta < 60) return `+${delta.toFixed(2)}s`;\n  return `+${(delta / 60).toFixed(1)}min`;\n}\n\nfunction getSummary(event: WireEvent): string {\n  const p = event.payload;\n  switch (event.type) {\n    case \"TurnBegin\": {\n      const input = p.user_input;\n      if (typeof input === \"string\") return input.length > 120 ? input.slice(0, 120) + \"…\" : input;\n      if (Array.isArray(input) && input.length > 0) {\n        const first = input[0] as Record<string, unknown>;\n        const t = String(first.text ?? \"\");\n        return t.length > 120 ? t.slice(0, 120) + \"…\" : t;\n      }\n      return \"\";\n    }\n    case \"StepBegin\":\n      return `Step ${p.n}`;\n    case \"TextPart\": {\n      const text = String(p.text ?? \"\");\n      return text.length > 120 ? text.slice(0, 120) + \"…\" : text;\n    }\n    case \"ThinkPart\": {\n      const think = String(p.thinking ?? p.think ?? \"\");\n      return think.length > 120 ? think.slice(0, 120) + \"…\" : think;\n    }\n    case \"ToolCall\": {\n      const fn = p.function as Record<string, unknown> | undefined;\n      return fn ? `${fn.name}()` : \"\";\n    }\n    case \"ToolCallPart\": {\n      const args = String(p.arguments_part ?? \"\");\n      return args.length > 80 ? args.slice(0, 80) + \"…\" : args;\n    }\n    case \"ToolResult\": {\n      const rv = p.return_value as Record<string, unknown> | undefined;\n      if (rv) {\n        const isErr = rv.is_error ? \"[error] \" : \"\";\n        const output = rv.output;\n        if (typeof output === \"string\") return `${isErr}${output.length > 120 ? output.slice(0, 120) + \"…\" : output}`;\n        if (Array.isArray(output)) return `${isErr}${output.length} part(s)`;\n        return isErr || \"result\";\n      }\n      return `tool_call_id: ${p.tool_call_id}`;\n    }\n    case \"StatusUpdate\": {\n      const parts: string[] = [];\n      if (p.context_usage != null)\n        parts.push(`ctx: ${((p.context_usage as number) * 100).toFixed(1)}%`);\n      return parts.join(\", \");\n    }\n    case \"ApprovalRequest\":\n      return `${p.sender}: ${p.action}`;\n    case \"ApprovalResponse\":\n      return String(p.response ?? \"\");\n    case \"SubagentEvent\": {\n      const inner = p.event as Record<string, unknown> | undefined;\n      const innerType = inner?.type as string | undefined;\n      const innerPayload = inner?.payload as Record<string, unknown> | undefined;\n      let detail = \"\";\n      if (innerType === \"ToolCall\" && innerPayload) {\n        const fn = innerPayload.function as Record<string, unknown> | undefined;\n        detail = fn ? ` ${fn.name}()` : \"\";\n      } else if (innerType === \"TurnBegin\" && innerPayload) {\n        const inp = innerPayload.user_input;\n        detail = typeof inp === \"string\" ? ` \"${inp.length > 60 ? inp.slice(0, 60) + \"…\" : inp}\"` : \"\";\n      } else if (innerType) {\n        detail = ` ${innerType}`;\n      }\n      return `task:${String(p.task_tool_call_id ?? \"\").slice(0, 8)}${detail}`;\n    }\n    default:\n      return \"\";\n  }\n}\n\nexport function WireEventCard({\n  event,\n  expanded,\n  onToggle,\n  onSelect,\n  selected,\n  prevEvent,\n  nestLevel = 0,\n  linkedToolName,\n  linkedToolCallId,\n  searchMatch,\n}: WireEventCardProps) {\n  const summary = getSummary(event);\n  const timeDelta = prevEvent\n    ? formatTimeDelta(event.timestamp, prevEvent.timestamp)\n    : \"\";\n  const isTurnBoundary =\n    event.type === \"TurnBegin\" || event.type === \"TurnEnd\";\n\n  const isNested = nestLevel > 0;\n  const isError = isErrorEvent(event);\n  const isToolEvent = event.type === \"ToolCall\" || event.type === \"ToolResult\";\n\n  return (\n    <div\n      className={`border-b py-1.5 ${isTurnBoundary ? \"bg-muted/30\" : \"\"} ${isNested ? \"border-l-2 border-l-purple-400/50 dark:border-l-purple-500/40\" : \"\"} ${isError ? \"bg-red-500/8 border-l-2 border-l-red-500/70\" : \"\"} ${searchMatch ? \"bg-yellow-500/10\" : \"\"} ${selected ? \"ring-1 ring-primary/50 bg-primary/5\" : \"\"}`}\n      style={{ paddingLeft: `${16 + nestLevel * 20}px`, paddingRight: 16 }}\n    >\n      {/* Time gap indicator */}\n      {timeDelta && prevEvent && event.timestamp - prevEvent.timestamp > 1 && (\n        <div className=\"flex items-center gap-2 py-1 mb-1\">\n          <div className=\"h-px flex-1 bg-border\" />\n          <span className=\"text-[10px] text-muted-foreground\">{timeDelta}</span>\n          <div className=\"h-px flex-1 bg-border\" />\n        </div>\n      )}\n\n      <button\n        onClick={onToggle}\n        className=\"flex w-full items-start gap-2 text-left\"\n      >\n        {/* Expand icon */}\n        <span className=\"mt-0.5 text-muted-foreground\">\n          {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}\n        </span>\n\n        {/* Timestamp */}\n        <span className=\"mt-0.5 shrink-0 font-mono text-[11px] text-muted-foreground w-[90px]\">\n          {formatTimestamp(event.timestamp)}\n        </span>\n\n        {/* Error icon */}\n        {isError && (\n          <AlertCircle size={13} className=\"mt-0.5 shrink-0 text-red-500\" />\n        )}\n\n        {/* Type badge */}\n        <span\n          className={`mt-0.5 shrink-0 rounded border px-1.5 py-0 text-[11px] font-medium ${isError ? \"bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30\" : getTypeColor(event.type)}`}\n        >\n          {event.type}\n        </span>\n\n        {/* Linked tool info for nested events */}\n        {isNested && linkedToolName && (\n          <span className=\"mt-0.5 flex items-center gap-0.5 shrink-0\">\n            <Link size={9} className=\"text-purple-500/60\" />\n            <span className=\"text-[10px] font-mono text-purple-600 dark:text-purple-400\">\n              {linkedToolName}\n            </span>\n          </span>\n        )}\n\n        {/* Tool call ID */}\n        {linkedToolCallId && (\n          <span className=\"mt-0.5 shrink-0 text-[9px] font-mono text-muted-foreground bg-purple-500/10 px-1 py-0 rounded\">\n            {linkedToolCallId.slice(0, 12)}\n          </span>\n        )}\n\n        {/* Summary */}\n        {summary && (\n          <span className=\"mt-0.5 truncate text-xs text-muted-foreground\">\n            {summary}\n          </span>\n        )}\n\n        {/* Detail button for ToolCall/ToolResult */}\n        {isToolEvent && onSelect && (\n          <span\n            role=\"button\"\n            tabIndex={0}\n            onClick={(e) => {\n              e.stopPropagation();\n              onSelect();\n            }}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") { e.stopPropagation(); onSelect(); }\n            }}\n            className=\"mt-0.5 shrink-0 text-[10px] text-blue-600 dark:text-blue-400 hover:underline cursor-pointer\"\n            title=\"Show tool call details\"\n          >\n            detail\n          </span>\n        )}\n\n        {/* Time delta */}\n        {timeDelta && event.timestamp - (prevEvent?.timestamp ?? 0) <= 1 && (\n          <span className=\"mt-0.5 ml-auto shrink-0 text-[10px] text-muted-foreground\">\n            {timeDelta}\n          </span>\n        )}\n      </button>\n\n      {/* Expanded payload */}\n      {expanded && event.type === \"SubagentEvent\" && (\n        <SubagentContent payload={event.payload} depth={nestLevel + 1} />\n      )}\n      {expanded && event.type === \"ApprovalRequest\" && (\n        <ApprovalRequestContent payload={event.payload} />\n      )}\n      {expanded && event.type !== \"SubagentEvent\" && event.type !== \"ApprovalRequest\" && (\n        <ExpandedPayload payload={event.payload} />\n      )}\n    </div>\n  );\n}\n\nfunction ExpandedPayload({ payload }: { payload: Record<string, unknown> }) {\n  const [copied, setCopied] = useState(false);\n  const [wrap, setWrap] = useState(true);\n  const text = JSON.stringify(payload, null, 2);\n\n  const handleCopy = async () => {\n    await navigator.clipboard.writeText(text);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  return (\n    <div className=\"mt-2 ml-6 mb-2 rounded-md border bg-card relative group/payload\">\n      <div className=\"absolute top-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover/payload:opacity-100 transition-opacity z-10\">\n        <button\n          onClick={() => setWrap(!wrap)}\n          className={`rounded p-1 hover:bg-muted ${wrap ? \"text-foreground bg-muted/60\" : \"text-muted-foreground\"}`}\n          title={wrap ? \"No wrap\" : \"Wrap lines\"}\n        >\n          <WrapText size={14} />\n        </button>\n        <button\n          onClick={handleCopy}\n          className=\"rounded p-1 hover:bg-muted text-muted-foreground hover:text-foreground\"\n          title=\"Copy JSON\"\n        >\n          {copied ? <Check size={14} /> : <Copy size={14} />}\n        </button>\n      </div>\n      <pre className={`overflow-auto text-xs font-mono leading-relaxed text-card-foreground max-h-[500px] p-3 ${wrap ? \"whitespace-pre-wrap break-all\" : \"whitespace-pre\"}`}>\n        {text}\n      </pre>\n    </div>\n  );\n}\n\n/** Recursive SubagentEvent renderer */\nconst MAX_SUBAGENT_DEPTH = 5;\n\nfunction SubagentContent({ payload, depth }: { payload: Record<string, unknown>; depth: number }) {\n  const [showRaw, setShowRaw] = useState(false);\n  const taskId = String(payload.task_tool_call_id ?? \"\").slice(0, 12);\n  const inner = payload.event as Record<string, unknown> | undefined;\n\n  if (!inner) return <ExpandedPayload payload={payload} />;\n\n  const innerType = inner.type as string | undefined;\n  const innerPayload = (inner.payload as Record<string, unknown>) ?? {};\n\n  // If too deep, fallback to JSON\n  if (depth > MAX_SUBAGENT_DEPTH) {\n    return (\n      <div className=\"mt-2 ml-6 mb-2\">\n        <div className=\"text-[10px] text-muted-foreground italic mb-1\">\n          Max nesting depth reached — showing raw JSON\n        </div>\n        <ExpandedPayload payload={payload} />\n      </div>\n    );\n  }\n\n  // Build a summary for the inner event\n  const innerSummary = innerType ? getSummary({ index: 0, timestamp: 0, type: innerType, payload: innerPayload }) : \"\";\n\n  // Check if the inner event is itself a SubagentEvent (recursive)\n  const isNestedSubagent = innerType === \"SubagentEvent\";\n  const innerIsError = innerType ? isErrorEvent({ index: 0, timestamp: 0, type: innerType, payload: innerPayload }) : false;\n\n  return (\n    <div className=\"mt-2 ml-4 mb-2 border-l-2 border-l-indigo-400/50 dark:border-l-indigo-500/40 pl-3\">\n      {/* Subagent header */}\n      <div className=\"flex items-center gap-2 mb-1.5\">\n        <Bot size={12} className=\"text-indigo-500 shrink-0\" />\n        <span className=\"text-[10px] font-mono text-indigo-600 dark:text-indigo-400\">\n          task:{taskId}\n        </span>\n        <button\n          onClick={() => setShowRaw((v) => !v)}\n          className=\"text-[10px] text-muted-foreground hover:text-foreground\"\n        >\n          {showRaw ? \"hide raw\" : \"raw\"}\n        </button>\n      </div>\n\n      {showRaw && <ExpandedPayload payload={payload} />}\n\n      {/* Render the inner event as a mini card */}\n      {innerType && (\n        <div className={`rounded border px-3 py-1.5 ${innerIsError ? \"bg-red-500/5 border-red-500/20\" : \"bg-indigo-500/5 border-indigo-500/15\"}`}>\n          <div className=\"flex items-center gap-2\">\n            {innerIsError && <AlertCircle size={11} className=\"shrink-0 text-red-500\" />}\n            <span className={`shrink-0 rounded border px-1.5 py-0 text-[10px] font-medium ${innerIsError ? \"bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30\" : getTypeColor(innerType)}`}>\n              {innerType}\n            </span>\n            {innerSummary && (\n              <span className=\"truncate text-[11px] text-muted-foreground\">{innerSummary}</span>\n            )}\n          </div>\n\n          {/* Recursively render nested SubagentEvent */}\n          {isNestedSubagent ? (\n            <SubagentContent payload={innerPayload} depth={depth + 1} />\n          ) : (\n            <InnerEventContent type={innerType} payload={innerPayload} />\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** Render content of an inner subagent event (non-SubagentEvent types) */\nfunction InnerEventContent({ type, payload }: { type: string; payload: Record<string, unknown> }) {\n  const [expanded, setExpanded] = useState(false);\n\n  // For some types, show structured content directly\n  if (type === \"TextPart\" && payload.text) {\n    return (\n      <div className=\"mt-1 text-xs text-muted-foreground whitespace-pre-wrap max-h-32 overflow-auto\">\n        {String(payload.text).slice(0, 500)}\n      </div>\n    );\n  }\n\n  if (type === \"ToolCall\") {\n    const fn = payload.function as Record<string, unknown> | undefined;\n    return (\n      <div className=\"mt-1\">\n        <span className=\"text-[10px] font-mono text-purple-600 dark:text-purple-400\">{fn?.name as string}()</span>\n        {expanded && (\n          <pre className=\"mt-1 overflow-auto whitespace-pre-wrap text-[10px] font-mono text-muted-foreground max-h-32\">\n            {(() => {\n              if (!fn?.arguments) return \"{}\";\n              try { return JSON.stringify(JSON.parse(fn.arguments as string), null, 2); }\n              catch { return String(fn.arguments); }\n            })()}\n          </pre>\n        )}\n        <button onClick={() => setExpanded((v) => !v)} className=\"text-[9px] text-muted-foreground hover:text-foreground ml-1\">\n          {expanded ? \"hide args\" : \"show args\"}\n        </button>\n      </div>\n    );\n  }\n\n  if (type === \"ToolResult\") {\n    const rv = payload.return_value as Record<string, unknown> | undefined;\n    const isErr = rv?.is_error === true;\n    const output = rv?.output;\n    return (\n      <div className=\"mt-1\">\n        {isErr && <span className=\"text-[10px] text-red-500 font-medium\">[error] </span>}\n        <span className=\"text-[10px] text-muted-foreground\">\n          {typeof output === \"string\" ? output.slice(0, 200) : JSON.stringify(output).slice(0, 200)}\n        </span>\n        {expanded && <ExpandedPayload payload={payload} />}\n        <button onClick={() => setExpanded((v) => !v)} className=\"text-[9px] text-muted-foreground hover:text-foreground ml-1\">\n          {expanded ? \"hide\" : \"more\"}\n        </button>\n      </div>\n    );\n  }\n\n  // Default: collapsible JSON\n  return (\n    <div className=\"mt-1\">\n      <button onClick={() => setExpanded((v) => !v)} className=\"text-[9px] text-muted-foreground hover:text-foreground\">\n        {expanded ? \"hide payload\" : \"show payload\"}\n      </button>\n      {expanded && <ExpandedPayload payload={payload} />}\n    </div>\n  );\n}\n\n/** Enhanced ApprovalRequest content with DisplayBlock rendering */\nfunction ApprovalRequestContent({ payload }: { payload: Record<string, unknown> }) {\n  const [showRaw, setShowRaw] = useState(false);\n  const display = (payload.display as Array<Record<string, unknown>>) ?? [];\n\n  return (\n    <div className=\"mt-2 ml-6 mb-2 space-y-2\">\n      {/* Request info */}\n      <div className=\"rounded border bg-amber-500/5 border-amber-500/15 px-3 py-2\">\n        <div className=\"flex items-center gap-2 text-[11px]\">\n          <span className=\"font-medium text-amber-700 dark:text-amber-300\">{String(payload.sender ?? \"\")}</span>\n          <span className=\"text-muted-foreground\">wants to</span>\n          <span className=\"font-mono font-medium text-foreground\">{String(payload.action ?? \"\")}</span>\n        </div>\n        {payload.description != null && (\n          <div className=\"mt-1 text-xs text-muted-foreground\">{String(payload.description)}</div>\n        )}\n        <div className=\"flex items-center gap-2 mt-1\">\n          <span className=\"text-[9px] font-mono text-muted-foreground\">id: {String(payload.id ?? \"\").slice(0, 12)}</span>\n          <button onClick={() => setShowRaw((v) => !v)} className=\"text-[9px] text-muted-foreground hover:text-foreground\">\n            {showRaw ? \"hide raw\" : \"raw\"}\n          </button>\n        </div>\n      </div>\n\n      {showRaw && <ExpandedPayload payload={payload} />}\n\n      {/* Display blocks */}\n      {display.map((block, i) => (\n        <DisplayBlockRenderer key={i} block={block} />\n      ))}\n    </div>\n  );\n}\n\n/** Render a single DisplayBlock */\nfunction DisplayBlockRenderer({ block }: { block: Record<string, unknown> }) {\n  const type = block.type as string | undefined;\n\n  if (type === \"diff\") {\n    return <DiffBlock block={block} />;\n  }\n  if (type === \"shell\") {\n    return <ShellBlock block={block} />;\n  }\n  if (type === \"todo\") {\n    return <TodoBlock block={block} />;\n  }\n\n  // Unknown block type — show JSON\n  return (\n    <div className=\"rounded border bg-muted/20 px-3 py-2\">\n      <div className=\"text-[10px] font-mono text-muted-foreground mb-1\">[{type ?? \"unknown\"}]</div>\n      <pre className=\"overflow-auto whitespace-pre-wrap text-[10px] font-mono text-muted-foreground max-h-32\">\n        {JSON.stringify(block, null, 2)}\n      </pre>\n    </div>\n  );\n}\n\n/** Diff display block — shows file diff with add/remove coloring */\nfunction DiffBlock({ block }: { block: Record<string, unknown> }) {\n  const [expanded, setExpanded] = useState(true);\n  const path = String(block.path ?? \"\");\n  const oldText = String(block.old_text ?? \"\");\n  const newText = String(block.new_text ?? \"\");\n\n  // Simple line-by-line diff visualization\n  const oldLines = oldText.split(\"\\n\");\n  const newLines = newText.split(\"\\n\");\n\n  return (\n    <div className=\"rounded border bg-card overflow-hidden\">\n      <button\n        onClick={() => setExpanded((v) => !v)}\n        className=\"flex items-center gap-1.5 w-full px-3 py-1.5 text-left hover:bg-muted/50 text-[11px]\"\n      >\n        <FileEdit size={12} className=\"text-blue-500 shrink-0\" />\n        <span className=\"font-mono truncate\">{path}</span>\n        {expanded ? <ChevronDown size={11} /> : <ChevronRight size={11} />}\n      </button>\n      {expanded && (\n        <div className=\"border-t max-h-64 overflow-auto\">\n          {oldLines.length > 0 && oldText && (\n            <div>\n              {oldLines.map((line, i) => (\n                <div key={`old-${i}`} className=\"px-3 py-0 bg-red-500/10 text-red-700 dark:text-red-300 font-mono text-[10px] leading-5\">\n                  <span className=\"select-none text-red-500/60 mr-2\">-</span>{line}\n                </div>\n              ))}\n            </div>\n          )}\n          {newLines.length > 0 && newText && (\n            <div>\n              {newLines.map((line, i) => (\n                <div key={`new-${i}`} className=\"px-3 py-0 bg-green-500/10 text-green-700 dark:text-green-300 font-mono text-[10px] leading-5\">\n                  <span className=\"select-none text-green-500/60 mr-2\">+</span>{line}\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** Shell display block — terminal-style command */\nfunction ShellBlock({ block }: { block: Record<string, unknown> }) {\n  const command = String(block.command ?? \"\");\n  const language = String(block.language ?? \"bash\");\n\n  return (\n    <div className=\"rounded border bg-zinc-900 dark:bg-zinc-950 overflow-hidden\">\n      <div className=\"flex items-center gap-1.5 px-3 py-1 border-b border-zinc-700\">\n        <Terminal size={11} className=\"text-green-400\" />\n        <span className=\"text-[10px] text-zinc-400\">{language}</span>\n      </div>\n      <pre className=\"px-3 py-2 overflow-auto whitespace-pre-wrap text-[11px] font-mono text-green-300 max-h-48\">\n        {command}\n      </pre>\n    </div>\n  );\n}\n\n/** Todo display block */\nfunction TodoBlock({ block }: { block: Record<string, unknown> }) {\n  const items = (block.items as Array<Record<string, unknown>>) ?? [];\n\n  return (\n    <div className=\"rounded border bg-card px-3 py-2\">\n      <div className=\"flex items-center gap-1.5 mb-1.5\">\n        <ListTodo size={12} className=\"text-blue-500\" />\n        <span className=\"text-[10px] font-medium text-muted-foreground\">Todo</span>\n      </div>\n      <div className=\"space-y-0.5\">\n        {items.map((item, i) => {\n          const status = item.status as string;\n          const icon = status === \"done\" ? \"✓\" : status === \"in_progress\" ? \"▶\" : \"○\";\n          const color = status === \"done\" ? \"text-green-600\" : status === \"in_progress\" ? \"text-blue-600\" : \"text-muted-foreground\";\n          return (\n            <div key={i} className={`flex items-center gap-1.5 text-[11px] ${color}`}>\n              <span className=\"shrink-0 w-3 text-center\">{icon}</span>\n              <span>{String(item.title ?? \"\")}</span>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/wire-filters.tsx",
    "content": "import {\n  ChevronsDownUp,\n  ChevronsUpDown,\n  Search,\n  ChevronUp,\n  ChevronDown,\n  X,\n  AlertCircle,\n  BarChart3,\n  List,\n  GanttChart,\n  Brain,\n  Zap,\n  ShieldCheck,\n  ShieldAlert,\n} from \"lucide-react\";\n\ninterface FilterPreset {\n  label: string;\n  types: Set<string>;\n  errorsOnly: boolean;\n}\n\nconst FILTER_PRESETS: FilterPreset[] = [\n  { label: \"All Events\", types: new Set(), errorsOnly: false },\n  { label: \"Errors Only\", types: new Set(), errorsOnly: true },\n  { label: \"Tool Calls\", types: new Set([\"ToolCall\", \"ToolResult\", \"ToolCallPart\"]), errorsOnly: false },\n  { label: \"Thinking\", types: new Set([\"ThinkPart\", \"TextPart\"]), errorsOnly: false },\n  { label: \"Approvals\", types: new Set([\"ApprovalRequest\", \"ApprovalResponse\"]), errorsOnly: false },\n];\n\nfunction setsEqual(a: Set<string>, b: Set<string>): boolean {\n  if (a.size !== b.size) return false;\n  for (const item of a) {\n    if (!b.has(item)) return false;\n  }\n  return true;\n}\n\ninterface WireFiltersProps {\n  allTypes: string[];\n  selectedTypes: Set<string>;\n  onToggle: (type: string) => void;\n  total: number;\n  filteredCount: number;\n  allExpanded: boolean;\n  onExpandAll: () => void;\n  onCollapseAll: () => void;\n  // Search\n  searchQuery: string;\n  onSearchChange: (query: string) => void;\n  searchMatchCount: number;\n  // Error navigation\n  errorCount: number;\n  errorsOnly: boolean;\n  onToggleErrorsOnly: () => void;\n  onNextError: () => void;\n  onPrevError: () => void;\n  // View mode\n  viewMode?: \"events\" | \"timeline\" | \"decisions\";\n  onViewModeChange?: (mode: \"events\" | \"timeline\" | \"decisions\") => void;\n  // Usage chart\n  showUsageChart?: boolean;\n  onToggleUsageChart?: () => void;\n  // Presets\n  onApplyPreset?: (types: Set<string>, errorsOnly: boolean) => void;\n  // Integrity\n  integrityScore?: number;\n  onToggleIntegrity?: () => void;\n}\n\nconst TYPE_COLORS: Record<string, string> = {\n  TurnBegin: \"bg-blue-500/20 border-blue-500/40 text-blue-700 dark:text-blue-300\",\n  TurnEnd: \"bg-blue-500/20 border-blue-500/40 text-blue-700 dark:text-blue-300\",\n  StepBegin: \"bg-green-500/20 border-green-500/40 text-green-700 dark:text-green-300\",\n  StepInterrupted: \"bg-yellow-500/20 border-yellow-500/40 text-yellow-700 dark:text-yellow-300\",\n  CompactionBegin: \"bg-orange-500/20 border-orange-500/40 text-orange-700 dark:text-orange-300\",\n  CompactionEnd: \"bg-orange-500/20 border-orange-500/40 text-orange-700 dark:text-orange-300\",\n  StatusUpdate: \"bg-gray-500/20 border-gray-500/40 text-gray-700 dark:text-gray-300\",\n  TextPart: \"bg-gray-500/20 border-gray-500/40 text-gray-700 dark:text-gray-300\",\n  ThinkPart: \"bg-gray-500/20 border-gray-500/40 text-gray-700 dark:text-gray-300\",\n  ToolCall: \"bg-purple-500/20 border-purple-500/40 text-purple-700 dark:text-purple-300\",\n  ToolResult: \"bg-purple-500/20 border-purple-500/40 text-purple-700 dark:text-purple-300\",\n  ToolCallPart: \"bg-purple-500/20 border-purple-500/40 text-purple-700 dark:text-purple-300\",\n  ApprovalRequest: \"bg-amber-500/20 border-amber-500/40 text-amber-700 dark:text-amber-300\",\n  ApprovalResponse: \"bg-amber-500/20 border-amber-500/40 text-amber-700 dark:text-amber-300\",\n  SubagentEvent: \"bg-indigo-500/20 border-indigo-500/40 text-indigo-700 dark:text-indigo-300\",\n};\n\nexport function WireFilters({\n  allTypes,\n  selectedTypes,\n  onToggle,\n  total,\n  filteredCount,\n  allExpanded,\n  onExpandAll,\n  onCollapseAll,\n  searchQuery,\n  onSearchChange,\n  searchMatchCount,\n  errorCount,\n  errorsOnly,\n  onToggleErrorsOnly,\n  onNextError,\n  onPrevError,\n  viewMode = \"events\",\n  onViewModeChange,\n  showUsageChart,\n  onToggleUsageChart,\n  onApplyPreset,\n  integrityScore,\n  onToggleIntegrity,\n}: WireFiltersProps) {\n  const activePresetIndex = FILTER_PRESETS.findIndex(\n    (p) => setsEqual(p.types, selectedTypes) && p.errorsOnly === errorsOnly,\n  );\n\n  return (\n    <div className=\"border-b px-4 py-2 space-y-2\">\n      {/* Row 0: Preset filter buttons */}\n      {onApplyPreset && (\n        <div className=\"flex items-center gap-1.5 flex-wrap\">\n          <Zap size={12} className=\"text-muted-foreground mr-0.5\" />\n          {FILTER_PRESETS.map((preset, i) => (\n            <button\n              key={preset.label}\n              onClick={() => onApplyPreset(preset.types, preset.errorsOnly)}\n              className={`rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors ${\n                activePresetIndex === i\n                  ? \"bg-primary/15 border-primary/40 text-foreground\"\n                  : \"bg-muted/50 border-border text-muted-foreground hover:text-foreground hover:bg-muted\"\n              }`}\n            >\n              {preset.label}\n            </button>\n          ))}\n        </div>\n      )}\n\n      {/* Row 1: Search + controls */}\n      <div className=\"flex items-center gap-2\">\n        {/* Search box */}\n        <div className=\"relative flex-1 max-w-xs\">\n          <Search size={13} className=\"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground\" />\n          <input\n            type=\"text\"\n            value={searchQuery}\n            onChange={(e) => onSearchChange(e.target.value)}\n            placeholder=\"Search events...\"\n            data-wire-search\n            className=\"w-full rounded border bg-background pl-7 pr-7 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring\"\n          />\n          {searchQuery && (\n            <button\n              onClick={() => onSearchChange(\"\")}\n              className=\"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n            >\n              <X size={12} />\n            </button>\n          )}\n        </div>\n        {searchQuery && (\n          <span className=\"text-[11px] text-muted-foreground shrink-0\">\n            {searchMatchCount} matches\n          </span>\n        )}\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        {/* Error navigation */}\n        <button\n          onClick={onToggleErrorsOnly}\n          className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] transition-colors ${\n            errorsOnly\n              ? \"bg-red-500/15 border-red-500/30 text-red-700 dark:text-red-300\"\n              : \"text-muted-foreground hover:text-foreground hover:bg-muted\"\n          }`}\n          title=\"Show errors only\"\n        >\n          <AlertCircle size={12} />\n          {errorCount} errors\n        </button>\n        {errorCount > 0 && (\n          <>\n            <button\n              onClick={onPrevError}\n              className=\"rounded border p-0.5 text-muted-foreground hover:text-foreground hover:bg-muted\"\n              title=\"Previous error\"\n            >\n              <ChevronUp size={14} />\n            </button>\n            <button\n              onClick={onNextError}\n              className=\"rounded border p-0.5 text-muted-foreground hover:text-foreground hover:bg-muted\"\n              title=\"Next error\"\n            >\n              <ChevronDown size={14} />\n            </button>\n          </>\n        )}\n\n        <div className=\"h-4 w-px bg-border\" />\n\n        {/* Stats + expand/collapse */}\n        <span className=\"text-xs text-muted-foreground shrink-0\">\n          {selectedTypes.size > 0 || errorsOnly || searchQuery\n            ? `${filteredCount} / ${total}`\n            : `${total}`}\n        </span>\n        <button\n          onClick={allExpanded ? onCollapseAll : onExpandAll}\n          className=\"flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted transition-colors\"\n          title={allExpanded ? \"Collapse all\" : \"Expand all\"}\n        >\n          {allExpanded ? <ChevronsDownUp size={12} /> : <ChevronsUpDown size={12} />}\n          {allExpanded ? \"Collapse\" : \"Expand\"}\n        </button>\n\n        {onToggleUsageChart && (\n          <>\n            <div className=\"h-4 w-px bg-border\" />\n            <button\n              onClick={onToggleUsageChart}\n              className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] transition-colors ${\n                showUsageChart\n                  ? \"bg-primary/10 text-foreground border-primary/30\"\n                  : \"text-muted-foreground hover:text-foreground hover:bg-muted\"\n              }`}\n              title=\"Toggle context usage chart\"\n            >\n              <BarChart3 size={12} />\n              Chart\n            </button>\n          </>\n        )}\n\n        {onViewModeChange && (\n          <>\n            <div className=\"h-4 w-px bg-border\" />\n            <div className=\"flex items-center rounded border overflow-hidden\">\n              <button\n                onClick={() => onViewModeChange(\"events\")}\n                className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] transition-colors ${\n                  viewMode === \"events\"\n                    ? \"bg-primary/10 text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground hover:bg-muted\"\n                }`}\n                title=\"Events view\"\n              >\n                <List size={12} />\n                Events\n              </button>\n              <div className=\"w-px h-4 bg-border\" />\n              <button\n                onClick={() => onViewModeChange(\"timeline\")}\n                className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] transition-colors ${\n                  viewMode === \"timeline\"\n                    ? \"bg-primary/10 text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground hover:bg-muted\"\n                }`}\n                title=\"Timeline view\"\n              >\n                <GanttChart size={12} />\n                Timeline\n              </button>\n              <div className=\"w-px h-4 bg-border\" />\n              <button\n                onClick={() => onViewModeChange(\"decisions\")}\n                className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] transition-colors ${\n                  viewMode === \"decisions\"\n                    ? \"bg-primary/10 text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground hover:bg-muted\"\n                }`}\n                title=\"Decisions view\"\n              >\n                <Brain size={12} />\n                Decisions\n              </button>\n            </div>\n          </>\n        )}\n\n        {integrityScore != null && onToggleIntegrity && (\n          <>\n            <div className=\"h-4 w-px bg-border\" />\n            <button\n              onClick={onToggleIntegrity}\n              className={`flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] font-medium transition-colors ${\n                integrityScore === 100\n                  ? \"bg-green-500/15 border-green-500/30 text-green-700 dark:text-green-300\"\n                  : integrityScore >= 80\n                    ? \"bg-amber-500/15 border-amber-500/30 text-amber-700 dark:text-amber-300\"\n                    : \"bg-red-500/15 border-red-500/30 text-red-700 dark:text-red-300\"\n              }`}\n              title=\"Toggle integrity panel\"\n            >\n              {integrityScore === 100 ? (\n                <ShieldCheck size={11} />\n              ) : (\n                <ShieldAlert size={11} />\n              )}\n              {integrityScore === 100 ? \"✓ 100%\" : `${integrityScore}%`}\n            </button>\n          </>\n        )}\n      </div>\n\n      {/* Row 2: Type filter pills */}\n      <div className=\"flex items-center gap-1.5 flex-wrap\">\n        {allTypes.map((type) => {\n          const active = selectedTypes.has(type);\n          const colorClass = TYPE_COLORS[type] ?? \"bg-secondary border-border text-secondary-foreground\";\n          return (\n            <button\n              key={type}\n              onClick={() => onToggle(type)}\n              className={`rounded-full border px-2 py-0.5 text-[11px] font-medium transition-opacity ${colorClass} ${\n                active || selectedTypes.size === 0\n                  ? \"opacity-100\"\n                  : \"opacity-40\"\n              }`}\n            >\n              {type}\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/features/wire-viewer/wire-viewer.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { type WireEvent, getWireEvents } from \"@/lib/api\";\nimport { WireEventCard, isErrorEvent } from \"./wire-event-card\";\nimport { WireFilters } from \"./wire-filters\";\nimport { TurnTree } from \"./turn-tree\";\nimport { ToolCallDetail } from \"./tool-call-detail\";\nimport { UsageChart, ToolTokenBreakdown } from \"./usage-chart\";\nimport { ToolStatsDashboard } from \"./tool-stats-dashboard\";\nimport { TurnEfficiency } from \"./turn-efficiency\";\nimport { TimelineView } from \"./timeline-view\";\nimport { DecisionPath } from \"./decision-path\";\nimport { computeIntegrity, IntegrityPanel } from \"./integrity-check\";\nimport { Virtuoso, type VirtuosoHandle } from \"react-virtuoso\";\n\ntype ViewMode = \"events\" | \"timeline\" | \"decisions\";\n\ninterface WireViewerProps {\n  sessionId: string;\n  /** Increment to force-refresh data */\n  refreshKey?: number;\n  /** Callback to navigate to Context Messages tab with a specific tool_call_id */\n  onNavigateToContext?: (toolCallId: string) => void;\n  /** If set, scroll to the ToolCall/ToolResult with this tool_call_id */\n  scrollToToolCallId?: string | null;\n  /** Called after the scroll target has been consumed */\n  onScrollTargetConsumed?: () => void;\n}\n\n/** Metadata attached to each event for tool call grouping */\ninterface EventMeta {\n  nestLevel: number;\n  linkedToolName?: string;\n  linkedToolCallId?: string;\n}\n\n/** Build a map from event index -> grouping metadata.\n *  Handles parallel tool calls by tracking all in-flight calls. */\nfunction buildToolGrouping(events: WireEvent[]): Map<number, EventMeta> {\n  const meta = new Map<number, EventMeta>();\n\n  // Track all active (unresolved) tool calls to handle parallel execution.\n  const activeToolCalls: { id: string; name: string }[] = [];\n  // O(1) lookup from tool call id to tool name (avoids O(n²) inner loop)\n  const toolCallNames = new Map<string, string>();\n\n  const getLatestActive = () =>\n    activeToolCalls.length > 0\n      ? activeToolCalls[activeToolCalls.length - 1]\n      : undefined;\n\n  for (const event of events) {\n    if (event.type === \"ToolCall\") {\n      const id = event.payload.id as string | undefined;\n      const fn = event.payload.function as Record<string, unknown> | undefined;\n      const name = fn?.name as string | undefined;\n      if (id) {\n        activeToolCalls.push({ id, name: name ?? \"\" });\n        toolCallNames.set(id, name ?? \"\");\n      }\n      meta.set(event.index, {\n        nestLevel: 0,\n        linkedToolCallId: id,\n        linkedToolName: name,\n      });\n    } else if (event.type === \"ToolCallPart\") {\n      const latest = getLatestActive();\n      meta.set(event.index, {\n        nestLevel: latest ? 1 : 0,\n        linkedToolCallId: latest?.id,\n        linkedToolName: latest?.name,\n      });\n    } else if (event.type === \"ToolResult\") {\n      const tcId = event.payload.tool_call_id as string | undefined;\n      const toolName = tcId ? toolCallNames.get(tcId) : undefined;\n      if (tcId) {\n        const idx = activeToolCalls.findIndex((tc) => tc.id === tcId);\n        if (idx !== -1) activeToolCalls.splice(idx, 1);\n      }\n      meta.set(event.index, {\n        nestLevel: 0,\n        linkedToolCallId: tcId,\n        linkedToolName: toolName,\n      });\n    } else {\n      meta.set(event.index, { nestLevel: 0 });\n    }\n  }\n\n  return meta;\n}\n\nexport function WireViewer({ sessionId, refreshKey = 0, onNavigateToContext, scrollToToolCallId, onScrollTargetConsumed }: WireViewerProps) {\n  const [events, setEvents] = useState<WireEvent[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n  const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());\n  const [expandedSet, setExpandedSet] = useState<Set<number>>(new Set());\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const [errorsOnly, setErrorsOnly] = useState(false);\n  const [treeCollapsed, setTreeCollapsed] = useState(false);\n  const [selectedToolEvent, setSelectedToolEvent] = useState<WireEvent | null>(null);\n  const [visibleRange, setVisibleRange] = useState<[number, number]>([0, 0]);\n  const [viewMode, setViewMode] = useState<ViewMode>(\"events\");\n  const [showUsageChart, setShowUsageChart] = useState(false);\n  const [showIntegrity, setShowIntegrity] = useState(false);\n  const virtuosoRef = useRef<VirtuosoHandle>(null);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(null);\n    if (refreshKey === 0) {\n      setExpandedSet(new Set());\n      setSearchQuery(\"\");\n      setErrorsOnly(false);\n    }\n    getWireEvents(sessionId, refreshKey > 0)\n      .then((res) => setEvents(res.events))\n      .catch((err) => setError(err.message))\n      .finally(() => setLoading(false));\n  }, [sessionId, refreshKey]);\n\n  const allTypes = useMemo(() => {\n    const types = new Set<string>();\n    for (const e of events) {\n      types.add(e.type);\n    }\n    return Array.from(types).sort();\n  }, [events]);\n\n  const toolGrouping = useMemo(() => buildToolGrouping(events), [events]);\n\n  // Search: build a set of matching event indices\n  const searchMatchSet = useMemo(() => {\n    const matches = new Set<number>();\n    if (!searchQuery) return matches;\n    const q = searchQuery.toLowerCase();\n    for (const e of events) {\n      const haystack = JSON.stringify(e.payload).toLowerCase();\n      if (haystack.includes(q) || e.type.toLowerCase().includes(q)) {\n        matches.add(e.index);\n      }\n    }\n    return matches;\n  }, [events, searchQuery]);\n\n  // Error indices (in original events order)\n  const errorIndices = useMemo(\n    () => events.filter(isErrorEvent).map((e) => e.index),\n    [events],\n  );\n\n  // Integrity check\n  const integrityResult = useMemo(() => computeIntegrity(events), [events]);\n\n  // O(1) index-based lookup for prevEvent (avoids O(n) find per row)\n  const eventByIndex = useMemo(() => {\n    const map = new Map<number, WireEvent>();\n    for (const e of events) map.set(e.index, e);\n    return map;\n  }, [events]);\n\n  // Combined filtering: type filter + errors only + search\n  const filtered = useMemo(() => {\n    let result = events;\n    if (selectedTypes.size > 0) {\n      result = result.filter((e) => selectedTypes.has(e.type));\n    }\n    if (errorsOnly) {\n      result = result.filter(isErrorEvent);\n    }\n    if (searchQuery) {\n      result = result.filter((e) => searchMatchSet.has(e.index));\n    }\n    return result;\n  }, [events, selectedTypes, errorsOnly, searchQuery, searchMatchSet]);\n\n  // Handle incoming scroll-to-tool-call-id from cross-reference navigation\n  useEffect(() => {\n    if (!scrollToToolCallId || events.length === 0) return;\n    const target = events.find(\n      (e) =>\n        (e.type === \"ToolCall\" && e.payload.id === scrollToToolCallId) ||\n        (e.type === \"ToolResult\" && e.payload.tool_call_id === scrollToToolCallId),\n    );\n    if (target) {\n      const pos = filtered.findIndex((e) => e.index === target.index);\n      if (pos >= 0 && virtuosoRef.current) {\n        setTimeout(() => {\n          virtuosoRef.current?.scrollToIndex({\n            index: pos,\n            align: \"center\",\n            behavior: \"smooth\",\n          });\n        }, 100);\n        setExpandedSet((prev) => {\n          const next = new Set(prev);\n          next.add(target.index);\n          return next;\n        });\n        setSelectedToolEvent(target);\n      }\n    }\n    onScrollTargetConsumed?.();\n  }, [scrollToToolCallId, events, filtered, onScrollTargetConsumed]);\n\n  const toggleExpand = useCallback((index: number) => {\n    setExpandedSet((prev) => {\n      const next = new Set(prev);\n      if (next.has(index)) {\n        next.delete(index);\n      } else {\n        next.add(index);\n      }\n      return next;\n    });\n  }, []);\n\n  const allExpanded =\n    filtered.length > 0 && filtered.every((e) => expandedSet.has(e.index));\n\n  const expandAll = useCallback(() => {\n    setExpandedSet((prev) => {\n      const next = new Set(prev);\n      for (const e of filtered) next.add(e.index);\n      return next;\n    });\n  }, [filtered]);\n\n  const collapseAll = useCallback(() => {\n    setExpandedSet((prev) => {\n      const next = new Set(prev);\n      for (const e of filtered) next.delete(e.index);\n      return next;\n    });\n  }, [filtered]);\n\n  const applyPreset = useCallback(\n    (types: Set<string>, newErrorsOnly: boolean) => {\n      setSelectedTypes(types);\n      setErrorsOnly(newErrorsOnly);\n    },\n    [],\n  );\n\n  // Scroll to a specific event by its index in the original events array\n  const scrollToEventIndex = useCallback(\n    (eventIndex: number) => {\n      const pos = filtered.findIndex((e) => e.index === eventIndex);\n      if (pos >= 0 && virtuosoRef.current) {\n        virtuosoRef.current.scrollToIndex({\n          index: pos,\n          align: \"center\",\n          behavior: \"smooth\",\n        });\n      }\n    },\n    [filtered],\n  );\n\n  // Handle click on ToolCall / ToolResult to show detail panel\n  const handleEventSelect = useCallback(\n    (event: WireEvent) => {\n      if (event.type === \"ToolCall\" || event.type === \"ToolResult\") {\n        setSelectedToolEvent((prev) =>\n          prev?.index === event.index ? null : event,\n        );\n      }\n    },\n    [],\n  );\n\n  // Error navigation: track current error position\n  const errorNavRef = useRef(0);\n\n  const navigateError = useCallback(\n    (direction: \"next\" | \"prev\") => {\n      if (errorIndices.length === 0) return;\n      if (direction === \"next\") {\n        errorNavRef.current =\n          (errorNavRef.current + 1) % errorIndices.length;\n      } else {\n        errorNavRef.current =\n          (errorNavRef.current - 1 + errorIndices.length) % errorIndices.length;\n      }\n      const targetIndex = errorIndices[errorNavRef.current];\n      // Find position in filtered list\n      const pos = filtered.findIndex((e) => e.index === targetIndex);\n      if (pos >= 0 && virtuosoRef.current) {\n        virtuosoRef.current.scrollToIndex({\n          index: pos,\n          align: \"center\",\n          behavior: \"smooth\",\n        });\n        // Auto-expand the error event\n        setExpandedSet((prev) => {\n          const next = new Set(prev);\n          next.add(targetIndex);\n          return next;\n        });\n      }\n    },\n    [errorIndices, filtered],\n  );\n\n  // Keyboard navigation: focused event index in filtered list\n  const focusIndexRef = useRef(0);\n\n  useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      const tag = (e.target as HTMLElement)?.tagName;\n      if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n      if (viewMode !== \"events\") return;\n\n      if (e.key === \"j\") {\n        // Move focus down\n        e.preventDefault();\n        focusIndexRef.current = Math.min(\n          focusIndexRef.current + 1,\n          filtered.length - 1,\n        );\n        virtuosoRef.current?.scrollToIndex({\n          index: focusIndexRef.current,\n          align: \"center\",\n          behavior: \"smooth\",\n        });\n      } else if (e.key === \"k\") {\n        // Move focus up\n        e.preventDefault();\n        focusIndexRef.current = Math.max(focusIndexRef.current - 1, 0);\n        virtuosoRef.current?.scrollToIndex({\n          index: focusIndexRef.current,\n          align: \"center\",\n          behavior: \"smooth\",\n        });\n      } else if (e.key === \"Enter\") {\n        // Toggle expand on focused event\n        e.preventDefault();\n        const event = filtered[focusIndexRef.current];\n        if (event) toggleExpand(event.index);\n      } else if (e.key === \"e\") {\n        // Jump to next error\n        e.preventDefault();\n        navigateError(\"next\");\n      } else if (e.key === \"/\") {\n        // Focus search box\n        e.preventDefault();\n        const input = document.querySelector(\"[data-wire-search]\") as HTMLInputElement | null;\n        input?.focus();\n      } else if (e.key === \"Escape\") {\n        // Close tool detail panel\n        setSelectedToolEvent(null);\n      }\n    };\n    window.addEventListener(\"keydown\", handler);\n    return () => window.removeEventListener(\"keydown\", handler);\n  }, [filtered, viewMode, toggleExpand, navigateError]);\n\n  if (loading) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        Loading wire events...\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-destructive\">\n        Error: {error}\n      </div>\n    );\n  }\n\n  if (events.length === 0) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        No wire events found\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <WireFilters\n        allTypes={allTypes}\n        selectedTypes={selectedTypes}\n        onToggle={(type) => {\n          setSelectedTypes((prev) => {\n            const next = new Set(prev);\n            if (next.has(type)) {\n              next.delete(type);\n            } else {\n              next.add(type);\n            }\n            return next;\n          });\n        }}\n        total={events.length}\n        filteredCount={filtered.length}\n        allExpanded={allExpanded}\n        onExpandAll={expandAll}\n        onCollapseAll={collapseAll}\n        searchQuery={searchQuery}\n        onSearchChange={setSearchQuery}\n        searchMatchCount={searchMatchSet.size}\n        errorCount={errorIndices.length}\n        errorsOnly={errorsOnly}\n        onToggleErrorsOnly={() => setErrorsOnly((v) => !v)}\n        onNextError={() => navigateError(\"next\")}\n        onPrevError={() => navigateError(\"prev\")}\n        viewMode={viewMode}\n        onViewModeChange={setViewMode}\n        showUsageChart={showUsageChart}\n        onToggleUsageChart={() => setShowUsageChart((v) => !v)}\n        onApplyPreset={applyPreset}\n        integrityScore={integrityResult.score}\n        onToggleIntegrity={() => setShowIntegrity((v) => !v)}\n      />\n\n      {/* Integrity panel (collapsible) */}\n      {showIntegrity && viewMode === \"events\" && integrityResult.orphans.length > 0 && (\n        <IntegrityPanel result={integrityResult} onScrollToIndex={scrollToEventIndex} />\n      )}\n\n      {/* Usage chart + tool token breakdown (collapsible) */}\n      {showUsageChart && viewMode === \"events\" && (\n        <>\n          <UsageChart events={events} onScrollToIndex={scrollToEventIndex} />\n          <ToolTokenBreakdown events={events} />\n          <ToolStatsDashboard events={events} onScrollToIndex={scrollToEventIndex} />\n          <TurnEfficiency events={events} onScrollToIndex={scrollToEventIndex} />\n        </>\n      )}\n\n      {viewMode === \"timeline\" ? (\n        <TimelineView events={events} onScrollToIndex={(idx) => {\n          setViewMode(\"events\");\n          // Defer scroll to after view switch\n          setTimeout(() => scrollToEventIndex(idx), 100);\n        }} />\n      ) : viewMode === \"decisions\" ? (\n        <DecisionPath events={events} onScrollToIndex={(idx) => {\n          setViewMode(\"events\");\n          setTimeout(() => scrollToEventIndex(idx), 100);\n        }} />\n      ) : (\n        <div className=\"flex flex-1 overflow-hidden\">\n          {/* Turn/Step navigation sidebar */}\n          <TurnTree\n            events={events}\n            collapsed={treeCollapsed}\n            onToggleCollapse={() => setTreeCollapsed((v) => !v)}\n            onScrollToIndex={scrollToEventIndex}\n            visibleRange={visibleRange}\n          />\n\n          {/* Main content area */}\n          <div className=\"flex flex-1 flex-col overflow-hidden\">\n            <div className=\"flex-1 overflow-hidden\">\n              <Virtuoso\n                ref={virtuosoRef}\n                data={filtered}\n                rangeChanged={(range) => {\n                  if (filtered.length > 0) {\n                    const startIdx = filtered[range.startIndex]?.index ?? 0;\n                    const endIdx = filtered[range.endIndex]?.index ?? 0;\n                    setVisibleRange([startIdx, endIdx]);\n                  }\n                }}\n                itemContent={(_, event) => {\n                  const meta = toolGrouping.get(event.index);\n                  return (\n                    <WireEventCard\n                      event={event}\n                      expanded={expandedSet.has(event.index)}\n                      onToggle={() => toggleExpand(event.index)}\n                      onSelect={() => handleEventSelect(event)}\n                      selected={selectedToolEvent?.index === event.index}\n                      prevEvent={event.index > 0 ? eventByIndex.get(event.index - 1) : undefined}\n                      nestLevel={meta?.nestLevel}\n                      linkedToolName={meta?.linkedToolName}\n                      linkedToolCallId={meta?.linkedToolCallId}\n                      searchMatch={searchQuery ? searchMatchSet.has(event.index) : undefined}\n                    />\n                  );\n                }}\n              />\n            </div>\n\n            {/* Tool call detail panel */}\n            {selectedToolEvent && (\n              <ToolCallDetail\n                selectedEvent={selectedToolEvent}\n                allEvents={events}\n                onClose={() => setSelectedToolEvent(null)}\n                onNavigateToContext={onNavigateToContext}\n              />\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Keyboard shortcuts help */}\n      <KeyboardHelp />\n    </div>\n  );\n}\n\nfunction KeyboardHelp() {\n  const [show, setShow] = useState(false);\n\n  return (\n    <div className=\"fixed bottom-4 right-4 z-50\">\n      {show && (\n        <div className=\"mb-2 rounded-lg border bg-popover p-3 shadow-lg text-xs space-y-1 w-52\">\n          <div className=\"font-medium text-foreground mb-2\">Keyboard Shortcuts</div>\n          <Shortcut keys=\"j / k\" desc=\"Navigate events\" />\n          <Shortcut keys=\"Enter\" desc=\"Expand / collapse\" />\n          <Shortcut keys=\"e\" desc=\"Next error\" />\n          <Shortcut keys=\"/\" desc=\"Focus search\" />\n          <Shortcut keys=\"Esc\" desc=\"Close panel\" />\n          <Shortcut keys=\"1 / 2 / 3\" desc=\"Switch tab\" />\n        </div>\n      )}\n      <button\n        onClick={() => setShow((v) => !v)}\n        className=\"rounded-full border bg-popover shadow-md w-7 h-7 flex items-center justify-center text-sm text-muted-foreground hover:text-foreground transition-colors\"\n        title=\"Keyboard shortcuts\"\n      >\n        ?\n      </button>\n    </div>\n  );\n}\n\nfunction Shortcut({ keys, desc }: { keys: string; desc: string }) {\n  return (\n    <div className=\"flex items-center justify-between\">\n      <span className=\"text-muted-foreground\">{desc}</span>\n      <kbd className=\"font-mono text-[10px] bg-muted px-1.5 py-0.5 rounded border\">{keys}</kbd>\n    </div>\n  );\n}\n"
  },
  {
    "path": "vis/src/hooks/use-theme.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\";\n\ntype Theme = \"light\" | \"dark\";\n\nfunction getSystemTheme(): Theme {\n  return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n    ? \"dark\"\n    : \"light\";\n}\n\nexport function useTheme() {\n  const [theme, setThemeState] = useState<Theme>(() => {\n    const saved = localStorage.getItem(\"vis-theme\") as Theme | null;\n    return saved ?? getSystemTheme();\n  });\n\n  useEffect(() => {\n    document.documentElement.classList.toggle(\"dark\", theme === \"dark\");\n    localStorage.setItem(\"vis-theme\", theme);\n  }, [theme]);\n\n  const toggleTheme = useCallback(() => {\n    setThemeState((prev) => (prev === \"dark\" ? \"light\" : \"dark\"));\n  }, []);\n\n  return { theme, toggleTheme };\n}\n"
  },
  {
    "path": "vis/src/index.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n@import \"shadcn/tailwind.css\";\n@import \"@fontsource-variable/inter\";\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n  --radius: 0.5rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.141 0.005 285.823);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.141 0.005 285.823);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.141 0.005 285.823);\n  --primary: oklch(0.21 0.006 285.885);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.967 0.001 286.375);\n  --secondary-foreground: oklch(0.21 0.006 285.885);\n  --muted: oklch(0.967 0.001 286.375);\n  --muted-foreground: oklch(0.552 0.016 285.938);\n  --accent: oklch(0.967 0.001 286.375);\n  --accent-foreground: oklch(0.21 0.006 285.885);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.92 0.004 286.32);\n  --input: oklch(0.92 0.004 286.32);\n  --ring: oklch(0.552 0.016 285.938);\n  --destructive-foreground: oklch(1 0 0);\n}\n\n.dark {\n  --background: oklch(0.141 0.005 285.823);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.18 0.005 285.823);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.18 0.005 285.823);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.75 0.02 285.885);\n  --primary-foreground: oklch(0.141 0.005 285.823);\n  --secondary: oklch(0.274 0.006 286.033);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.274 0.006 286.033);\n  --muted-foreground: oklch(0.705 0.015 286.067);\n  --accent: oklch(0.274 0.006 286.033);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(0.274 0.006 286.033);\n  --input: oklch(0.274 0.006 286.033);\n  --ring: oklch(0.552 0.016 285.938);\n  --destructive-foreground: oklch(0.141 0.005 285.823);\n}\n\n* {\n  border-color: var(--border);\n}\n\nhtml,\nbody {\n  margin: 0;\n  padding: 0;\n  height: 100%;\n  overflow: hidden;\n}\n\nbody {\n  font-family:\n    \"Inter Variable\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n    sans-serif;\n  font-size: 14px;\n  background-color: var(--background);\n  color: var(--foreground);\n}\n\n#root {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n@theme inline {\n  --font-sans:\n    \"Inter Variable\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n    sans-serif;\n  --font-mono:\n    ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\",\n    monospace;\n\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n}\n\n@layer base {\n  *,\n  *::before,\n  *::after {\n    box-sizing: border-box;\n    @apply border-border outline-ring/50;\n  }\n}\n\n/* Markdown prose styles for streamdown */\n.streamdown-prose h1 { font-size: 1.5em; font-weight: 700; margin: 0.5em 0; }\n.streamdown-prose h2 { font-size: 1.25em; font-weight: 600; margin: 0.5em 0; }\n.streamdown-prose h3 { font-size: 1.1em; font-weight: 600; margin: 0.4em 0; }\n.streamdown-prose h4,\n.streamdown-prose h5,\n.streamdown-prose h6 { font-size: 1em; font-weight: 600; margin: 0.3em 0; }\n.streamdown-prose p { margin: 0.3em 0; }\n.streamdown-prose ul { list-style: disc; padding-left: 1.5em; margin: 0.3em 0; }\n.streamdown-prose ol { list-style: decimal; padding-left: 1.5em; margin: 0.3em 0; }\n.streamdown-prose li { margin: 0.1em 0; }\n.streamdown-prose blockquote {\n  border-left: 3px solid var(--border);\n  padding-left: 0.75em;\n  margin: 0.3em 0;\n  color: var(--muted-foreground);\n}\n.streamdown-prose hr {\n  border: none;\n  border-top: 1px solid var(--border);\n  margin: 0.5em 0;\n}\n.streamdown-prose a {\n  color: var(--primary);\n  text-decoration: underline;\n}\n.streamdown-prose table {\n  border-collapse: collapse;\n  width: 100%;\n  margin: 0.3em 0;\n  font-size: 0.85em;\n}\n.streamdown-prose th,\n.streamdown-prose td {\n  border: 1px solid var(--border);\n  padding: 0.3em 0.6em;\n  text-align: left;\n}\n.streamdown-prose th {\n  background: var(--muted);\n  font-weight: 600;\n}\n\n::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\n::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--muted-foreground);\n  border-radius: 3px;\n  opacity: 0.5;\n}\n"
  },
  {
    "path": "vis/src/lib/api.ts",
    "content": "import { apiCache } from \"./cache.ts\";\n\nconst BASE = \"/api/vis\";\n\nasync function fetchJSON<T>(path: string): Promise<T> {\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), 30_000);\n  try {\n    const res = await fetch(`${BASE}${path}`, { signal: controller.signal });\n    if (!res.ok) {\n      throw new Error(`API error: ${res.status} ${res.statusText}`);\n    }\n    return (await res.json()) as T;\n  } catch (e) {\n    if (e instanceof DOMException && e.name === \"AbortError\") {\n      throw new Error(\"Request timed out\");\n    }\n    throw e;\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n\nexport interface SessionMetadataInfo {\n  session_id: string;\n  title: string;\n  title_generated: boolean;\n  archived: boolean;\n  archived_at: number | null;\n  auto_archive_exempt: boolean;\n  wire_mtime: number | null;\n}\n\nexport interface SessionInfo {\n  session_id: string;\n  session_dir: string;\n  work_dir: string | null;\n  work_dir_hash: string;\n  title: string;\n  last_updated: number;\n  has_wire: boolean;\n  has_context: boolean;\n  has_state: boolean;\n  metadata: SessionMetadataInfo | null;\n  wire_size: number;\n  context_size: number;\n  state_size: number;\n  total_size: number;\n  turns: number;\n  imported?: boolean;\n}\n\nexport interface SessionSummary {\n  turns: number;\n  steps: number;\n  tool_calls: number;\n  errors: number;\n  compactions: number;\n  duration_sec: number;\n  input_tokens: number;\n  output_tokens: number;\n  wire_size: number;\n  context_size: number;\n  state_size: number;\n  total_size: number;\n}\n\nexport interface WireEvent {\n  index: number;\n  timestamp: number;\n  type: string;\n  payload: Record<string, unknown>;\n}\n\nexport interface WireResponse {\n  total: number;\n  events: WireEvent[];\n}\n\nexport interface ContextMessage {\n  index: number;\n  role: string;\n  content?: ContentPart[] | string;\n  tool_calls?: ToolCallItem[];\n  tool_call_id?: string;\n  name?: string;\n  partial?: boolean;\n  // _usage / _checkpoint special fields\n  token_count?: number;\n  id?: number;\n  [key: string]: unknown;\n}\n\n/** Normalize content to always be an array of ContentPart. */\nexport function normalizeContent(\n  content: ContentPart[] | string | undefined | null,\n): ContentPart[] {\n  if (!content) return [];\n  if (typeof content === \"string\") {\n    return [{ type: \"text\", text: content }];\n  }\n  if (Array.isArray(content)) return content;\n  return [];\n}\n\nexport interface ContentPart {\n  type: string;\n  // TextPart\n  text?: string;\n  // ThinkPart (actual field name is \"think\", not \"thinking\")\n  think?: string;\n  thinking?: string;\n  encrypted?: string;\n  // ImageURLPart\n  image_url?: { url: string; id?: string };\n  // AudioURLPart\n  audio_url?: { url: string; id?: string };\n  // VideoURLPart\n  video_url?: { url: string; id?: string };\n  [key: string]: unknown;\n}\n\nexport interface ToolCallItem {\n  id: string;\n  type: string;\n  function: {\n    name: string;\n    arguments: string;\n  };\n  extras?: Record<string, unknown>;\n}\n\nexport interface ContextResponse {\n  total: number;\n  messages: ContextMessage[];\n}\n\nexport function listSessions(forceRefresh = false): Promise<SessionInfo[]> {\n  if (forceRefresh) apiCache.invalidate(\"sessions\");\n  return apiCache.get(\"sessions\", () => fetchJSON<SessionInfo[]>(\"/sessions\"), 30_000);\n}\n\nconst CONTENT_PART_MAP: Record<string, string> = {\n  text: \"TextPart\",\n  think: \"ThinkPart\",\n};\n\n/** Resolve ContentPart subtypes so the rest of the frontend can match on\n *  \"TextPart\" / \"ThinkPart\" instead of checking payload.type.\n *  Also recurses into SubagentEvent inner events. */\nfunction normalizeWireEvents(res: WireResponse): WireResponse {\n  return {\n    ...res,\n    events: res.events.map((e) => {\n      // Top-level ContentPart\n      if (e.type === \"ContentPart\" && typeof e.payload.type === \"string\") {\n        const mapped = CONTENT_PART_MAP[e.payload.type];\n        if (mapped) return { ...e, type: mapped };\n      }\n      // SubagentEvent: normalize nested event\n      if (e.type === \"SubagentEvent\" && e.payload.event && typeof e.payload.event === \"object\") {\n        const inner = e.payload.event as Record<string, unknown>;\n        if (inner.type === \"ContentPart\" && inner.payload && typeof inner.payload === \"object\") {\n          const innerPayload = inner.payload as Record<string, unknown>;\n          const mapped = CONTENT_PART_MAP[innerPayload.type as string];\n          if (mapped) {\n            return { ...e, payload: { ...e.payload, event: { ...inner, type: mapped } } };\n          }\n        }\n      }\n      return e;\n    }),\n  };\n}\n\nexport function getWireEvents(sessionId: string, forceRefresh = false): Promise<WireResponse> {\n  const key = `wire:${sessionId}`;\n  if (forceRefresh) apiCache.invalidate(key);\n  return apiCache.get(key, () =>\n    fetchJSON<WireResponse>(`/sessions/${sessionId}/wire`).then(normalizeWireEvents),\n  );\n}\n\nexport function getContextMessages(\n  sessionId: string,\n  forceRefresh = false,\n): Promise<ContextResponse> {\n  const key = `context:${sessionId}`;\n  if (forceRefresh) apiCache.invalidate(key);\n  return apiCache.get(key, () => fetchJSON<ContextResponse>(`/sessions/${sessionId}/context`));\n}\n\nexport function getSessionState(\n  sessionId: string,\n  forceRefresh = false,\n): Promise<Record<string, unknown>> {\n  const key = `state:${sessionId}`;\n  if (forceRefresh) apiCache.invalidate(key);\n  return apiCache.get(key, () => fetchJSON<Record<string, unknown>>(`/sessions/${sessionId}/state`));\n}\n\nexport interface AggregateStats {\n  total_sessions: number;\n  total_turns: number;\n  total_tokens: { input: number; output: number };\n  total_duration_sec: number;\n  tool_usage: { name: string; count: number; error_count: number }[];\n  daily_usage: { date: string; sessions: number; turns: number }[];\n  per_project: { work_dir: string; sessions: number; turns: number }[];\n}\n\nexport interface VisCapabilities {\n  open_in_supported: boolean;\n}\n\nexport function getAggregateStats(forceRefresh = false): Promise<AggregateStats> {\n  const key = \"aggregate-stats\";\n  if (forceRefresh) apiCache.invalidate(key);\n  return apiCache.get(key, () => fetchJSON<AggregateStats>(\"/statistics\"), 60_000);\n}\n\nexport function getVisCapabilities(forceRefresh = false): Promise<VisCapabilities> {\n  const key = \"vis-capabilities\";\n  if (forceRefresh) apiCache.invalidate(key);\n  return apiCache.get(key, () => fetchJSON<VisCapabilities>(\"/capabilities\"), 60_000);\n}\n\nexport function getSessionDownloadUrl(sessionId: string): string {\n  return `${BASE}/sessions/${sessionId}/download`;\n}\n\ntype OpenInApp = \"finder\";\n\nexport async function openInPath(app: OpenInApp, path: string): Promise<void> {\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), 30_000);\n  try {\n    const res = await fetch(\"/api/open-in\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ app, path }),\n      signal: controller.signal,\n    });\n    if (!res.ok) {\n      const detail = await res.json().catch(() => ({}));\n      throw new Error(detail.detail || `Open failed: ${res.status}`);\n    }\n  } catch (e) {\n    if (e instanceof DOMException && e.name === \"AbortError\") {\n      throw new Error(\"Open request timed out\");\n    }\n    throw e;\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n\nexport function getSessionSummary(\n  sessionId: string,\n  forceRefresh = false,\n): Promise<SessionSummary> {\n  const key = `summary:${sessionId}`;\n  if (forceRefresh) apiCache.invalidate(key);\n  return apiCache.get(key, () => fetchJSON<SessionSummary>(`/sessions/${sessionId}/summary`));\n}\n\nexport async function importSession(file: File): Promise<{ session_id: string; work_dir_hash: string }> {\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), 120_000);\n  try {\n    const formData = new FormData();\n    formData.append(\"file\", file);\n    const res = await fetch(`${BASE}/sessions/import`, { method: \"POST\", body: formData, signal: controller.signal });\n    if (!res.ok) {\n      const detail = await res.json().catch(() => ({}));\n      throw new Error(detail.detail || `Import failed: ${res.status}`);\n    }\n    apiCache.invalidate(\"sessions\");\n    return res.json();\n  } catch (e) {\n    if (e instanceof DOMException && e.name === \"AbortError\") {\n      throw new Error(\"Import request timed out\");\n    }\n    throw e;\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n\nexport async function deleteSession(sessionId: string): Promise<void> {\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), 30_000);\n  try {\n    const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: \"DELETE\", signal: controller.signal });\n    if (!res.ok) {\n      const detail = await res.json().catch(() => ({}));\n      throw new Error(detail.detail || `Delete failed: ${res.status}`);\n    }\n    apiCache.invalidate(\"sessions\");\n  } catch (e) {\n    if (e instanceof DOMException && e.name === \"AbortError\") {\n      throw new Error(\"Delete request timed out\");\n    }\n    throw e;\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n"
  },
  {
    "path": "vis/src/lib/cache.ts",
    "content": "interface CacheEntry<T> {\n  data: T;\n  timestamp: number;\n  promise?: Promise<T>;\n}\n\nconst DEFAULT_TTL_MS = 300_000; // 5 minutes\nconst MAX_ENTRIES = 100;\n\nclass ApiCache {\n  private store = new Map<string, CacheEntry<unknown>>();\n\n  private evictIfNeeded(): void {\n    if (this.store.size <= MAX_ENTRIES) return;\n    // Evict oldest entries (by insertion order — Map preserves order)\n    const toDelete = this.store.size - MAX_ENTRIES;\n    let deleted = 0;\n    for (const [key, entry] of this.store) {\n      // Don't evict in-flight requests\n      if (entry.promise) continue;\n      this.store.delete(key);\n      deleted++;\n      if (deleted >= toDelete) break;\n    }\n  }\n\n  async get<T>(\n    key: string,\n    fetcher: () => Promise<T>,\n    ttlMs: number = DEFAULT_TTL_MS,\n  ): Promise<T> {\n    const existing = this.store.get(key) as CacheEntry<T> | undefined;\n\n    // 1. If cached and not expired, return cached data\n    if (existing && Date.now() - existing.timestamp < ttlMs) {\n      // If there's an in-flight promise, wait for it\n      if (existing.promise) {\n        return existing.promise;\n      }\n      return existing.data;\n    }\n\n    // 2. If there's an in-flight request for this key, return the same promise (dedup)\n    if (existing?.promise) {\n      return existing.promise;\n    }\n\n    // 3. Otherwise, call fetcher, store result, return it\n    const promise = fetcher().then(\n      (data) => {\n        this.store.set(key, { data, timestamp: Date.now() });\n        this.evictIfNeeded();\n        return data;\n      },\n      (err: unknown) => {\n        // On failure, remove the in-flight entry so next call retries\n        const current = this.store.get(key);\n        if (current?.promise === promise) {\n          this.store.delete(key);\n        }\n        throw err;\n      },\n    );\n\n    this.store.set(key, {\n      data: undefined as T,\n      timestamp: Date.now(),\n      promise,\n    });\n\n    return promise;\n  }\n\n  invalidate(key: string): void {\n    this.store.delete(key);\n  }\n\n  clear(): void {\n    this.store.clear();\n  }\n}\n\nexport const apiCache = new ApiCache();\n"
  },
  {
    "path": "vis/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "vis/src/main.tsx",
    "content": "import { Component, StrictMode, type ErrorInfo, type ReactNode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { App } from \"./App.tsx\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport \"./index.css\";\n\nclass ErrorBoundary extends Component<\n  { children: ReactNode },\n  { error: Error | null }\n> {\n  state: { error: Error | null } = { error: null };\n\n  static getDerivedStateFromError(error: Error) {\n    return { error };\n  }\n\n  componentDidCatch(error: Error, info: ErrorInfo) {\n    console.error(\"Uncaught error:\", error, info);\n  }\n\n  render() {\n    if (this.state.error) {\n      return (\n        <div className=\"flex h-screen items-center justify-center bg-background text-foreground\">\n          <div className=\"max-w-md space-y-4 text-center\">\n            <h1 className=\"text-lg font-semibold\">Something went wrong</h1>\n            <pre className=\"rounded border bg-muted p-3 text-xs text-left overflow-auto max-h-48 whitespace-pre-wrap\">\n              {this.state.error.message}\n            </pre>\n            <button\n              onClick={() => window.location.reload()}\n              className=\"rounded border px-4 py-1.5 text-sm hover:bg-muted transition-colors\"\n            >\n              Reload\n            </button>\n          </div>\n        </div>\n      );\n    }\n    return this.props.children;\n  }\n}\n\ncreateRoot(document.getElementById(\"root\")!).render(\n  <StrictMode>\n    <TooltipProvider>\n      <ErrorBoundary>\n        <App />\n      </ErrorBoundary>\n    </TooltipProvider>\n  </StrictMode>,\n);\n"
  },
  {
    "path": "vis/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"vite/client\"],\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"erasableSyntaxOnly\": false,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "vis/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "vis/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "vis/vite.config.ts",
    "content": "import path from \"node:path\";\nimport tailwindcss from \"@tailwindcss/vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { defineConfig } from \"vite\";\n\nexport default defineConfig({\n  base: \"./\",\n  plugins: [react(), tailwindcss()],\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n  server: {\n    port: 5173,\n    proxy: {\n      \"/api\": {\n        target: process.env.VITE_API_TARGET ?? \"http://127.0.0.1:5495\",\n        changeOrigin: true,\n      },\n    },\n  },\n});\n"
  },
  {
    "path": "web/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n.vite\n.claude\n"
  },
  {
    "path": "web/biome.jsonc",
    "content": "{\n  \"$schema\": \"./node_modules/@biomejs/biome/configuration_schema.json\",\n  \"extends\": [\"ultracite/core\", \"ultracite/react\"],\n  \"files\": {\n    // external code\n    \"includes\": [\n      \"!src/components/ai-elements\",\n      \"!src/components/ui\",\n      \"!src/lib/api\",\n      \"!**/dist\",\n    ],\n  },\n  \"formatter\": {\n    // Disable Biome formatting while we continue using Prettier.\n    \"enabled\": false,\n  },\n  // TODO: some rules can be enabled later when the development is more mature\n  \"linter\": {\n    \"rules\": {\n      \"correctness\": {\n        // View Transitions API uses ::view-transition-old(root) which linter doesn't recognize\n        \"noUnknownTypeSelector\": \"off\",\n      },\n      \"suspicious\": {\n        // Middleware pattern often requires async without await\n        \"useAwait\": \"off\",\n      },\n      \"performance\": {},\n      \"complexity\": {\n        // TODO: refactor complex functions later\n        \"noExcessiveCognitiveComplexity\": \"off\",\n      },\n      \"nursery\": {\n        \"useSortedClasses\": \"off\",\n        \"noLeakedRender\": \"off\",\n      },\n      \"style\": \"off\",\n    },\n  },\n  \"assist\": {\n    \"enabled\": true,\n    \"actions\": {\n      \"source\": {\n        \"organizeImports\": \"off\",\n        \"useSortedAttributes\": \"off\",\n      },\n    },\n  },\n  \"overrides\": [\n    {\n      \"includes\": [\"src/lib/api/**/*\"],\n      \"linter\": {\n        \"enabled\": false,\n      },\n      \"formatter\": {\n        \"enabled\": false,\n      },\n    },\n    {\n      \"includes\": [\"biome.jsonc\", \"index.html\"],\n      \"formatter\": {\n        \"enabled\": false,\n      },\n    },\n  ],\n}\n"
  },
  {
    "path": "web/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"registries\": {\n    \"@ai-elements\": \"https://registry.ai-sdk.dev/{name}.json\"\n  }\n}\n"
  },
  {
    "path": "web/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/logo.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, interactive-widget=resizes-content\" />\n    <title>Kimi Code Web UI</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/openapi.json",
    "content": "{\n  \"openapi\": \"3.1.0\",\n  \"info\": { \"title\": \"Kimi Code CLI Web Interface\", \"version\": \"0.1.0\" },\n  \"paths\": {\n    \"/api/config/\": {\n      \"get\": {\n        \"tags\": [\"config\"],\n        \"summary\": \"Get global (kimi-cli) config snapshot\",\n        \"description\": \"Get global (kimi-cli) config snapshot.\",\n        \"operationId\": \"get_global_config_api_config__get\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/GlobalConfig\" }\n              }\n            }\n          }\n        }\n      },\n      \"patch\": {\n        \"tags\": [\"config\"],\n        \"summary\": \"Update global (kimi-cli) default model/thinking\",\n        \"description\": \"Update global (kimi-cli) default model/thinking.\",\n        \"operationId\": \"update_global_config_api_config__patch\",\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/UpdateGlobalConfigRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/UpdateGlobalConfigResponse\"\n                }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/config/toml\": {\n      \"get\": {\n        \"tags\": [\"config\"],\n        \"summary\": \"Get kimi-cli config.toml\",\n        \"description\": \"Get kimi-cli config.toml.\",\n        \"operationId\": \"get_config_toml_api_config_toml_get\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/ConfigToml\" }\n              }\n            }\n          }\n        }\n      },\n      \"put\": {\n        \"tags\": [\"config\"],\n        \"summary\": \"Update kimi-cli config.toml\",\n        \"description\": \"Update kimi-cli config.toml.\",\n        \"operationId\": \"update_config_toml_api_config_toml_put\",\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/UpdateConfigTomlRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/UpdateConfigTomlResponse\"\n                }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/sessions/\": {\n      \"get\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"List all sessions\",\n        \"description\": \"List sessions with optional pagination and search.\\n\\nArgs:\\n    limit: Maximum number of sessions to return (default 100, max 500).\\n    offset: Number of sessions to skip (default 0).\\n    q: Optional search query to filter by title or work_dir.\\n    archived: Filter by archived status.\\n        - None (default): Only return non-archived sessions.\\n        - True: Only return archived sessions.\",\n        \"operationId\": \"list_sessions_api_sessions__get\",\n        \"parameters\": [\n          {\n            \"name\": \"limit\",\n            \"in\": \"query\",\n            \"required\": false,\n            \"schema\": { \"type\": \"integer\", \"default\": 100, \"title\": \"Limit\" }\n          },\n          {\n            \"name\": \"offset\",\n            \"in\": \"query\",\n            \"required\": false,\n            \"schema\": { \"type\": \"integer\", \"default\": 0, \"title\": \"Offset\" }\n          },\n          {\n            \"name\": \"q\",\n            \"in\": \"query\",\n            \"required\": false,\n            \"schema\": {\n              \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n              \"title\": \"Q\"\n            }\n          },\n          {\n            \"name\": \"archived\",\n            \"in\": \"query\",\n            \"required\": false,\n            \"schema\": {\n              \"anyOf\": [{ \"type\": \"boolean\" }, { \"type\": \"null\" }],\n              \"title\": \"Archived\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": { \"$ref\": \"#/components/schemas/Session\" },\n                  \"title\": \"Response List Sessions Api Sessions  Get\"\n                }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Create a new session\",\n        \"description\": \"Create a new session.\",\n        \"operationId\": \"create_session_api_sessions__post\",\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"anyOf\": [\n                  { \"$ref\": \"#/components/schemas/CreateSessionRequest\" },\n                  { \"type\": \"null\" }\n                ],\n                \"title\": \"Request\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/Session\" }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/sessions/{session_id}\": {\n      \"get\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Get session\",\n        \"description\": \"Get a session by ID.\",\n        \"operationId\": \"get_session_api_sessions__session_id__get\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"anyOf\": [\n                    { \"$ref\": \"#/components/schemas/Session\" },\n                    { \"type\": \"null\" }\n                  ],\n                  \"title\": \"Response Get Session Api Sessions  Session Id  Get\"\n                }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      },\n      \"delete\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Delete a session\",\n        \"description\": \"Delete a session.\",\n        \"operationId\": \"delete_session_api_sessions__session_id__delete\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": { \"application/json\": { \"schema\": {} } }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      },\n      \"patch\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Update session\",\n        \"description\": \"Update a session (e.g., rename title or archive/unarchive).\",\n        \"operationId\": \"update_session_api_sessions__session_id__patch\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": { \"$ref\": \"#/components/schemas/UpdateSessionRequest\" }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/Session\" }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/sessions/{session_id}/files\": {\n      \"post\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Upload file to session\",\n        \"description\": \"Upload a file to a session.\",\n        \"operationId\": \"upload_session_file_api_sessions__session_id__files_post\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          }\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"multipart/form-data\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/Body_upload_session_file_api_sessions__session_id__files_post\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/UploadSessionFileResponse\"\n                }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/sessions/{session_id}/uploads/{path}\": {\n      \"get\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Get uploaded file from session uploads\",\n        \"description\": \"Get a file from a session's uploads directory.\",\n        \"operationId\": \"get_session_upload_file_api_sessions__session_id__uploads__path__get\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          },\n          {\n            \"name\": \"path\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": { \"type\": \"string\", \"title\": \"Path\" }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": { \"application/json\": { \"schema\": {} } }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/sessions/{session_id}/files/{path}\": {\n      \"get\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Get file or list directory from session work_dir\",\n        \"description\": \"Get a file or list directory from session work directory.\",\n        \"operationId\": \"get_session_file_api_sessions__session_id__files__path__get\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          },\n          {\n            \"name\": \"path\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": { \"type\": \"string\", \"title\": \"Path\" }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": { \"application/json\": { \"schema\": {} } }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/sessions/{session_id}/generate-title\": {\n      \"post\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Generate session title using AI\",\n        \"description\": \"Generate a concise session title using AI based on the first conversation turn.\\n\\nIf request body is empty or parameters are missing, the backend will\\nautomatically read the first turn from wire.jsonl.\",\n        \"operationId\": \"generate_session_title_api_sessions__session_id__generate_title_post\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"anyOf\": [\n                  { \"$ref\": \"#/components/schemas/GenerateTitleRequest\" },\n                  { \"type\": \"null\" }\n                ],\n                \"title\": \"Request\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/GenerateTitleResponse\"\n                }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/sessions/{session_id}/git-diff\": {\n      \"get\": {\n        \"tags\": [\"sessions\"],\n        \"summary\": \"Get git diff stats\",\n        \"description\": \"get git diff stats for the session's work directory\",\n        \"operationId\": \"get_session_git_diff_api_sessions__session_id__git_diff_get\",\n        \"parameters\": [\n          {\n            \"name\": \"session_id\",\n            \"in\": \"path\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\",\n              \"title\": \"Session Id\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/GitDiffStats\" }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/work-dirs/\": {\n      \"get\": {\n        \"tags\": [\"work-dirs\"],\n        \"summary\": \"List available work directories\",\n        \"description\": \"Get a list of available work directories from metadata.\",\n        \"operationId\": \"get_work_dirs_api_work_dirs__get\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"items\": { \"type\": \"string\" },\n                  \"type\": \"array\",\n                  \"title\": \"Response Get Work Dirs Api Work Dirs  Get\"\n                }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/work-dirs/startup\": {\n      \"get\": {\n        \"tags\": [\"work-dirs\"],\n        \"summary\": \"Get the startup directory\",\n        \"description\": \"Get the directory where kimi web was started.\",\n        \"operationId\": \"get_startup_dir_api_work_dirs_startup_get\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"title\": \"Response Get Startup Dir Api Work Dirs Startup Get\"\n                }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/api/open-in\": {\n      \"post\": {\n        \"tags\": [\"open-in\"],\n        \"summary\": \"Open a path in a local application\",\n        \"operationId\": \"open_in_api_open_in_post\",\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": { \"$ref\": \"#/components/schemas/OpenInRequest\" }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/OpenInResponse\" }\n              }\n            }\n          },\n          \"422\": {\n            \"description\": \"Validation Error\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": { \"$ref\": \"#/components/schemas/HTTPValidationError\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"/healthz\": {\n      \"get\": {\n        \"summary\": \"Health Probe\",\n        \"description\": \"Health check endpoint.\",\n        \"operationId\": \"health_probe_healthz_get\",\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful Response\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"additionalProperties\": true,\n                  \"type\": \"object\",\n                  \"title\": \"Response Health Probe Healthz Get\"\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n  },\n  \"components\": {\n    \"schemas\": {\n      \"Body_upload_session_file_api_sessions__session_id__files_post\": {\n        \"properties\": {\n          \"file\": { \"type\": \"string\", \"format\": \"binary\", \"title\": \"File\" }\n        },\n        \"type\": \"object\",\n        \"required\": [\"file\"],\n        \"title\": \"Body_upload_session_file_api_sessions__session_id__files_post\"\n      },\n      \"ConfigModel\": {\n        \"properties\": {\n          \"provider\": { \"type\": \"string\", \"title\": \"Provider\" },\n          \"model\": { \"type\": \"string\", \"title\": \"Model\" },\n          \"max_context_size\": {\n            \"type\": \"integer\",\n            \"title\": \"Max Context Size\"\n          },\n          \"capabilities\": {\n            \"anyOf\": [\n              {\n                \"items\": { \"$ref\": \"#/components/schemas/ModelCapability\" },\n                \"type\": \"array\",\n                \"uniqueItems\": true\n              },\n              { \"type\": \"null\" }\n            ],\n            \"title\": \"Capabilities\"\n          },\n          \"name\": {\n            \"type\": \"string\",\n            \"title\": \"Name\",\n            \"description\": \"Model key in kimi-cli config (Config.models)\"\n          },\n          \"provider_type\": {\n            \"$ref\": \"#/components/schemas/ProviderType\",\n            \"description\": \"Provider type (LLMProvider.type)\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\n          \"provider\",\n          \"model\",\n          \"max_context_size\",\n          \"name\",\n          \"provider_type\"\n        ],\n        \"title\": \"ConfigModel\",\n        \"description\": \"Model configuration for frontend.\"\n      },\n      \"ConfigToml\": {\n        \"properties\": {\n          \"content\": {\n            \"type\": \"string\",\n            \"title\": \"Content\",\n            \"description\": \"Raw TOML content\"\n          },\n          \"path\": {\n            \"type\": \"string\",\n            \"title\": \"Path\",\n            \"description\": \"Path to config file\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"content\", \"path\"],\n        \"title\": \"ConfigToml\",\n        \"description\": \"Raw config.toml content.\"\n      },\n      \"CreateSessionRequest\": {\n        \"properties\": {\n          \"work_dir\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Work Dir\"\n          },\n          \"create_dir\": {\n            \"type\": \"boolean\",\n            \"title\": \"Create Dir\",\n            \"default\": false\n          }\n        },\n        \"type\": \"object\",\n        \"title\": \"CreateSessionRequest\",\n        \"description\": \"Create session request.\"\n      },\n      \"GenerateTitleRequest\": {\n        \"properties\": {\n          \"user_message\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"User Message\"\n          },\n          \"assistant_response\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Assistant Response\"\n          }\n        },\n        \"type\": \"object\",\n        \"title\": \"GenerateTitleRequest\",\n        \"description\": \"Generate title request.\\n\\nParameters are optional - if not provided, the backend will read\\nfrom wire.jsonl automatically.\"\n      },\n      \"GenerateTitleResponse\": {\n        \"properties\": { \"title\": { \"type\": \"string\", \"title\": \"Title\" } },\n        \"type\": \"object\",\n        \"required\": [\"title\"],\n        \"title\": \"GenerateTitleResponse\",\n        \"description\": \"Generate title response.\"\n      },\n      \"GitDiffStats\": {\n        \"properties\": {\n          \"is_git_repo\": {\n            \"type\": \"boolean\",\n            \"title\": \"Is Git Repo\",\n            \"description\": \"Whether the directory is a git repo\"\n          },\n          \"has_changes\": {\n            \"type\": \"boolean\",\n            \"title\": \"Has Changes\",\n            \"description\": \"Whether there are uncommitted changes\",\n            \"default\": false\n          },\n          \"total_additions\": {\n            \"type\": \"integer\",\n            \"title\": \"Total Additions\",\n            \"description\": \"Total added lines\",\n            \"default\": 0\n          },\n          \"total_deletions\": {\n            \"type\": \"integer\",\n            \"title\": \"Total Deletions\",\n            \"description\": \"Total deleted lines\",\n            \"default\": 0\n          },\n          \"files\": {\n            \"items\": { \"$ref\": \"#/components/schemas/GitFileDiff\" },\n            \"type\": \"array\",\n            \"title\": \"Files\",\n            \"description\": \"Per-file diff stats\",\n            \"default\": []\n          },\n          \"error\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Error\",\n            \"description\": \"Error message if any\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"is_git_repo\"],\n        \"title\": \"GitDiffStats\",\n        \"description\": \"Git diff statistics for a work directory.\"\n      },\n      \"GitFileDiff\": {\n        \"properties\": {\n          \"path\": {\n            \"type\": \"string\",\n            \"title\": \"Path\",\n            \"description\": \"File path\"\n          },\n          \"additions\": {\n            \"type\": \"integer\",\n            \"title\": \"Additions\",\n            \"description\": \"Number of added lines\"\n          },\n          \"deletions\": {\n            \"type\": \"integer\",\n            \"title\": \"Deletions\",\n            \"description\": \"Number of deleted lines\"\n          },\n          \"status\": {\n            \"type\": \"string\",\n            \"enum\": [\"added\", \"modified\", \"deleted\", \"renamed\"],\n            \"title\": \"Status\",\n            \"description\": \"File change status\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"path\", \"additions\", \"deletions\", \"status\"],\n        \"title\": \"GitFileDiff\",\n        \"description\": \"Single file git diff statistics\"\n      },\n      \"GlobalConfig\": {\n        \"properties\": {\n          \"default_model\": {\n            \"type\": \"string\",\n            \"title\": \"Default Model\",\n            \"description\": \"Current default model key\"\n          },\n          \"default_thinking\": {\n            \"type\": \"boolean\",\n            \"title\": \"Default Thinking\",\n            \"description\": \"Current default thinking mode\"\n          },\n          \"models\": {\n            \"items\": { \"$ref\": \"#/components/schemas/ConfigModel\" },\n            \"type\": \"array\",\n            \"title\": \"Models\",\n            \"description\": \"All configured models\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"default_model\", \"default_thinking\", \"models\"],\n        \"title\": \"GlobalConfig\",\n        \"description\": \"Global configuration snapshot for frontend.\"\n      },\n      \"HTTPValidationError\": {\n        \"properties\": {\n          \"detail\": {\n            \"items\": { \"$ref\": \"#/components/schemas/ValidationError\" },\n            \"type\": \"array\",\n            \"title\": \"Detail\"\n          }\n        },\n        \"type\": \"object\",\n        \"title\": \"HTTPValidationError\"\n      },\n      \"ModelCapability\": {\n        \"type\": \"string\",\n        \"enum\": [\"image_in\", \"video_in\", \"thinking\", \"always_thinking\"]\n      },\n      \"OpenInRequest\": {\n        \"properties\": {\n          \"app\": {\n            \"type\": \"string\",\n            \"enum\": [\n              \"finder\",\n              \"cursor\",\n              \"vscode\",\n              \"iterm\",\n              \"terminal\",\n              \"antigravity\"\n            ],\n            \"title\": \"App\"\n          },\n          \"path\": { \"type\": \"string\", \"title\": \"Path\" }\n        },\n        \"type\": \"object\",\n        \"required\": [\"app\", \"path\"],\n        \"title\": \"OpenInRequest\",\n        \"description\": \"Open path in a local app.\"\n      },\n      \"OpenInResponse\": {\n        \"properties\": {\n          \"ok\": { \"type\": \"boolean\", \"title\": \"Ok\" },\n          \"detail\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Detail\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"ok\"],\n        \"title\": \"OpenInResponse\",\n        \"description\": \"Open path response.\"\n      },\n      \"ProviderType\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"kimi\",\n          \"openai_legacy\",\n          \"openai_responses\",\n          \"anthropic\",\n          \"google_genai\",\n          \"gemini\",\n          \"vertexai\",\n          \"_echo\",\n          \"_scripted_echo\",\n          \"_chaos\"\n        ]\n      },\n      \"Session\": {\n        \"properties\": {\n          \"session_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\",\n            \"title\": \"Session Id\",\n            \"description\": \"Session unique ID\"\n          },\n          \"title\": {\n            \"type\": \"string\",\n            \"title\": \"Title\",\n            \"description\": \"Session title derived from kimi-cli history\"\n          },\n          \"last_updated\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\",\n            \"title\": \"Last Updated\",\n            \"description\": \"Last updated timestamp\"\n          },\n          \"is_running\": {\n            \"type\": \"boolean\",\n            \"title\": \"Is Running\",\n            \"description\": \"Whether the session is running\",\n            \"default\": false\n          },\n          \"status\": {\n            \"anyOf\": [\n              { \"$ref\": \"#/components/schemas/SessionStatus\" },\n              { \"type\": \"null\" }\n            ],\n            \"description\": \"Session runtime status\"\n          },\n          \"work_dir\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Work Dir\",\n            \"description\": \"Working directory for the session\"\n          },\n          \"session_dir\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Session Dir\",\n            \"description\": \"Session directory path\"\n          },\n          \"archived\": {\n            \"type\": \"boolean\",\n            \"title\": \"Archived\",\n            \"description\": \"Whether the session is archived\",\n            \"default\": false\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"session_id\", \"title\", \"last_updated\"],\n        \"title\": \"Session\",\n        \"description\": \"Web UI session metadata.\"\n      },\n      \"SessionStatus\": {\n        \"properties\": {\n          \"session_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\",\n            \"title\": \"Session Id\",\n            \"description\": \"Session unique ID\"\n          },\n          \"state\": {\n            \"type\": \"string\",\n            \"enum\": [\"stopped\", \"idle\", \"busy\", \"restarting\", \"error\"],\n            \"title\": \"State\",\n            \"description\": \"Current session state\"\n          },\n          \"seq\": {\n            \"type\": \"integer\",\n            \"title\": \"Seq\",\n            \"description\": \"Monotonic sequence number\"\n          },\n          \"worker_id\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Worker Id\",\n            \"description\": \"Worker instance ID\"\n          },\n          \"reason\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Reason\",\n            \"description\": \"Reason for the state transition\"\n          },\n          \"detail\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Detail\",\n            \"description\": \"Additional detail for debugging\"\n          },\n          \"updated_at\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\",\n            \"title\": \"Updated At\",\n            \"description\": \"Timestamp for this state\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"session_id\", \"state\", \"seq\", \"updated_at\"],\n        \"title\": \"SessionStatus\",\n        \"description\": \"Runtime status of a web session.\"\n      },\n      \"UpdateConfigTomlRequest\": {\n        \"properties\": {\n          \"content\": {\n            \"type\": \"string\",\n            \"title\": \"Content\",\n            \"description\": \"New TOML content\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"content\"],\n        \"title\": \"UpdateConfigTomlRequest\",\n        \"description\": \"Request to update config.toml.\"\n      },\n      \"UpdateConfigTomlResponse\": {\n        \"properties\": {\n          \"success\": {\n            \"type\": \"boolean\",\n            \"title\": \"Success\",\n            \"description\": \"Whether the update was successful\"\n          },\n          \"error\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Error\",\n            \"description\": \"Error message if failed\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"success\"],\n        \"title\": \"UpdateConfigTomlResponse\",\n        \"description\": \"Response after updating config.toml.\"\n      },\n      \"UpdateGlobalConfigRequest\": {\n        \"properties\": {\n          \"default_model\": {\n            \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"null\" }],\n            \"title\": \"Default Model\",\n            \"description\": \"New default model key\"\n          },\n          \"default_thinking\": {\n            \"anyOf\": [{ \"type\": \"boolean\" }, { \"type\": \"null\" }],\n            \"title\": \"Default Thinking\",\n            \"description\": \"New default thinking mode\"\n          },\n          \"restart_running_sessions\": {\n            \"anyOf\": [{ \"type\": \"boolean\" }, { \"type\": \"null\" }],\n            \"title\": \"Restart Running Sessions\",\n            \"description\": \"Whether to restart running sessions\"\n          },\n          \"force_restart_busy_sessions\": {\n            \"anyOf\": [{ \"type\": \"boolean\" }, { \"type\": \"null\" }],\n            \"title\": \"Force Restart Busy Sessions\",\n            \"description\": \"Whether to force restart busy sessions\"\n          }\n        },\n        \"type\": \"object\",\n        \"title\": \"UpdateGlobalConfigRequest\",\n        \"description\": \"Request to update global config.\"\n      },\n      \"UpdateGlobalConfigResponse\": {\n        \"properties\": {\n          \"config\": {\n            \"$ref\": \"#/components/schemas/GlobalConfig\",\n            \"description\": \"Updated config snapshot\"\n          },\n          \"restarted_session_ids\": {\n            \"anyOf\": [\n              { \"items\": { \"type\": \"string\" }, \"type\": \"array\" },\n              { \"type\": \"null\" }\n            ],\n            \"title\": \"Restarted Session Ids\",\n            \"description\": \"IDs of restarted sessions\"\n          },\n          \"skipped_busy_session_ids\": {\n            \"anyOf\": [\n              { \"items\": { \"type\": \"string\" }, \"type\": \"array\" },\n              { \"type\": \"null\" }\n            ],\n            \"title\": \"Skipped Busy Session Ids\",\n            \"description\": \"IDs of busy sessions that were skipped\"\n          }\n        },\n        \"type\": \"object\",\n        \"required\": [\"config\"],\n        \"title\": \"UpdateGlobalConfigResponse\",\n        \"description\": \"Response after updating global config.\"\n      },\n      \"UpdateSessionRequest\": {\n        \"properties\": {\n          \"title\": {\n            \"anyOf\": [\n              { \"type\": \"string\", \"maxLength\": 200, \"minLength\": 1 },\n              { \"type\": \"null\" }\n            ],\n            \"title\": \"Title\"\n          },\n          \"archived\": {\n            \"anyOf\": [{ \"type\": \"boolean\" }, { \"type\": \"null\" }],\n            \"title\": \"Archived\",\n            \"description\": \"Archive or unarchive the session\"\n          }\n        },\n        \"type\": \"object\",\n        \"title\": \"UpdateSessionRequest\",\n        \"description\": \"Update session request.\"\n      },\n      \"UploadSessionFileResponse\": {\n        \"properties\": {\n          \"path\": { \"type\": \"string\", \"title\": \"Path\" },\n          \"filename\": { \"type\": \"string\", \"title\": \"Filename\" },\n          \"size\": { \"type\": \"integer\", \"title\": \"Size\" }\n        },\n        \"type\": \"object\",\n        \"required\": [\"path\", \"filename\", \"size\"],\n        \"title\": \"UploadSessionFileResponse\",\n        \"description\": \"Upload file response.\"\n      },\n      \"ValidationError\": {\n        \"properties\": {\n          \"loc\": {\n            \"items\": { \"anyOf\": [{ \"type\": \"string\" }, { \"type\": \"integer\" }] },\n            \"type\": \"array\",\n            \"title\": \"Location\"\n          },\n          \"msg\": { \"type\": \"string\", \"title\": \"Message\" },\n          \"type\": { \"type\": \"string\", \"title\": \"Error Type\" }\n        },\n        \"type\": \"object\",\n        \"required\": [\"loc\", \"msg\", \"type\"],\n        \"title\": \"ValidationError\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "web/openapitools.json",
    "content": "{\n  \"$schema\": \"./node_modules/@openapitools/openapi-generator-cli/config.schema.json\",\n  \"spaces\": 2,\n  \"generator-cli\": {\n    \"version\": \"7.17.0\"\n  }\n}\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"kimi-cli-web\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"host\": \"vite --host\",\n    \"typecheck\": \"tsc -b --noEmit\",\n    \"build\": \"tsc -b && vite build\",\n    \"generate\": \"./scripts/generate-api.sh\",\n    \"format\": \"biome check --write .\",\n    \"lint\": \"biome check .\",\n    \"lint:fix\": \"biome check --write .\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@codemirror/lang-yaml\": \"^6.1.2\",\n    \"@fontsource-variable/inter\": \"^5.2.8\",\n    \"@fontsource/iosevka\": \"^5.2.5\",\n    \"@radix-ui/react-collapsible\": \"^1.1.12\",\n    \"@radix-ui/react-context-menu\": \"^2.2.16\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-hover-card\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slot\": \"^1.2.4\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-toggle\": \"^1.1.10\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@radix-ui/react-use-controllable-state\": \"^1.2.2\",\n    \"@tailwindcss/vite\": \"^4.1.17\",\n    \"@tanstack/react-table\": \"^8.21.3\",\n    \"@uiw/react-codemirror\": \"^4.25.3\",\n    \"@xyflow/react\": \"^12.9.3\",\n    \"ai\": \"^5.0.99\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"diff\": \"^8.0.2\",\n    \"embla-carousel-react\": \"^8.6.0\",\n    \"gitdiff-parser\": \"^0.3.1\",\n    \"js-md5\": \"^0.8.3\",\n    \"js-yaml\": \"^4.1.1\",\n    \"lucide-react\": \"^0.561.0\",\n    \"motion\": \"^12.23.24\",\n    \"nanoid\": \"^5.1.6\",\n    \"radix-ui\": \"^1.4.3\",\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-error-boundary\": \"^6.0.0\",\n    \"react-resizable-panels\": \"^4.0.7\",\n    \"react-scan\": \"^0.4.3\",\n    \"react-virtuoso\": \"4.17.0\",\n    \"refractor\": \"^5.0.0\",\n    \"shadcn\": \"^3.7.0\",\n    \"shiki\": \"^3.20.0\",\n    \"sonner\": \"^2.0.7\",\n    \"streamdown\": \"^1.6.10\",\n    \"swr\": \"^2.3.6\",\n    \"tailwind-merge\": \"^3.4.0\",\n    \"tailwindcss\": \"^4.1.17\",\n    \"tokenlens\": \"^1.3.1\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"use-stick-to-bottom\": \"^1.1.1\",\n    \"uuid\": \"^13.0.0\",\n    \"zustand\": \"^5.0.9\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"2.3.8\",\n    \"@types/js-md5\": \"^0.8.0\",\n    \"@types/js-yaml\": \"^4.0.9\",\n    \"@types/node\": \"^24.10.1\",\n    \"@types/react\": \"^19.2.5\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@vitejs/plugin-react\": \"^5.1.1\",\n    \"rollup-plugin-visualizer\": \"6.0.3\",\n    \"typescript\": \"~5.9.3\",\n    \"ultracite\": \"^7.1.1\",\n    \"vite\": \"^7.2.4\",\n    \"vite-plugin-node-polyfills\": \"0.24.0\"\n  }\n}\n"
  },
  {
    "path": "web/scripts/generate-api.sh",
    "content": "#!/bin/bash\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_DIR=\"$(dirname \"$SCRIPT_DIR\")\"\ncd \"$PROJECT_DIR\"\n\nAPI_PORT=\"${API_PORT:-5494}\"\nAPI_URL=\"http://127.0.0.1:${API_PORT}/openapi.json\"\n\necho \"Fetching OpenAPI spec from $API_URL...\"\nif ! curl -sf -o openapi.json \"$API_URL\"; then\n    echo \"Error: Failed to fetch OpenAPI spec. Is the backend running on port $API_PORT?\"\n    echo \"Start it with: uv run ikimi web --port $API_PORT\"\n    exit 1\nfi\n\necho \"Removing old API client...\"\nrm -rf src/lib/api\n\necho \"Generating TypeScript client...\"\n\n# Check if docker is available\nif ! command -v docker &> /dev/null; then\n    echo \"Error: docker is not installed\"\n    exit 1\nfi\n\n# Try docker without sudo first, fall back to sudo if needed\nDOCKER_CMD=\"docker\"\nif ! docker info &> /dev/null; then\n    if sudo docker info &> /dev/null; then\n        DOCKER_CMD=\"sudo docker\"\n        echo \"Note: Using sudo for docker\"\n    else\n        echo \"Error: Cannot access docker. Make sure docker is running and you have permissions.\"\n        exit 1\n    fi\nfi\n\n$DOCKER_CMD run --rm \\\n    -v \"$PROJECT_DIR:/local\" \\\n    --network host \\\n    openapitools/openapi-generator-cli:v7.17.0 generate \\\n    -i /local/openapi.json \\\n    -g typescript-fetch \\\n    -o /local/src/lib/api \\\n    --additional-properties=supportsES6=true,npmVersion=10.9.0,typescriptThreePlus=true\n\n# Fix ownership if docker created files as root\nif [ -n \"$(find src/lib/api -user root 2>/dev/null)\" ]; then\n    echo \"Fixing file ownership...\"\n    sudo chown -R \"$(whoami)\" src/lib/api\nfi\n\necho \"Formatting generated code...\"\nbun run format\n\necho \"Done! API client generated successfully.\"\n"
  },
  {
    "path": "web/src/App.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { ChatStatus } from \"ai\";\nimport { PromptInputProvider } from \"@ai-elements\";\nimport { toast } from \"sonner\";\nimport { PanelLeftOpen, PanelLeftClose } from \"lucide-react\";\nimport { cn } from \"./lib/utils\";\nimport { ResizablePanel, ResizablePanelGroup } from \"./components/ui/resizable\";\nimport { ChatWorkspaceContainer } from \"./features/chat/chat-workspace-container\";\nimport { SessionsSidebar } from \"./features/sessions/sessions\";\nimport { CreateSessionDialog } from \"./features/sessions/create-session-dialog\";\nimport { Toaster } from \"./components/ui/sonner\";\nimport { formatRelativeTime } from \"./hooks/utils\";\nimport { useSessions } from \"./hooks/useSessions\";\nimport { useTheme } from \"./hooks/use-theme\";\nimport { ThemeToggle } from \"./components/ui/theme-toggle\";\nimport type { SessionStatus } from \"./lib/api/models\";\nimport type { PanelSize, PanelImperativeHandle } from \"react-resizable-panels\";\nimport { consumeAuthTokenFromUrl, setAuthToken } from \"./lib/auth\";\n\n/**\n * Get session ID from URL search params\n */\nfunction getSessionIdFromUrl(): string | null {\n  const params = new URLSearchParams(window.location.search);\n  return params.get(\"session\");\n}\n\n/**\n * Update URL with session ID without triggering page reload\n */\nfunction updateUrlWithSession(sessionId: string | null): void {\n  const url = new URL(window.location.href);\n  if (sessionId) {\n    url.searchParams.set(\"session\", sessionId);\n  } else {\n    url.searchParams.delete(\"session\");\n  }\n  window.history.replaceState({}, \"\", url.toString());\n}\n\nconst SIDEBAR_COLLAPSED_SIZE = 48;\nconst SIDEBAR_MIN_SIZE = 200;\nconst SIDEBAR_DEFAULT_SIZE = 260;\nconst SIDEBAR_ANIMATION_MS = 250;\n\nfunction App() {\n  // Initialize theme on app startup\n  useTheme();\n\n  const sidebarElementRef = useRef<HTMLDivElement | null>(null);\n  const sidebarPanelRef = useRef<PanelImperativeHandle | null>(null);\n  const sessionsHook = useSessions();\n  const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);\n  const [isDesktop, setIsDesktop] = useState(() => {\n    if (typeof window === \"undefined\") {\n      return true;\n    }\n    return window.matchMedia(\"(min-width: 1024px)\").matches;\n  });\n\n  const {\n    sessions,\n    archivedSessions,\n    selectedSessionId,\n    createSession,\n    deleteSession,\n    selectSession,\n    uploadSessionFile,\n    getSessionFile,\n    getSessionFileUrl,\n    listSessionDirectory,\n    refreshSession,\n    refreshSessions,\n    refreshArchivedSessions,\n    loadMoreSessions,\n    loadMoreArchivedSessions,\n    hasMoreSessions,\n    hasMoreArchivedSessions,\n    isLoadingMore,\n    isLoadingMoreArchived,\n    isLoadingArchived,\n    searchQuery,\n    setSearchQuery,\n    applySessionStatus,\n    fetchWorkDirs,\n    fetchStartupDir,\n    renameSession,\n    generateTitle,\n    archiveSession,\n    unarchiveSession,\n    bulkArchiveSessions,\n    bulkUnarchiveSessions,\n    bulkDeleteSessions,\n    forkSession,\n    error: sessionsError,\n  } = sessionsHook;\n\n  const currentSession = useMemo(\n    () => sessions.find((session) => session.sessionId === selectedSessionId),\n    [sessions, selectedSessionId],\n  );\n\n  const [streamStatus, setStreamStatus] = useState<ChatStatus>(\"ready\");\n\n  useEffect(() => {\n    const token = consumeAuthTokenFromUrl();\n    if (token) {\n      setAuthToken(token);\n    }\n  }, []);\n\n  // Create session dialog state (lifted to App for unified access)\n  const [showCreateDialog, setShowCreateDialog] = useState(false);\n\n  // Auto-open create dialog or create session directly from URL params\n  useEffect(() => {\n    const params = new URLSearchParams(window.location.search);\n    const action = params.get(\"action\");\n    if (action === \"create\") {\n      setShowCreateDialog(true);\n    } else if (action === \"create-in-dir\") {\n      const workDir = params.get(\"workDir\");\n      if (!workDir) return; // invalid params, ignore silently\n      createSession(workDir).catch(() => {\n        // Errors are already handled globally via sessionsError → toast\n      });\n    } else {\n      return;\n    }\n    params.delete(\"action\");\n    params.delete(\"workDir\");\n    const url = new URL(window.location.href);\n    url.search = params.toString();\n    window.history.replaceState({}, \"\", url.toString());\n  }, [createSession]);\n\n  const handleOpenCreateDialog = useCallback(() => {\n    setShowCreateDialog(true);\n    setIsMobileSidebarOpen(false);\n  }, []);\n\n  const handleOpenMobileSidebar = useCallback(() => {\n    setIsMobileSidebarOpen(true);\n  }, []);\n\n  const handleCloseMobileSidebar = useCallback(() => {\n    setIsMobileSidebarOpen(false);\n  }, []);\n\n  // Sidebar state\n  const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);\n  const [isSidebarAnimating, setIsSidebarAnimating] = useState(false);\n  const handleCollapseSidebar = useCallback(() => {\n    setIsSidebarAnimating(true);\n    sidebarPanelRef.current?.collapse();\n  }, []);\n  const handleExpandSidebar = useCallback(() => {\n    setIsSidebarAnimating(true);\n    sidebarPanelRef.current?.expand();\n  }, []);\n  const handleSidebarResize = useCallback((panelSize: PanelSize) => {\n    const collapsed = panelSize.inPixels <= SIDEBAR_COLLAPSED_SIZE + 1;\n    setIsSidebarCollapsed((prev) => (prev === collapsed ? prev : collapsed));\n  }, []);\n\n  useEffect(() => {\n    if (!isSidebarAnimating) {\n      return;\n    }\n    const timer = window.setTimeout(() => {\n      setIsSidebarAnimating(false);\n    }, SIDEBAR_ANIMATION_MS);\n    return () => window.clearTimeout(timer);\n  }, [isSidebarAnimating]);\n\n  useEffect(() => {\n    const current = sidebarPanelRef.current;\n    if (!current) {\n      return;\n    }\n    setIsSidebarCollapsed(current.isCollapsed());\n  }, []);\n\n  useEffect(() => {\n    const element = sidebarElementRef.current;\n    if (!element) {\n      return;\n    }\n    if (isSidebarAnimating) {\n      element.style.transition = `flex-basis ${SIDEBAR_ANIMATION_MS}ms ease-in-out`;\n      return;\n    }\n    element.style.transition = \"\";\n  }, [isSidebarAnimating]);\n\n  // Track layout breakpoint and close mobile sidebar when switching to desktop\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(min-width: 1024px)\");\n    const handleChange = () => {\n      const matches = mediaQuery.matches;\n      setIsDesktop(matches);\n      if (matches) setIsMobileSidebarOpen(false);\n    };\n    handleChange();\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  // Track if we've restored session from URL\n  const hasRestoredFromUrlRef = useRef(false);\n\n  // Eagerly restore session from URL - don't wait for session list to load\n  // This allows session content to load in parallel with the session list\n  useEffect(() => {\n    if (hasRestoredFromUrlRef.current) {\n      return;\n    }\n\n    const urlSessionId = getSessionIdFromUrl();\n    if (urlSessionId) {\n      console.log(\"[App] Eagerly restoring session from URL:\", urlSessionId);\n      selectSession(urlSessionId);\n    }\n    hasRestoredFromUrlRef.current = true;\n  }, [selectSession]);\n\n  // Validate session exists once session list loads, clear URL if not found\n  useEffect(() => {\n    if (sessions.length === 0 || !selectedSessionId) {\n      return;\n    }\n\n    if (searchQuery.trim() || hasMoreSessions) {\n      return;\n    }\n\n    const sessionExists = sessions.some(\n      (s) => s.sessionId === selectedSessionId,\n    );\n    if (!sessionExists) {\n      console.log(\"[App] Session from URL not found, clearing selection\");\n      updateUrlWithSession(null);\n      selectSession(\"\");\n    }\n  }, [sessions, selectedSessionId, selectSession, hasMoreSessions, searchQuery]);\n\n  // Update URL when selected session changes\n  useEffect(() => {\n    // Skip the initial render before URL restoration\n    if (!hasRestoredFromUrlRef.current) {\n      return;\n    }\n    updateUrlWithSession(selectedSessionId || null);\n  }, [selectedSessionId]);\n\n  // Show toast notifications for errors\n  useEffect(() => {\n    if (sessionsError) {\n      toast.error(\"Session Error\", {\n        description: sessionsError,\n      });\n    }\n  }, [sessionsError]);\n\n  const handleStreamStatusChange = useCallback((nextStatus: ChatStatus) => {\n    setStreamStatus(nextStatus);\n  }, []);\n\n  const handleSessionStatus = useCallback(\n    (status: SessionStatus) => {\n      applySessionStatus(status);\n\n      if (status.state !== \"idle\") {\n        return;\n      }\n\n      const reason = status.reason ?? \"\";\n\n      if (reason === \"config_update\") {\n        console.log(\"[App] Config update detected, refreshing global config\");\n        window.dispatchEvent(new Event(\"kimi:config-update\"));\n      }\n\n      if (!reason.startsWith(\"prompt_\")) {\n        return;\n      }\n\n      console.log(\n        \"[App] Prompt complete, refreshing session info:\",\n        status.sessionId,\n      );\n      refreshSession(status.sessionId);\n    },\n    [applySessionStatus, refreshSession],\n  );\n\n  const handleCreateSession = useCallback(\n    async (workDir: string, createDir?: boolean) => {\n      await createSession(workDir, createDir);\n    },\n    [createSession],\n  );\n\n  const handleCreateSessionInDir = useCallback(\n    async (workDir: string) => {\n      await createSession(workDir);\n    },\n    [createSession],\n  );\n\n  const handleDeleteSession = useCallback(\n    async (sessionId: string) => {\n      await deleteSession(sessionId);\n    },\n    [deleteSession],\n  );\n\n  const handleSelectSession = useCallback(\n    (sessionId: string) => {\n      selectSession(sessionId);\n      setIsMobileSidebarOpen(false);\n    },\n    [selectSession],\n  );\n\n  const handleRefreshSessions = useCallback(async () => {\n    await refreshSessions();\n  }, [refreshSessions]);\n\n  const handleSearchQueryChange = useCallback(\n    (query: string) => {\n      setSearchQuery(query);\n    },\n    [setSearchQuery],\n  );\n\n  // Transform Session[] to SessionSummary[] for sidebar\n  const sessionSummaries = useMemo(\n    () =>\n      sessions.map((session) => ({\n        id: session.sessionId,\n        title: session.title ?? \"Untitled\",\n        updatedAt: formatRelativeTime(session.lastUpdated),\n        workDir: session.workDir,\n        lastUpdated: session.lastUpdated,\n      })),\n    [sessions],\n  );\n\n  // Transform archived Session[] to SessionSummary[] for sidebar\n  const archivedSessionSummaries = useMemo(\n    () =>\n      archivedSessions.map((session) => ({\n        id: session.sessionId,\n        title: session.title ?? \"Untitled\",\n        updatedAt: formatRelativeTime(session.lastUpdated),\n        workDir: session.workDir,\n        lastUpdated: session.lastUpdated,\n      })),\n    [archivedSessions],\n  );\n\n  const handleForkSession = useCallback(\n    async (sessionId: string, turnIndex: number) => {\n      await forkSession(sessionId, turnIndex);\n    },\n    [forkSession],\n  );\n\n  const renderChatPanel = () => (\n    <ChatWorkspaceContainer\n      selectedSessionId={selectedSessionId}\n      currentSession={currentSession}\n      sessionDescription={currentSession?.title}\n      onSessionStatus={handleSessionStatus}\n      onStreamStatusChange={handleStreamStatusChange}\n      uploadSessionFile={uploadSessionFile}\n      onListSessionDirectory={listSessionDirectory}\n      onGetSessionFileUrl={getSessionFileUrl}\n      onGetSessionFile={getSessionFile}\n      onOpenCreateDialog={handleOpenCreateDialog}\n      onOpenSidebar={handleOpenMobileSidebar}\n      generateTitle={generateTitle}\n      onRenameSession={renameSession}\n      onForkSession={handleForkSession}\n    />\n  );\n\n  return (\n    <PromptInputProvider>\n      <div className=\"box-border flex h-[100dvh] flex-col bg-background text-foreground px-[calc(0.75rem+var(--safe-left))] pr-[calc(0.75rem+var(--safe-right))] pt-[calc(0.75rem+var(--safe-top))] pb-1 lg:pb-[calc(0.75rem+var(--safe-bottom))] max-lg:h-[100svh] max-lg:overflow-hidden\">\n        <div className=\"mx-auto flex h-full min-h-0 w-full flex-1 flex-col gap-2 max-w-none\">\n          {isDesktop ? (\n            <ResizablePanelGroup\n              orientation=\"horizontal\"\n              className=\"min-h-0 flex-1 overflow-hidden\"\n            >\n              {/* Sidebar */}\n              <ResizablePanel\n                id=\"sessions\"\n                collapsible\n                collapsedSize={SIDEBAR_COLLAPSED_SIZE}\n                defaultSize={SIDEBAR_DEFAULT_SIZE}\n                minSize={SIDEBAR_MIN_SIZE}\n                elementRef={sidebarElementRef}\n                panelRef={sidebarPanelRef}\n                onResize={handleSidebarResize}\n                className={cn(\"relative min-h-0 border-r pl-0.5 pr-2 overflow-hidden\")}\n              >\n                {/* Collapsed sidebar - vertical strip with logo and expand button */}\n                <div\n                  className={cn(\n                    \"absolute inset-0 flex h-full flex-col items-center py-3 transition-all duration-200 ease-in-out\",\n                    isSidebarCollapsed\n                      ? \"opacity-100 translate-x-0\"\n                      : \"opacity-0 -translate-x-2 pointer-events-none select-none\",\n                  )}\n                >\n                  <a\n                    href=\"https://www.kimi.com/code\"\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"hover:opacity-80 transition-opacity\"\n                  >\n                    <img\n                      src=\"/logo.png\"\n                      alt=\"Kimi\"\n                      width={24}\n                      height={24}\n                      className=\"size-6\"\n                    />\n                  </a>\n                  <button\n                    type=\"button\"\n                    aria-label=\"Expand sidebar\"\n                    className=\"mt-auto mb-1 inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/50 hover:text-foreground\"\n                    onClick={handleExpandSidebar}\n                  >\n                    <PanelLeftOpen className=\"size-4\" />\n                  </button>\n                </div>\n                {/* Expanded sidebar */}\n                <div\n                  className={cn(\n                    \"absolute inset-0 flex h-full min-h-0 flex-col gap-3 transition-all duration-200 ease-in-out\",\n                    isSidebarCollapsed\n                      ? \"opacity-0 translate-x-2 pointer-events-none select-none\"\n                      : \"opacity-100 translate-x-0\",\n                  )}\n                >\n                  <SessionsSidebar\n                    onDeleteSession={handleDeleteSession}\n                    onSelectSession={handleSelectSession}\n                    onRenameSession={renameSession}\n                    onArchiveSession={archiveSession}\n                    onUnarchiveSession={unarchiveSession}\n                    onBulkArchiveSessions={bulkArchiveSessions}\n                    onBulkUnarchiveSessions={bulkUnarchiveSessions}\n                    onBulkDeleteSessions={bulkDeleteSessions}\n                    onRefreshSessions={handleRefreshSessions}\n                    onRefreshArchivedSessions={refreshArchivedSessions}\n                    onLoadMoreSessions={loadMoreSessions}\n                    onLoadMoreArchivedSessions={loadMoreArchivedSessions}\n                    onOpenCreateDialog={handleOpenCreateDialog}\n                    onCreateSessionInDir={handleCreateSessionInDir}\n                    streamStatus={streamStatus}\n                    selectedSessionId={selectedSessionId}\n                    sessions={sessionSummaries}\n                    archivedSessions={archivedSessionSummaries}\n                    hasMoreSessions={hasMoreSessions}\n                    hasMoreArchivedSessions={hasMoreArchivedSessions}\n                    isLoadingMore={isLoadingMore}\n                    isLoadingMoreArchived={isLoadingMoreArchived}\n                    isLoadingArchived={isLoadingArchived}\n                    searchQuery={searchQuery}\n                    onSearchQueryChange={handleSearchQueryChange}\n                  />\n                  <div className=\"mt-auto flex items-center justify-between pl-2 pb-2 pr-2\">\n                    <div className=\"flex items-center gap-2\">\n                      <ThemeToggle />\n                    </div>\n                    <button\n                      type=\"button\"\n                      aria-label=\"Collapse sidebar\"\n                      className=\"inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/50 hover:text-foreground\"\n                      onClick={handleCollapseSidebar}\n                    >\n                      <PanelLeftClose className=\"size-4\" />\n                    </button>\n                  </div>\n                </div>\n              </ResizablePanel>\n\n              {/* Main Chat Area */}\n              <ResizablePanel id=\"chat\" className=\"relative min-h-0 flex justify-center flex-1\">\n                {renderChatPanel()}\n              </ResizablePanel>\n            </ResizablePanelGroup>\n          ) : (\n            <div className=\"flex min-h-0 flex-1 flex-col\">\n              {renderChatPanel()}\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Toast notifications */}\n      <Toaster position=\"top-right\" richColors />\n\n      {/* Create Session Dialog - unified for sidebar button and keyboard shortcut */}\n      <CreateSessionDialog\n        open={showCreateDialog}\n        onOpenChange={setShowCreateDialog}\n        onConfirm={handleCreateSession}\n        fetchWorkDirs={fetchWorkDirs}\n        fetchStartupDir={fetchStartupDir}\n      />\n\n      {/* Mobile Sessions Sidebar */}\n      {isMobileSidebarOpen ? (\n        <div className=\"fixed inset-0 z-50 flex lg:hidden\" role=\"dialog\" aria-modal=\"true\">\n          <button\n            type=\"button\"\n            className=\"absolute inset-0 bg-black/40\"\n            aria-label=\"Close sessions sidebar\"\n            onClick={handleCloseMobileSidebar}\n          />\n          <div className=\"relative flex h-full w-[min(86vw,360px)] flex-col border-r border-border bg-background pt-[var(--safe-top)] shadow-2xl\">\n            <div className=\"min-h-0 flex-1\">\n              <SessionsSidebar\n                onDeleteSession={handleDeleteSession}\n                onSelectSession={handleSelectSession}\n                onRenameSession={renameSession}\n                onArchiveSession={archiveSession}\n                onUnarchiveSession={unarchiveSession}\n                onBulkArchiveSessions={bulkArchiveSessions}\n                onBulkUnarchiveSessions={bulkUnarchiveSessions}\n                onBulkDeleteSessions={bulkDeleteSessions}\n                onRefreshSessions={handleRefreshSessions}\n                onRefreshArchivedSessions={refreshArchivedSessions}\n                onLoadMoreSessions={loadMoreSessions}\n                onLoadMoreArchivedSessions={loadMoreArchivedSessions}\n                onOpenCreateDialog={handleOpenCreateDialog}\n                onCreateSessionInDir={handleCreateSessionInDir}\n                onClose={handleCloseMobileSidebar}\n                streamStatus={streamStatus}\n                selectedSessionId={selectedSessionId}\n                sessions={sessionSummaries}\n                archivedSessions={archivedSessionSummaries}\n                hasMoreSessions={hasMoreSessions}\n                hasMoreArchivedSessions={hasMoreArchivedSessions}\n                isLoadingMore={isLoadingMore}\n                isLoadingMoreArchived={isLoadingMoreArchived}\n                isLoadingArchived={isLoadingArchived}\n                searchQuery={searchQuery}\n                onSearchQueryChange={handleSearchQueryChange}\n              />\n            </div>\n            <div className=\"flex items-center justify-between border-t px-3 py-2\">\n              <ThemeToggle />\n            </div>\n          </div>\n        </div>\n      ) : null}\n    </PromptInputProvider>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "web/src/bootstrap.tsx",
    "content": "import { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport \"./index.css\";\nimport App from \"./App.tsx\";\nimport { ErrorBoundary } from \"./components/error-boundary\";\n\nconst DYNAMIC_IMPORT_ERROR_PATTERNS: string[] = [\n  \"Failed to fetch dynamically imported module\",\n  \"Importing a module script failed\",\n  \"Failed to load module script\",\n  \"ChunkLoadError\",\n];\n\nconst isDynamicImportFailure = (error: Error): boolean =>\n  DYNAMIC_IMPORT_ERROR_PATTERNS.some((pattern) =>\n    error.message.includes(pattern),\n  );\n\nconst DYNAMIC_IMPORT_RELOAD_KEY = \"kimi:dynamic-import-reload\";\n\nconst shouldReloadAfterDynamicImportFailure = (): boolean =>\n  sessionStorage.getItem(DYNAMIC_IMPORT_RELOAD_KEY) !== \"1\";\n\nconst markDynamicImportReloaded = (): void => {\n  sessionStorage.setItem(DYNAMIC_IMPORT_RELOAD_KEY, \"1\");\n};\n\nconst setupDynamicImportRecovery = (): void => {\n  // Internal UI ships with frequent breaking changes, so if a stale tab hits\n  // missing hashed assets we prefer a single automatic refresh to align to\n  // the latest build. The session guard avoids infinite reload loops when the\n  // failure is due to transient network issues instead of a version mismatch.\n  window.addEventListener(\"vite:preloadError\", () => {\n    if (shouldReloadAfterDynamicImportFailure()) {\n      markDynamicImportReloaded();\n      window.location.reload();\n    }\n  });\n\n  window.addEventListener(\n    \"unhandledrejection\",\n    (event: PromiseRejectionEvent) => {\n      const { reason } = event;\n      if (reason instanceof Error && isDynamicImportFailure(reason)) {\n        event.preventDefault();\n        if (shouldReloadAfterDynamicImportFailure()) {\n          markDynamicImportReloaded();\n          window.location.reload();\n        }\n      }\n    },\n  );\n};\n\nsetupDynamicImportRecovery();\ncreateRoot(document.getElementById(\"root\")!).render(\n  <StrictMode>\n    <ErrorBoundary>\n      <App />\n    </ErrorBoundary>\n  </StrictMode>,\n);\n"
  },
  {
    "path": "web/src/components/ai-elements/chain-of-thought.tsx",
    "content": "\"use client\";\n\nimport { useControllableState } from \"@radix-ui/react-use-controllable-state\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  BrainIcon,\n  ChevronDownIcon,\n  DotIcon,\n  type LucideIcon,\n} from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { createContext, memo, useContext, useMemo } from \"react\";\n\ntype ChainOfThoughtContextValue = {\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n};\n\nconst ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(\n  null,\n);\n\nconst useChainOfThought = () => {\n  const context = useContext(ChainOfThoughtContext);\n  if (!context) {\n    throw new Error(\n      \"ChainOfThought components must be used within ChainOfThought\",\n    );\n  }\n  return context;\n};\n\nexport type ChainOfThoughtProps = ComponentProps<\"div\"> & {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\nexport const ChainOfThought = memo(\n  ({\n    className,\n    open,\n    defaultOpen = false,\n    onOpenChange,\n    children,\n    ...props\n  }: ChainOfThoughtProps) => {\n    const [isOpen, setIsOpen] = useControllableState({\n      prop: open,\n      defaultProp: defaultOpen,\n      onChange: onOpenChange,\n    });\n\n    const chainOfThoughtContext = useMemo(\n      () => ({ isOpen, setIsOpen }),\n      [isOpen, setIsOpen],\n    );\n\n    return (\n      <ChainOfThoughtContext.Provider value={chainOfThoughtContext}>\n        <div\n          className={cn(\"not-prose max-w-prose space-y-4\", className)}\n          {...props}\n        >\n          {children}\n        </div>\n      </ChainOfThoughtContext.Provider>\n    );\n  },\n);\n\nexport type ChainOfThoughtHeaderProps = ComponentProps<\n  typeof CollapsibleTrigger\n>;\n\nexport const ChainOfThoughtHeader = memo(\n  ({ className, children, ...props }: ChainOfThoughtHeaderProps) => {\n    const { isOpen, setIsOpen } = useChainOfThought();\n\n    return (\n      <Collapsible onOpenChange={setIsOpen} open={isOpen}>\n        <CollapsibleTrigger\n          className={cn(\n            \"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground\",\n            className,\n          )}\n          {...props}\n        >\n          <BrainIcon className=\"size-4\" />\n          <span className=\"flex-1 text-left\">\n            {children ?? \"Chain of Thought\"}\n          </span>\n          <ChevronDownIcon\n            className={cn(\n              \"size-4 transition-transform\",\n              isOpen ? \"rotate-180\" : \"rotate-0\",\n            )}\n          />\n        </CollapsibleTrigger>\n      </Collapsible>\n    );\n  },\n);\n\nexport type ChainOfThoughtStepProps = ComponentProps<\"div\"> & {\n  icon?: LucideIcon;\n  label: ReactNode;\n  description?: ReactNode;\n  status?: \"complete\" | \"active\" | \"pending\";\n};\n\nexport const ChainOfThoughtStep = memo(\n  ({\n    className,\n    icon: Icon = DotIcon,\n    label,\n    description,\n    status = \"complete\",\n    children,\n    ...props\n  }: ChainOfThoughtStepProps) => {\n    const statusStyles = {\n      complete: \"text-muted-foreground\",\n      active: \"text-foreground\",\n      pending: \"text-muted-foreground/50\",\n    };\n\n    return (\n      <div\n        className={cn(\n          \"flex gap-2 text-sm group/step\",\n          statusStyles[status],\n          \"fade-in-0 slide-in-from-top-2 animate-in\",\n          className,\n        )}\n        {...props}\n      >\n        <div className=\"relative mt-0.5\">\n          <Icon className=\"size-4\" />\n          {/* Timeline connector — hidden on the last step */}\n          <div className=\"-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border group-last/step:hidden\" />\n        </div>\n        <div className=\"flex-1 space-y-2\">\n          <div>{label}</div>\n          {description && (\n            <div className=\"text-muted-foreground text-xs\">{description}</div>\n          )}\n          {children}\n        </div>\n      </div>\n    );\n  },\n);\n\nexport type ChainOfThoughtSearchResultsProps = ComponentProps<\"div\">;\n\nexport const ChainOfThoughtSearchResults = memo(\n  ({ className, ...props }: ChainOfThoughtSearchResultsProps) => (\n    <div className={cn(\"flex items-center gap-2\", className)} {...props} />\n  ),\n);\n\nexport type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;\n\nexport const ChainOfThoughtSearchResult = memo(\n  ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (\n    <Badge\n      className={cn(\"gap-1 px-2 py-0.5 font-normal text-xs\", className)}\n      variant=\"secondary\"\n      {...props}\n    >\n      {children}\n    </Badge>\n  ),\n);\n\nexport type ChainOfThoughtContentProps = ComponentProps<\n  typeof CollapsibleContent\n>;\n\nexport const ChainOfThoughtContent = memo(\n  ({ className, children, ...props }: ChainOfThoughtContentProps) => {\n    const { isOpen } = useChainOfThought();\n\n    return (\n      <Collapsible open={isOpen}>\n        <CollapsibleContent\n          className={cn(\n            \"mt-2 space-y-3\",\n            \"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in\",\n            className,\n          )}\n          {...props}\n        >\n          {children}\n        </CollapsibleContent>\n      </Collapsible>\n    );\n  },\n);\n\nexport type ChainOfThoughtImageProps = ComponentProps<\"div\"> & {\n  caption?: string;\n};\n\nexport const ChainOfThoughtImage = memo(\n  ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (\n    <div className={cn(\"mt-2 space-y-2\", className)} {...props}>\n      <div className=\"relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3\">\n        {children}\n      </div>\n      {caption && <p className=\"text-muted-foreground text-xs\">{caption}</p>}\n    </div>\n  ),\n);\n\nChainOfThought.displayName = \"ChainOfThought\";\nChainOfThoughtHeader.displayName = \"ChainOfThoughtHeader\";\nChainOfThoughtStep.displayName = \"ChainOfThoughtStep\";\nChainOfThoughtSearchResults.displayName = \"ChainOfThoughtSearchResults\";\nChainOfThoughtSearchResult.displayName = \"ChainOfThoughtSearchResult\";\nChainOfThoughtContent.displayName = \"ChainOfThoughtContent\";\nChainOfThoughtImage.displayName = \"ChainOfThoughtImage\";\n"
  },
  {
    "path": "web/src/components/ai-elements/code-block.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  CheckIcon,\n  ChevronDownIcon,\n  CopyIcon,\n  DownloadIcon,\n  ExternalLinkIcon,\n  Maximize2Icon,\n  Minimize2Icon,\n} from \"lucide-react\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport {\n  type ComponentProps,\n  createContext,\n  type HTMLAttributes,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type { BundledLanguage, ShikiTransformer } from \"shiki\";\n\ntype CodeBlockProps = HTMLAttributes<HTMLDivElement> & {\n  code: string;\n  language?: string;\n  showLineNumbers?: boolean;\n};\n\ntype CodeBlockContextType = {\n  code: string;\n};\n\nconst CodeBlockContext = createContext<CodeBlockContextType>({\n  code: \"\",\n});\n\n// Detect and strip prefixed line numbers coming from tools like ReadFile (cat -n style)\nconst LINE_NO_PATTERNS: RegExp[] = [\n  /^\\s{0,6}(\\d+)\\t/, // e.g. \"     4\\timport ...\"\n  /^\\s{0,6}(\\d+)\\s{2,}/, // e.g. \"     4    import ...\"\n  /^\\s*(\\d+):\\s/, // e.g. \"12: import ...\"\n];\n\nconst HIGHLIGHT_CACHE_LIMIT = 50;\nconst DEFAULT_DOWNLOAD_EXTENSION = \"txt\";\nconst DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = {\n  bash: \"sh\",\n  sh: \"sh\",\n  shell: \"sh\",\n  zsh: \"sh\",\n  fish: \"fish\",\n  javascript: \"js\",\n  js: \"js\",\n  jsx: \"jsx\",\n  typescript: \"ts\",\n  ts: \"ts\",\n  tsx: \"tsx\",\n  json: \"json\",\n  yaml: \"yaml\",\n  yml: \"yml\",\n  markdown: \"md\",\n  md: \"md\",\n  python: \"py\",\n  py: \"py\",\n  go: \"go\",\n  rust: \"rs\",\n  java: \"java\",\n  c: \"c\",\n  cpp: \"cpp\",\n  csharp: \"cs\",\n  html: \"html\",\n  css: \"css\",\n  sql: \"sql\",\n};\n\ntype HighlightCacheEntry = {\n  light: string;\n  dark: string;\n};\n\ntype ShikiModule = typeof import(\"shiki\");\n\nlet shikiModulePromise: Promise<ShikiModule> | null = null;\n\nconst loadShikiModule = async (): Promise<ShikiModule> => {\n  if (!shikiModulePromise) {\n    shikiModulePromise = import(\"shiki\");\n  }\n  return shikiModulePromise;\n};\n\nconst isBundledLanguage = (\n  languages: Record<string, unknown>,\n  language: string,\n): language is BundledLanguage =>\n  Object.prototype.hasOwnProperty.call(languages, language);\n\n// Cache avoids async highlight reflows that can transiently measure as 0 height.\nconst highlightCache = new Map<string, HighlightCacheEntry>();\n\nfunction getHighlightCacheKey(\n  code: string,\n  language: string,\n  showLineNumbers: boolean,\n  lineNumbers?: number[],\n): string {\n  const lineKey = lineNumbers\n    ? `${lineNumbers[0] ?? 0}:${lineNumbers.length}`\n    : \"none\";\n  return `${language}|${showLineNumbers ? \"lines\" : \"plain\"}|${lineKey}|${code}`;\n}\n\nfunction getHighlightCache(key: string): HighlightCacheEntry | undefined {\n  const entry = highlightCache.get(key);\n  if (!entry) {\n    return undefined;\n  }\n  highlightCache.delete(key);\n  highlightCache.set(key, entry);\n  return entry;\n}\n\nfunction setHighlightCache(key: string, entry: HighlightCacheEntry) {\n  highlightCache.set(key, entry);\n  if (highlightCache.size <= HIGHLIGHT_CACHE_LIMIT) {\n    return;\n  }\n  const oldestKey = highlightCache.keys().next().value;\n  if (oldestKey !== undefined) {\n    highlightCache.delete(oldestKey);\n  }\n}\n\nfunction getDownloadExtension(language?: string): string {\n  if (!language) {\n    return DEFAULT_DOWNLOAD_EXTENSION;\n  }\n  const normalized = language.toLowerCase();\n  const mapped = DOWNLOAD_EXTENSION_BY_LANGUAGE[normalized];\n  if (mapped) {\n    return mapped;\n  }\n  const sanitized = normalized.replace(/[^a-z0-9]+/g, \"\");\n  return sanitized.length > 0 ? sanitized : DEFAULT_DOWNLOAD_EXTENSION;\n}\n\nfunction getDownloadFilename(language?: string): string {\n  return `code.${getDownloadExtension(language)}`;\n}\n\nfunction sanitizeCodeForLineNumbers(raw: string): {\n  code: string;\n  hadLineNumbers: boolean;\n  numbers?: number[];\n} {\n  const text = typeof raw === \"string\" ? raw : String(raw ?? \"\");\n  const lines = text.replace(/\\r\\n/g, \"\\n\").split(\"\\n\");\n  const nonEmpty = lines\n    .map((l, i) => ({ l, i }))\n    .filter(({ l }) => l.length > 0);\n  if (nonEmpty.length < 3) return { code: text, hadLineNumbers: false };\n\n  // Score each pattern by how many lines it matches\n  const scores = LINE_NO_PATTERNS.map((re) =>\n    nonEmpty.reduce((acc, { l }) => (re.test(l) ? acc + 1 : acc), 0),\n  );\n  const bestIdx = scores.indexOf(Math.max(...scores));\n  const bestScore = scores[bestIdx] ?? 0;\n  const ratio = bestScore / nonEmpty.length;\n  if (bestScore < 3 || ratio < 0.6)\n    return { code: text, hadLineNumbers: false };\n\n  const re = LINE_NO_PATTERNS[bestIdx]!;\n\n  // Find the first matched line to infer the base number\n  let firstIdx = -1;\n  let firstNum = 1;\n  for (let i = 0; i < lines.length; i++) {\n    const m = lines[i]?.match(re);\n    if (m) {\n      firstIdx = i;\n      firstNum = Number.parseInt(m[1]!, 10) || 1;\n      break;\n    }\n  }\n  const numbers: number[] = new Array(lines.length)\n    .fill(0)\n    .map((_, i) => (firstIdx >= 0 ? firstNum + (i - firstIdx) : i + 1));\n\n  const stripped = lines.map((l) => l.replace(re, \"\")).join(\"\\n\");\n  return { code: stripped, hadLineNumbers: true, numbers };\n}\n\nfunction makeLineNumberTransformer(numbers?: number[]): ShikiTransformer {\n  return {\n    name: \"line-numbers\",\n    line(node, line) {\n      const display =\n        Array.isArray(numbers) && numbers[line - 1] != null\n          ? numbers[line - 1]\n          : line;\n      node.children.unshift({\n        type: \"element\",\n        tagName: \"span\",\n        properties: {\n          className: [\n            \"inline-block\",\n            \"min-w-10\",\n            \"mr-4\",\n            \"text-right\",\n            \"select-none\",\n            \"text-muted-foreground\",\n          ],\n        },\n        children: [{ type: \"text\", value: String(display) }],\n      });\n    },\n  };\n}\n\nexport async function highlightCode(\n  code: string,\n  language: string,\n  showLineNumbers = false,\n  lineNumbers?: number[],\n): Promise<HighlightCacheEntry | null> {\n  const { bundledLanguages, codeToHtml } = await loadShikiModule();\n  if (!isBundledLanguage(bundledLanguages, language)) {\n    return null;\n  }\n\n  const transformers: ShikiTransformer[] =\n    showLineNumbers || (lineNumbers && lineNumbers.length > 0)\n      ? [makeLineNumberTransformer(lineNumbers)]\n      : [];\n\n  const [light, dark] = await Promise.all([\n    codeToHtml(code, {\n      lang: language,\n      theme: \"one-light\",\n      transformers,\n    }),\n    codeToHtml(code, {\n      lang: language,\n      theme: \"one-dark-pro\",\n      transformers,\n    }),\n  ]);\n\n  return { light, dark };\n}\n\nconst COLLAPSE_THRESHOLD = 300;\n\nexport const CodeBlock = ({\n  code,\n  language,\n  showLineNumbers = false,\n  className,\n  children,\n  ...props\n}: CodeBlockProps) => {\n  const [html, setHtml] = useState<string>(\"\");\n  const [darkHtml, setDarkHtml] = useState<string>(\"\");\n  const [isOverflowing, setIsOverflowing] = useState(false);\n  const [isTall, setIsTall] = useState(false);\n  const [isExpanded, setIsExpanded] = useState(false);\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n\n  const checkOverflow = useCallback(() => {\n    const el = scrollContainerRef.current;\n    if (el) {\n      setIsTall(el.scrollHeight > COLLAPSE_THRESHOLD);\n      setIsOverflowing(el.scrollHeight > el.clientHeight);\n    }\n  }, []);\n\n  // Use useLayoutEffect for the initial measurement to prevent flash\n  // (measure before paint so tall blocks start collapsed).\n  // ResizeObserver handles subsequent size changes.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: html/darkHtml are intentional triggers to re-measure when highlighted content loads\n  useLayoutEffect(() => {\n    checkOverflow();\n  }, [checkOverflow, html, darkHtml]);\n\n  useEffect(() => {\n    const el = scrollContainerRef.current;\n    if (!el) return;\n\n    const observer = new ResizeObserver(checkOverflow);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [checkOverflow]);\n  const {\n    code: sanitizedCode,\n    hadLineNumbers,\n    numbers,\n  } = useMemo(() => sanitizeCodeForLineNumbers(code ?? \"\"), [code]);\n  const copyText = sanitizedCode;\n  const wantLineNumbers = showLineNumbers || hadLineNumbers;\n  const cacheKey = useMemo(() => {\n    if (!language) {\n      return null;\n    }\n    return getHighlightCacheKey(\n      sanitizedCode,\n      language,\n      wantLineNumbers,\n      numbers,\n    );\n  }, [sanitizedCode, language, wantLineNumbers, numbers]);\n\n  useEffect(() => {\n    let cancelled = false;\n    setHtml(\"\");\n    setDarkHtml(\"\");\n    if (!language || !cacheKey) {\n      return () => {\n        cancelled = true;\n      };\n    }\n    const cached = getHighlightCache(cacheKey);\n    if (cached) {\n      setHtml(cached.light);\n      setDarkHtml(cached.dark);\n      return () => {\n        cancelled = true;\n      };\n    }\n    highlightCode(sanitizedCode, language, wantLineNumbers, numbers).then(\n      (highlighted) => {\n        if (cancelled || !highlighted) {\n          return;\n        }\n        setHighlightCache(cacheKey, highlighted);\n        setHtml(highlighted.light);\n        setDarkHtml(highlighted.dark);\n      },\n    );\n\n    return () => {\n      cancelled = true;\n    };\n  }, [cacheKey, language, numbers, sanitizedCode, wantLineNumbers]);\n\n  // Keep fallback layout close to highlighted output to minimize height deltas.\n  const contentClassName = [\n    \"[&>pre]:m-0\",\n    \"[&>pre]:whitespace-pre\",\n    \"[&>pre]:bg-card!\",\n    \"[&>pre]:p-3\",\n    \"[&>pre]:text-foreground!\",\n    \"[&>pre]:text-xs\",\n    \"[&_code]:font-mono\",\n    \"[&_code]:text-xs\",\n  ].join(\" \");\n\n  return (\n    <CodeBlockContext.Provider value={{ code: copyText }}>\n      <div\n        className={cn(\n          \"group relative w-full rounded border border-term-border bg-card text-foreground\",\n          className,\n        )}\n        {...props}\n      >\n        {/* Icons fixed at the top right, do not scroll with content */}\n        <div className=\"hover-reveal absolute top-1.5 right-1.5 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\n          {isTall && (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  className=\"shrink-0\"\n                  onClick={() => setIsExpanded(!isExpanded)}\n                  size=\"icon-xs\"\n                  variant=\"ghost\"\n                >\n                  {isExpanded ? <Minimize2Icon className=\"size-3.5\" /> : <Maximize2Icon className=\"size-3.5\" />}\n                </Button>\n              </TooltipTrigger>\n              <TooltipContent className=\"px-1.5 py-0.5\">\n                <p className=\"text-[12px]\">{isExpanded ? \"Collapse\" : \"Expand\"}</p>\n              </TooltipContent>\n            </Tooltip>\n          )}\n          {language === \"html\" && (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <CodeBlockPreviewButton />\n              </TooltipTrigger>\n              <TooltipContent className=\"px-1.5 py-0.5\">\n                <p className=\"text-[12px]\">Preview</p>\n              </TooltipContent>\n            </Tooltip>\n          )}\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <CodeBlockDownloadButton language={language} />\n            </TooltipTrigger>\n            <TooltipContent className=\"px-1.5 py-0.5\">\n              <p className=\"text-[12px]\">Download</p>\n            </TooltipContent>\n          </Tooltip>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <CodeBlockCopyButton />\n            </TooltipTrigger>\n            <TooltipContent className=\"px-1.5 py-0.5\">\n              <p className=\"text-[12px]\">Copy</p>\n            </TooltipContent>\n          </Tooltip>\n          {children}\n        </div>\n\n        {/* Scrolling container: only contain overscroll when content overflows */}\n        <div className=\"relative\">\n          <div\n            ref={scrollContainerRef}\n            className={cn(\n              \"overflow-auto\",\n              isTall && !isExpanded\n                ? \"max-h-[200px] overflow-hidden\"\n                : \"max-h-[60vh]\",\n              isOverflowing && isExpanded && \"overscroll-contain\",\n            )}\n          >\n            <div className=\"relative\">\n              {html ? (\n                <div\n                  className={cn(\"dark:hidden\", contentClassName)}\n                  // biome-ignore lint/security/noDangerouslySetInnerHtml: \"this is needed.\"\n                  dangerouslySetInnerHTML={{ __html: html }}\n                />\n              ) : (\n                <div className={cn(\"dark:hidden\", contentClassName)}>\n                  <pre>\n                    <code>{copyText}</code>\n                  </pre>\n                </div>\n              )}\n              {darkHtml ? (\n                <div\n                  className={cn(\"hidden dark:block\", contentClassName)}\n                  // biome-ignore lint/security/noDangerouslySetInnerHtml: \"this is needed.\"\n                  dangerouslySetInnerHTML={{ __html: darkHtml }}\n                />\n              ) : (\n                <div className={cn(\"hidden dark:block\", contentClassName)}>\n                  <pre>\n                    <code>{copyText}</code>\n                  </pre>\n                </div>\n              )}\n            </div>\n          </div>\n          {/* Gradient fade when collapsed */}\n          {isTall && !isExpanded && (\n            <div className=\"absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card to-transparent pointer-events-none\" />\n          )}\n        </div>\n        {/* Expand/collapse toggle */}\n        {isTall && (\n          <button\n            type=\"button\"\n            className=\"flex w-full items-center justify-center gap-1 border-t border-term-border py-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground hover:bg-muted/30 transition-colors\"\n            onClick={() => setIsExpanded(!isExpanded)}\n          >\n            <ChevronDownIcon\n              className={cn(\n                \"size-3 transition-transform duration-200\",\n                isExpanded && \"rotate-180\",\n              )}\n            />\n            {isExpanded ? \"Show less\" : \"Show more\"}\n          </button>\n        )}\n      </div>\n    </CodeBlockContext.Provider>\n  );\n};\n\nexport type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {\n  onCopy?: () => void;\n  onError?: (error: Error) => void;\n  timeout?: number;\n};\n\nexport const CodeBlockCopyButton = ({\n  ref,\n  onCopy,\n  onError,\n  timeout = 2000,\n  children,\n  className,\n  ...props\n}: CodeBlockCopyButtonProps) => {\n  const [isCopied, setIsCopied] = useState(false);\n  const { code } = useContext(CodeBlockContext);\n\n  const copyToClipboard = async () => {\n    if (typeof window === \"undefined\" || !navigator?.clipboard?.writeText) {\n      onError?.(new Error(\"Clipboard API not available\"));\n      return;\n    }\n\n    try {\n      await navigator.clipboard.writeText(code);\n      setIsCopied(true);\n      onCopy?.();\n      setTimeout(() => setIsCopied(false), timeout);\n    } catch (error) {\n      onError?.(error as Error);\n    }\n  };\n\n  const Icon = isCopied ? CheckIcon : CopyIcon;\n\n  return (\n    <Button\n      ref={ref}\n      className={cn(\"shrink-0\", className)}\n      onClick={copyToClipboard}\n      size=\"icon-xs\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <Icon className=\"size-3.5\" />}\n    </Button>\n  );\n};\n\nexport type CodeBlockDownloadButtonProps = ComponentProps<typeof Button> & {\n  language?: string;\n  filename?: string;\n  mimeType?: string;\n  onDownload?: (filename: string) => void;\n  onError?: (error: Error) => void;\n};\n\nexport const CodeBlockDownloadButton = ({\n  ref,\n  language,\n  filename,\n  mimeType = \"text/plain\",\n  onDownload,\n  onError,\n  children,\n  className,\n  ...props\n}: CodeBlockDownloadButtonProps) => {\n  const { code } = useContext(CodeBlockContext);\n  const resolvedFilename = filename ?? getDownloadFilename(language);\n\n  const handleDownload = () => {\n    if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n      onError?.(new Error(\"Download is not available\"));\n      return;\n    }\n\n    try {\n      const blob = new Blob([code], { type: mimeType });\n      const url = URL.createObjectURL(blob);\n      const anchor = document.createElement(\"a\");\n      anchor.href = url;\n      anchor.download = resolvedFilename;\n      document.body.appendChild(anchor);\n      anchor.click();\n      document.body.removeChild(anchor);\n      setTimeout(() => URL.revokeObjectURL(url), 0);\n      onDownload?.(resolvedFilename);\n    } catch (error) {\n      const err =\n        error instanceof Error ? error : new Error(\"Failed to download code\");\n      onError?.(err);\n    }\n  };\n\n  return (\n    <Button\n      ref={ref}\n      className={cn(\"shrink-0\", className)}\n      onClick={handleDownload}\n      size=\"icon-xs\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <DownloadIcon className=\"size-3.5\" />}\n    </Button>\n  );\n};\n\nexport type CodeBlockPreviewButtonProps = ComponentProps<typeof Button> & {\n  onPreview?: () => void;\n  onError?: (error: Error) => void;\n};\n\nexport const CodeBlockPreviewButton = ({\n  ref,\n  onPreview,\n  onError,\n  children,\n  className,\n  ...props\n}: CodeBlockPreviewButtonProps) => {\n  const { code } = useContext(CodeBlockContext);\n\n  const handlePreview = () => {\n    if (typeof window === \"undefined\") {\n      onError?.(new Error(\"Preview is not available\"));\n      return;\n    }\n\n    try {\n      const blob = new Blob([code], { type: \"text/html\" });\n      const url = URL.createObjectURL(blob);\n      window.open(url, \"_blank\");\n      setTimeout(() => URL.revokeObjectURL(url), 5000);\n      onPreview?.();\n    } catch (error) {\n      const err =\n        error instanceof Error ? error : new Error(\"Failed to preview\");\n      onError?.(err);\n    }\n  };\n\n  return (\n    <Button\n      ref={ref}\n      className={cn(\"shrink-0\", className)}\n      onClick={handlePreview}\n      size=\"icon-xs\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <ExternalLinkIcon className=\"size-3.5\" />}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "web/src/components/ai-elements/confirmation.tsx",
    "content": "\"use client\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { ToolState } from \"./tool\";\nimport {\n  type ComponentProps,\n  createContext,\n  type ReactNode,\n  useContext,\n} from \"react\";\n\n/** Approval info with required id, optional approved/reason, and extra properties allowed */\ntype ToolUIPartApproval =\n  | {\n      id: string;\n      approved?: boolean;\n      reason?: string;\n      [key: string]: unknown;\n    }\n  | undefined;\n\ntype ConfirmationContextValue = {\n  approval: ToolUIPartApproval;\n  state: ToolState;\n};\n\nconst ConfirmationContext = createContext<ConfirmationContextValue | null>(\n  null,\n);\n\nconst useConfirmation = () => {\n  const context = useContext(ConfirmationContext);\n\n  if (!context) {\n    throw new Error(\"Confirmation components must be used within Confirmation\");\n  }\n\n  return context;\n};\n\nexport type ConfirmationProps = ComponentProps<typeof Alert> & {\n  approval?: ToolUIPartApproval;\n  state: ToolState;\n};\n\nexport const Confirmation = ({\n  className,\n  approval,\n  state,\n  ...props\n}: ConfirmationProps) => {\n  if (!approval || state === \"input-streaming\" || state === \"input-available\") {\n    return null;\n  }\n\n  return (\n    <ConfirmationContext.Provider value={{ approval, state }}>\n      <Alert className={cn(\"flex flex-col gap-2\", className)} {...props} />\n    </ConfirmationContext.Provider>\n  );\n};\n\nexport type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;\n\nexport const ConfirmationTitle = ({\n  className,\n  ...props\n}: ConfirmationTitleProps) => (\n  <AlertDescription className={cn(\"inline\", className)} {...props} />\n);\n\nexport type ConfirmationRequestProps = {\n  children?: ReactNode;\n};\n\nexport const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {\n  const { state } = useConfirmation();\n\n  // Only show when approval is requested\n  if (state !== \"approval-requested\") {\n    return null;\n  }\n\n  return children;\n};\n\nexport type ConfirmationAcceptedProps = {\n  children?: ReactNode;\n};\n\nexport const ConfirmationAccepted = ({\n  children,\n}: ConfirmationAcceptedProps) => {\n  const { approval, state } = useConfirmation();\n\n  // Only show when approved and in response states\n  if (\n    !approval?.approved ||\n    (state !== \"approval-responded\" &&\n      state !== \"output-denied\" &&\n      state !== \"output-available\")\n  ) {\n    return null;\n  }\n\n  return children;\n};\n\nexport type ConfirmationRejectedProps = {\n  children?: ReactNode;\n};\n\nexport const ConfirmationRejected = ({\n  children,\n}: ConfirmationRejectedProps) => {\n  const { approval, state } = useConfirmation();\n\n  // Only show when rejected and in response states\n  if (\n    approval?.approved !== false ||\n    (state !== \"approval-responded\" &&\n      state !== \"output-denied\" &&\n      state !== \"output-available\")\n  ) {\n    return null;\n  }\n\n  return children;\n};\n\nexport type ConfirmationActionsProps = ComponentProps<\"div\">;\n\nexport const ConfirmationActions = ({\n  className,\n  ...props\n}: ConfirmationActionsProps) => {\n  const { state } = useConfirmation();\n\n  // Only show when approval is requested\n  if (state !== \"approval-requested\") {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\"flex items-center justify-start gap-2\", className)}\n      {...props}\n    />\n  );\n};\n\nexport type ConfirmationActionProps = ComponentProps<typeof Button>;\n\nexport const ConfirmationAction = (props: ConfirmationActionProps) => (\n  <Button className=\"h-8 px-3 text-sm\" type=\"button\" {...props} />\n);\n"
  },
  {
    "path": "web/src/components/ai-elements/context.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { cn } from \"@/lib/utils\";\nimport type { TokenUsage } from \"@/hooks/wireTypes\";\nimport type { LanguageModelUsage } from \"ai\";\nimport {\n  type ComponentProps,\n  createContext,\n  useContext,\n  useState,\n  type ReactNode,\n  type MouseEvent,\n} from \"react\";\nimport { getUsage } from \"tokenlens\";\n\nconst PERCENT_MAX = 100;\nconst ICON_RADIUS = 10;\nconst ICON_VIEWBOX = 24;\nconst ICON_CENTER = 12;\nconst ICON_STROKE_WIDTH = 2;\n\ntype ModelId = string;\n\ntype ContextSchema = {\n  usedTokens: number;\n  maxTokens: number;\n  usage?: LanguageModelUsage;\n  modelId?: ModelId;\n  tokenUsage?: TokenUsage | null;\n};\n\ntype ContextValue = ContextSchema & {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n};\n\nconst ContextContext = createContext<ContextValue | null>(null);\n\nconst useContextValue = () => {\n  const context = useContext(ContextContext);\n\n  if (!context) {\n    throw new Error(\"Context components must be used within Context\");\n  }\n\n  return context;\n};\n\nexport type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;\n\nexport const Context = ({\n  usedTokens,\n  maxTokens,\n  usage,\n  modelId,\n  tokenUsage,\n  open: _openProp,\n  onOpenChange: _onOpenChange,\n  ...props\n}: ContextProps) => {\n  const [open, setOpen] = useState(false);\n\n  return (\n    <ContextContext.Provider\n      value={{\n        usedTokens,\n        maxTokens,\n        usage,\n        modelId,\n        tokenUsage,\n        open,\n        setOpen,\n      }}\n    >\n      <HoverCard\n        closeDelay={150}\n        openDelay={0}\n        open={open}\n        onOpenChange={setOpen}\n        {...props}\n      />\n    </ContextContext.Provider>\n  );\n};\n\nexport const ContextProgressIcon = ({\n  usedPercent,\n  size = 20,\n}: {\n  usedPercent: number;\n  size?: number;\n}) => {\n  // Normalize to [0, 1] range and handle NaN/Infinity\n  const normalizedPercent = Number.isFinite(usedPercent)\n    ? Math.max(0, Math.min(1, usedPercent))\n    : 0;\n\n  const circumference = 2 * Math.PI * ICON_RADIUS;\n  const dashOffset = circumference * (1 - normalizedPercent);\n\n  return (\n    <svg\n      aria-label=\"Model context usage\"\n      height={size}\n      role=\"img\"\n      style={{ color: \"currentcolor\" }}\n      viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}\n      width={size}\n    >\n      <circle\n        cx={ICON_CENTER}\n        cy={ICON_CENTER}\n        fill=\"none\"\n        opacity=\"0.25\"\n        r={ICON_RADIUS}\n        stroke=\"currentColor\"\n        strokeWidth={ICON_STROKE_WIDTH}\n      />\n      <circle\n        cx={ICON_CENTER}\n        cy={ICON_CENTER}\n        fill=\"none\"\n        opacity=\"0.7\"\n        r={ICON_RADIUS}\n        stroke=\"currentColor\"\n        strokeDasharray={`${circumference} ${circumference}`}\n        strokeDashoffset={dashOffset}\n        strokeLinecap=\"round\"\n        strokeWidth={ICON_STROKE_WIDTH}\n        style={{ transformOrigin: \"center\", transform: \"rotate(-90deg)\" }}\n      />\n    </svg>\n  );\n};\n\nconst ContextIcon = () => {\n  const { usedTokens, maxTokens } = useContextValue();\n  const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0;\n  return <ContextProgressIcon usedPercent={usedPercent} />;\n};\n\nexport type ContextTriggerProps = {\n  children?: ReactNode;\n  className?: string;\n  onClick?: (event: MouseEvent<HTMLButtonElement>) => void;\n};\n\nexport const ContextTrigger = ({\n  children,\n  className,\n  onClick,\n}: ContextTriggerProps) => {\n  const { usedTokens, maxTokens, open, setOpen } = useContextValue();\n  const usedPercent = usedTokens / maxTokens;\n  const renderedPercent = new Intl.NumberFormat(\"en-US\", {\n    style: \"percent\",\n    maximumFractionDigits: 1,\n  }).format(usedPercent);\n\n  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {\n    onClick?.(event);\n    setOpen(!open);\n  };\n\n  if (children) {\n    return (\n      <HoverCardTrigger asChild>\n        <button\n          type=\"button\"\n          onClick={handleClick}\n          className={cn(\n            \"inline-flex items-center gap-1.5 bg-transparent p-0 text-left appearance-none\",\n            className,\n          )}\n        >\n          {children}\n        </button>\n      </HoverCardTrigger>\n    );\n  }\n\n  return (\n    <HoverCardTrigger asChild>\n      <Button type=\"button\" variant=\"ghost\" className={className} onClick={handleClick}>\n        <span className=\"font-medium text-muted-foreground\">\n          {renderedPercent}\n        </span>\n        <ContextIcon />\n      </Button>\n    </HoverCardTrigger>\n  );\n};\n\nexport type ContextContentProps = ComponentProps<typeof HoverCardContent>;\n\nexport const ContextContent = ({\n  className,\n  ...props\n}: ContextContentProps) => (\n  <HoverCardContent\n    className={cn(\"min-w-60 divide-y overflow-hidden p-0\", className)}\n    {...props}\n  />\n);\n\nexport type ContextContentHeaderProps = ComponentProps<\"div\">;\n\nexport const ContextContentHeader = ({\n  children,\n  className,\n  ...props\n}: ContextContentHeaderProps) => {\n  const { usedTokens, maxTokens } = useContextValue();\n  const usedPercent = usedTokens / maxTokens;\n  const displayPct = new Intl.NumberFormat(\"en-US\", {\n    style: \"percent\",\n    maximumFractionDigits: 1,\n  }).format(usedPercent);\n  const used = new Intl.NumberFormat(\"en-US\", {\n    notation: \"compact\",\n  }).format(usedTokens);\n  const total = new Intl.NumberFormat(\"en-US\", {\n    notation: \"compact\",\n  }).format(maxTokens);\n\n  return (\n    <div className={cn(\"w-full space-y-2 p-3\", className)} {...props}>\n      {children ?? (\n        <>\n          <div className=\"flex items-center justify-between gap-3 text-xs\">\n            <p>{displayPct}</p>\n            <p className=\"font-mono text-muted-foreground\">\n              {used} / {total}\n            </p>\n          </div>\n          <div className=\"space-y-2\">\n            <Progress className=\"bg-muted\" value={usedPercent * PERCENT_MAX} />\n          </div>\n        </>\n      )}\n    </div>\n  );\n};\n\nexport type ContextContentBodyProps = ComponentProps<\"div\">;\n\nexport const ContextContentBody = ({\n  children,\n  className,\n  ...props\n}: ContextContentBodyProps) => (\n  <div className={cn(\"w-full p-3\", className)} {...props}>\n    {children}\n  </div>\n);\n\nexport type ContextContentFooterProps = ComponentProps<\"div\">;\n\nexport const ContextContentFooter = ({\n  children,\n  className,\n  ...props\n}: ContextContentFooterProps) => {\n  const { modelId, usage } = useContextValue();\n  const costUSD = modelId\n    ? getUsage({\n        modelId,\n        usage: {\n          input: usage?.inputTokens ?? 0,\n          output: usage?.outputTokens ?? 0,\n        },\n      }).costUSD?.totalUSD\n    : undefined;\n  const totalCost = new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n  }).format(costUSD ?? 0);\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs\",\n        className,\n      )}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <span className=\"text-muted-foreground\">Total cost</span>\n          <span>{totalCost}</span>\n        </>\n      )}\n    </div>\n  );\n};\n\nexport type ContextInputUsageProps = ComponentProps<\"div\">;\n\nexport const ContextInputUsage = ({\n  className,\n  children,\n  ...props\n}: ContextInputUsageProps) => {\n  const { usage, modelId } = useContextValue();\n  const inputTokens = usage?.inputTokens ?? 0;\n\n  if (children) {\n    return children;\n  }\n\n  if (!inputTokens) {\n    return null;\n  }\n\n  const inputCost = modelId\n    ? getUsage({\n        modelId,\n        usage: { input: inputTokens, output: 0 },\n      }).costUSD?.totalUSD\n    : undefined;\n  const inputCostText = new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n  }).format(inputCost ?? 0);\n\n  return (\n    <div\n      className={cn(\"flex items-center justify-between text-xs\", className)}\n      {...props}\n    >\n      <span className=\"text-muted-foreground\">Input</span>\n      <TokensWithCost costText={inputCostText} tokens={inputTokens} />\n    </div>\n  );\n};\n\nexport type ContextOutputUsageProps = ComponentProps<\"div\">;\n\nexport const ContextOutputUsage = ({\n  className,\n  children,\n  ...props\n}: ContextOutputUsageProps) => {\n  const { usage, modelId } = useContextValue();\n  const outputTokens = usage?.outputTokens ?? 0;\n\n  if (children) {\n    return children;\n  }\n\n  if (!outputTokens) {\n    return null;\n  }\n\n  const outputCost = modelId\n    ? getUsage({\n        modelId,\n        usage: { input: 0, output: outputTokens },\n      }).costUSD?.totalUSD\n    : undefined;\n  const outputCostText = new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n  }).format(outputCost ?? 0);\n\n  return (\n    <div\n      className={cn(\"flex items-center justify-between text-xs\", className)}\n      {...props}\n    >\n      <span className=\"text-muted-foreground\">Output</span>\n      <TokensWithCost costText={outputCostText} tokens={outputTokens} />\n    </div>\n  );\n};\n\nexport type ContextReasoningUsageProps = ComponentProps<\"div\">;\n\nexport const ContextReasoningUsage = ({\n  className,\n  children,\n  ...props\n}: ContextReasoningUsageProps) => {\n  const { usage, modelId } = useContextValue();\n  const reasoningTokens = usage?.reasoningTokens ?? 0;\n\n  if (children) {\n    return children;\n  }\n\n  if (!reasoningTokens) {\n    return null;\n  }\n\n  const reasoningCost = modelId\n    ? getUsage({\n        modelId,\n        usage: { reasoningTokens },\n      }).costUSD?.totalUSD\n    : undefined;\n  const reasoningCostText = new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n  }).format(reasoningCost ?? 0);\n\n  return (\n    <div\n      className={cn(\"flex items-center justify-between text-xs\", className)}\n      {...props}\n    >\n      <span className=\"text-muted-foreground\">Reasoning</span>\n      <TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />\n    </div>\n  );\n};\n\nexport type ContextCacheUsageProps = ComponentProps<\"div\">;\n\nexport const ContextCacheUsage = ({\n  className,\n  children,\n  ...props\n}: ContextCacheUsageProps) => {\n  const { usage, modelId } = useContextValue();\n  const cacheTokens = usage?.cachedInputTokens ?? 0;\n\n  if (children) {\n    return children;\n  }\n\n  if (!cacheTokens) {\n    return null;\n  }\n\n  const cacheCost = modelId\n    ? getUsage({\n        modelId,\n        usage: { cacheReads: cacheTokens, input: 0, output: 0 },\n      }).costUSD?.totalUSD\n    : undefined;\n  const cacheCostText = new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n  }).format(cacheCost ?? 0);\n\n  return (\n    <div\n      className={cn(\"flex items-center justify-between text-xs\", className)}\n      {...props}\n    >\n      <span className=\"text-muted-foreground\">Cache</span>\n      <TokensWithCost costText={cacheCostText} tokens={cacheTokens} />\n    </div>\n  );\n};\n\nexport type ContextRawUsageProps = ComponentProps<\"div\">;\n\nexport const ContextRawUsage = ({\n  className,\n  children,\n  ...props\n}: ContextRawUsageProps) => {\n  const { tokenUsage } = useContextValue();\n\n  if (children) {\n    return children;\n  }\n\n  if (!tokenUsage) {\n    return null;\n  }\n\n  return (\n    <div className={cn(\"space-y-1 text-xs\", className)} {...props}>\n      <div className=\"text-[11px] font-medium text-muted-foreground\">\n        Raw token usage\n      </div>\n      <RawUsageRow label=\"Input (other)\" value={tokenUsage.input_other} />\n      {/* <RawUsageRow label=\"Cache read\" value={tokenUsage.input_cache_read} /> */}\n      {/* <RawUsageRow\n        label=\"Cache create\"\n        value={tokenUsage.input_cache_creation}\n      /> */}\n      <RawUsageRow label=\"Output\" value={tokenUsage.output} />\n    </div>\n  );\n};\n\nconst TokensWithCost = ({\n  tokens,\n  costText,\n}: {\n  tokens?: number;\n  costText?: string;\n}) => (\n  <span>\n    {tokens === undefined\n      ? \"—\"\n      : new Intl.NumberFormat(\"en-US\", {\n          notation: \"compact\",\n        }).format(tokens)}\n    {costText ? (\n      <span className=\"ml-2 text-muted-foreground\">• {costText}</span>\n    ) : null}\n  </span>\n);\n\nconst RawUsageRow = ({ label, value }: { label: string; value: number }) => (\n  <div className=\"flex items-center justify-between text-xs\">\n    <span className=\"text-muted-foreground\">{label}</span>\n    <span>\n      {new Intl.NumberFormat(\"en-US\", {\n        notation: \"compact\",\n      }).format(value)}\n    </span>\n  </div>\n);\n"
  },
  {
    "path": "web/src/components/ai-elements/conversation.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport type { ComponentProps } from \"react\";\nimport { useCallback } from \"react\";\nimport { StickToBottom, useStickToBottomContext } from \"use-stick-to-bottom\";\n\nexport type ConversationProps = ComponentProps<typeof StickToBottom>;\n\nexport const Conversation = ({ className, ...props }: ConversationProps) => (\n  <StickToBottom\n    className={cn(\"relative flex-1 overflow-y-hidden\", className)}\n    initial=\"smooth\"\n    resize=\"smooth\"\n    role=\"log\"\n    {...props}\n  />\n);\n\nexport type ConversationContentProps = ComponentProps<\n  typeof StickToBottom.Content\n>;\n\nexport const ConversationContent = ({\n  className,\n  ...props\n}: ConversationContentProps) => (\n  <StickToBottom.Content\n    className={cn(\"flex flex-col p-4\", className)}\n    {...props}\n  />\n);\n\nexport type ConversationEmptyStateProps = ComponentProps<\"div\"> & {\n  title?: string;\n  description?: string;\n  icon?: React.ReactNode;\n};\n\nexport const ConversationEmptyState = ({\n  className,\n  title = \"No messages yet\",\n  description = \"Start a conversation to see messages here\",\n  icon,\n  children,\n  ...props\n}: ConversationEmptyStateProps) => (\n  <div\n    className={cn(\n      \"flex size-full flex-col items-center justify-center gap-3 p-8 text-center\",\n      className,\n    )}\n    {...props}\n  >\n    {children ?? (\n      <>\n        {icon && <div className=\"text-muted-foreground\">{icon}</div>}\n        <div className=\"space-y-1\">\n          <h3 className=\"font-medium text-sm\">{title}</h3>\n          {description && (\n            <p className=\"text-muted-foreground text-sm\">{description}</p>\n          )}\n        </div>\n      </>\n    )}\n  </div>\n);\n\nexport type ConversationScrollButtonProps = ComponentProps<typeof Button>;\n\nexport const ConversationScrollButton = ({\n  className,\n  ...props\n}: ConversationScrollButtonProps) => {\n  const { isAtBottom, scrollToBottom } = useStickToBottomContext();\n\n  const handleScrollToBottom = useCallback(() => {\n    scrollToBottom();\n  }, [scrollToBottom]);\n\n  return (\n    !isAtBottom && (\n      <Button\n        className={cn(\n          \"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full\",\n          className,\n        )}\n        onClick={handleScrollToBottom}\n        size=\"icon\"\n        type=\"button\"\n        variant=\"outline\"\n        {...props}\n      >\n        <ArrowDownIcon className=\"size-4\" />\n      </Button>\n    )\n  );\n};\n"
  },
  {
    "path": "web/src/components/ai-elements/index.ts",
    "content": "export * from \"./chain-of-thought\";\nexport * from \"./code-block\";\nexport * from \"./confirmation\";\nexport * from \"./context\";\nexport * from \"./conversation\";\nexport * from \"./loader\";\nexport * from \"./message\";\nexport * from \"./model-selector\";\nexport * from \"./prompt-input\";\nexport * from \"./reasoning\";\nexport * from \"./shimmer\";\nexport * from \"./subagent-steps\";\nexport * from \"./tool\";\n"
  },
  {
    "path": "web/src/components/ai-elements/loader.tsx",
    "content": "import { cn } from \"@/lib/utils\";\nimport type { HTMLAttributes } from \"react\";\n\ntype LoaderIconProps = {\n  size?: number;\n};\n\nconst LoaderIcon = ({ size = 16 }: LoaderIconProps) => (\n  <svg\n    height={size}\n    strokeLinejoin=\"round\"\n    style={{ color: \"currentcolor\" }}\n    viewBox=\"0 0 16 16\"\n    width={size}\n  >\n    <title>Loader</title>\n    <g clipPath=\"url(#clip0_2393_1490)\">\n      <path d=\"M8 0V4\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path\n        d=\"M8 16V12\"\n        opacity=\"0.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M3.29773 1.52783L5.64887 4.7639\"\n        opacity=\"0.9\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M12.7023 1.52783L10.3511 4.7639\"\n        opacity=\"0.1\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M12.7023 14.472L10.3511 11.236\"\n        opacity=\"0.4\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M3.29773 14.472L5.64887 11.236\"\n        opacity=\"0.6\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M15.6085 5.52783L11.8043 6.7639\"\n        opacity=\"0.2\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M0.391602 10.472L4.19583 9.23598\"\n        opacity=\"0.7\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M15.6085 10.4722L11.8043 9.2361\"\n        opacity=\"0.3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <path\n        d=\"M0.391602 5.52783L4.19583 6.7639\"\n        opacity=\"0.8\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n    </g>\n    <defs>\n      <clipPath id=\"clip0_2393_1490\">\n        <rect fill=\"currentColor\" height=\"16\" width=\"16\" />\n      </clipPath>\n    </defs>\n  </svg>\n);\n\nexport type LoaderProps = HTMLAttributes<HTMLDivElement> & {\n  size?: number;\n};\n\nexport const Loader = ({ className, size = 16, ...props }: LoaderProps) => (\n  <div\n    className={cn(\n      \"inline-flex animate-spin items-center justify-center\",\n      className,\n    )}\n    {...props}\n  >\n    <LoaderIcon size={size} />\n  </div>\n);\n"
  },
  {
    "path": "web/src/components/ai-elements/message.tsx",
    "content": "\"use client\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { ButtonGroup, ButtonGroupText } from \"@/components/ui/button-group\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport type { UIMessage } from \"ai\";\nimport type { MessageAttachmentPart, NoPreviewAttachment, VideoNoPreviewAttachment } from \"@/hooks/types\";\nimport { useVideoThumbnail } from \"@/hooks/useVideoThumbnail\";\nimport {\n  CheckIcon,\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  CopyIcon,\n  GitBranchIcon,\n  PaperclipIcon,\n  VideoIcon,\n  XIcon,\n} from \"lucide-react\";\nimport type { ComponentProps, HTMLAttributes, ReactElement } from \"react\";\nimport { createContext, memo, useContext, useEffect, useState } from \"react\";\nimport { Streamdown } from \"streamdown\";\nimport {\n  escapeHtmlOutsideCodeBlocks,\n  safeRehypePlugins,\n  safeRemarkPlugins,\n  streamdownComponents,\n  streamdownRootClass,\n} from \"./streamdown\";\n\nexport type MessageProps = HTMLAttributes<HTMLDivElement> & {\n  from: UIMessage[\"role\"];\n};\n\nexport const Message = ({ className, from, ...props }: MessageProps) => (\n  <div\n    className={cn(\n      \"group flex w-full flex-col gap-1\",\n      from === \"user\" ? \"is-user\" : \"is-assistant\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type MessageContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const MessageContent = ({\n  children,\n  className,\n  ...props\n}: MessageContentProps) => (\n  <div\n    className={cn(\n      \"flex w-full flex-col gap-1 overflow-hidden text-sm\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n  </div>\n);\n\n/** User message content with bubble styling */\nexport type UserMessageContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const UserMessageContent = ({\n  children,\n  className,\n  ...props\n}: UserMessageContentProps) => {\n  return (\n    <div\n      className={cn(\n        \"w-full rounded-2xl bg-secondary/50 px-4 py-3 text-sm\",\n        \"dark:bg-secondary/30\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"whitespace-pre-wrap break-words\">{children}</div>\n    </div>\n  );\n};\n\nexport type MessageActionsProps = ComponentProps<\"div\">;\n\nexport const MessageActions = ({\n  className,\n  children,\n  ...props\n}: MessageActionsProps) => (\n  <div className={cn(\"flex items-center gap-1 ml-4\", className)} {...props}>\n    {children}\n  </div>\n);\n\nexport type MessageActionProps = ComponentProps<typeof Button> & {\n  tooltip?: string;\n  label?: string;\n};\n\nexport const MessageAction = ({\n  tooltip,\n  children,\n  label,\n  variant = \"ghost\",\n  size = \"icon-sm\",\n  className,\n  ...props\n}: MessageActionProps) => {\n  const button = (\n    <Button size={size} type=\"button\" variant={variant} className={cn(\"size-6\", className)} {...props}>\n      {children}\n      <span className=\"sr-only\">{label || tooltip}</span>\n    </Button>\n  );\n\n  if (tooltip) {\n    return (\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>{button}</TooltipTrigger>\n          <TooltipContent className=\"px-1.5 py-0.5\">\n            <p className=\"text-[12px]\">{tooltip}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    );\n  }\n\n  return button;\n};\n\nexport type MessageCopyButtonProps = {\n  content: string;\n  timeout?: number;\n};\n\nexport const MessageCopyButton = ({\n  content,\n  timeout = 2000,\n}: MessageCopyButtonProps) => {\n  const [isCopied, setIsCopied] = useState(false);\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(content);\n      setIsCopied(true);\n      setTimeout(() => setIsCopied(false), timeout);\n    } catch {\n      // Clipboard API not available or write failed\n    }\n  };\n\n  return (\n    <MessageAction tooltip={isCopied ? \"Copied!\" : \"Copy\"} onClick={handleCopy}>\n      {isCopied ? <CheckIcon className=\"size-3\" /> : <CopyIcon className=\"size-3\" />}\n    </MessageAction>\n  );\n};\n\nexport type MessageForkButtonProps = {\n  onFork: () => void;\n};\n\nexport const MessageForkButton = ({ onFork }: MessageForkButtonProps) => {\n  return (\n    <AlertDialog>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <AlertDialogTrigger asChild>\n              <Button size=\"icon-sm\" type=\"button\" variant=\"ghost\" className=\"size-6\">\n                <GitBranchIcon className=\"size-3\" />\n                <span className=\"sr-only\">Fork session</span>\n              </Button>\n            </AlertDialogTrigger>\n          </TooltipTrigger>\n          <TooltipContent className=\"px-1.5 py-0.5\">\n            <p className=\"text-[12px]\">Fork session from this point</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n      <AlertDialogContent size=\"sm\">\n        <AlertDialogHeader>\n          <AlertDialogTitle>Fork Session</AlertDialogTitle>\n          <AlertDialogDescription>\n            A new session will be created with the conversation history up to\n            and including this response. The current session will not be\n            affected.\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>Cancel</AlertDialogCancel>\n          <AlertDialogAction onClick={onFork}>Fork</AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n\ntype MessageBranchContextType = {\n  currentBranch: number;\n  totalBranches: number;\n  goToPrevious: () => void;\n  goToNext: () => void;\n  branches: ReactElement[];\n  setBranches: (branches: ReactElement[]) => void;\n};\n\nconst MessageBranchContext = createContext<MessageBranchContextType | null>(\n  null,\n);\n\nconst useMessageBranch = () => {\n  const context = useContext(MessageBranchContext);\n\n  if (!context) {\n    throw new Error(\n      \"MessageBranch components must be used within MessageBranch\",\n    );\n  }\n\n  return context;\n};\n\nexport type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {\n  defaultBranch?: number;\n  onBranchChange?: (branchIndex: number) => void;\n};\n\nexport const MessageBranch = ({\n  defaultBranch = 0,\n  onBranchChange,\n  className,\n  ...props\n}: MessageBranchProps) => {\n  const [currentBranch, setCurrentBranch] = useState(defaultBranch);\n  const [branches, setBranches] = useState<ReactElement[]>([]);\n\n  const handleBranchChange = (newBranch: number) => {\n    setCurrentBranch(newBranch);\n    onBranchChange?.(newBranch);\n  };\n\n  const goToPrevious = () => {\n    const newBranch =\n      currentBranch > 0 ? currentBranch - 1 : branches.length - 1;\n    handleBranchChange(newBranch);\n  };\n\n  const goToNext = () => {\n    const newBranch =\n      currentBranch < branches.length - 1 ? currentBranch + 1 : 0;\n    handleBranchChange(newBranch);\n  };\n\n  const contextValue: MessageBranchContextType = {\n    currentBranch,\n    totalBranches: branches.length,\n    goToPrevious,\n    goToNext,\n    branches,\n    setBranches,\n  };\n\n  return (\n    <MessageBranchContext.Provider value={contextValue}>\n      <div\n        className={cn(\"grid w-full gap-2 [&>div]:pb-0\", className)}\n        {...props}\n      />\n    </MessageBranchContext.Provider>\n  );\n};\n\nexport type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const MessageBranchContent = ({\n  children,\n  ...props\n}: MessageBranchContentProps) => {\n  const { currentBranch, setBranches, branches } = useMessageBranch();\n  const childrenArray = Array.isArray(children) ? children : [children];\n\n  // Use useEffect to update branches when they change\n  useEffect(() => {\n    if (branches.length !== childrenArray.length) {\n      setBranches(childrenArray);\n    }\n  }, [childrenArray, branches, setBranches]);\n\n  return childrenArray.map((branch, index) => (\n    <div\n      className={cn(\n        \"grid gap-2 overflow-hidden\",\n        index === currentBranch ? \"block\" : \"hidden\",\n      )}\n      key={branch.key}\n      {...props}\n    >\n      {branch}\n    </div>\n  ));\n};\n\nexport type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {\n  from: UIMessage[\"role\"];\n};\n\nexport const MessageBranchSelector = ({\n  className,\n  from,\n  ...props\n}: MessageBranchSelectorProps) => {\n  const { totalBranches } = useMessageBranch();\n\n  // Don't render if there's only one branch\n  if (totalBranches <= 1) {\n    return null;\n  }\n\n  return (\n    <ButtonGroup\n      className=\"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md\"\n      orientation=\"horizontal\"\n      {...props}\n    />\n  );\n};\n\nexport type MessageBranchPreviousProps = ComponentProps<typeof Button>;\n\nexport const MessageBranchPrevious = ({\n  children,\n  ...props\n}: MessageBranchPreviousProps) => {\n  const { goToPrevious, totalBranches } = useMessageBranch();\n\n  return (\n    <Button\n      aria-label=\"Previous branch\"\n      disabled={totalBranches <= 1}\n      onClick={goToPrevious}\n      size=\"icon-sm\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <ChevronLeftIcon size={14} />}\n    </Button>\n  );\n};\n\nexport type MessageBranchNextProps = ComponentProps<typeof Button>;\n\nexport const MessageBranchNext = ({\n  children,\n  className,\n  ...props\n}: MessageBranchNextProps) => {\n  const { goToNext, totalBranches } = useMessageBranch();\n\n  return (\n    <Button\n      aria-label=\"Next branch\"\n      disabled={totalBranches <= 1}\n      onClick={goToNext}\n      size=\"icon-sm\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <ChevronRightIcon size={14} />}\n    </Button>\n  );\n};\n\nexport type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;\n\nexport const MessageBranchPage = ({\n  className,\n  ...props\n}: MessageBranchPageProps) => {\n  const { currentBranch, totalBranches } = useMessageBranch();\n\n  return (\n    <ButtonGroupText\n      className={cn(\n        \"border-none bg-transparent text-muted-foreground shadow-none\",\n        className,\n      )}\n      {...props}\n    >\n      {currentBranch + 1} of {totalBranches}\n    </ButtonGroupText>\n  );\n};\n\nexport type MessageResponseProps = ComponentProps<typeof Streamdown>;\n\nexport const MessageResponse = memo(\n  ({ className, children, ...props }: MessageResponseProps) => (\n    <Streamdown\n      className={cn(\n        \"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0\",\n        streamdownRootClass,\n        className,\n      )}\n      components={streamdownComponents}\n      rehypePlugins={safeRehypePlugins}\n      remarkPlugins={safeRemarkPlugins}\n      {...props}\n    >\n      {typeof children === \"string\"\n        ? escapeHtmlOutsideCodeBlocks(children)\n        : children}\n    </Streamdown>\n  ),\n  (prevProps, nextProps) => prevProps.children === nextProps.children,\n);\n\nMessageResponse.displayName = \"MessageResponse\";\n\nexport type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {\n  data: MessageAttachmentPart;\n  className?: string;\n  onRemove?: () => void;\n};\n\nexport function MessageAttachment({\n  data,\n  className,\n  onRemove,\n  ...props\n}: MessageAttachmentProps) {\n  const [isPreviewOpen, setIsPreviewOpen] = useState(false);\n  const [textContent, setTextContent] = useState<string | null>(null);\n  const isNoPreviewAttachment = (\n    attachment: MessageAttachmentPart,\n  ): attachment is NoPreviewAttachment =>\n    \"kind\" in attachment && attachment.kind === \"nopreview\";\n  const isVideoNoPreviewAttachment = (\n    attachment: MessageAttachmentPart,\n  ): attachment is VideoNoPreviewAttachment =>\n    \"kind\" in attachment && attachment.kind === \"video-nopreview\";\n  const isNoPreview = isNoPreviewAttachment(data);\n  const isVideoNoPreview = isVideoNoPreviewAttachment(data);\n  const filename = data.filename || \"\";\n  let mediaType: string | undefined;\n  let url: string | undefined;\n  if (!isNoPreview && !isVideoNoPreview) {\n    mediaType = data.mediaType;\n    url = data.url;\n  } else if (isVideoNoPreview) {\n    mediaType = data.mediaType;\n  }\n  const isImage = mediaType?.startsWith(\"image/\") && url;\n  const isVideo = mediaType?.startsWith(\"video/\") && url;\n  const isText = mediaType?.startsWith(\"text/\") && url;\n  const canPreview = (isImage || isVideo || isText) && Boolean(url);\n  const attachmentLabel =\n    filename || (isImage ? \"Image\" : isVideo || isVideoNoPreview ? \"Video\" : \"Attachment\");\n  const typeBadge = isImage ? \"Image\" : isVideo ? \"Video\" : undefined;\n  const videoPoster = useVideoThumbnail(isVideo ? url : undefined);\n\n  // Decode text content from data URL when opening preview\n  const handleOpenPreview = () => {\n    if (isText && url?.startsWith(\"data:\")) {\n      try {\n        const base64 = url.split(\",\")[1];\n        const decoded = atob(base64);\n        const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0));\n        setTextContent(new TextDecoder().decode(bytes));\n      } catch {\n        setTextContent(\"Failed to decode file content\");\n      }\n    }\n    setIsPreviewOpen(true);\n  };\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"group relative size-24 overflow-hidden rounded-lg\",\n          (isImage || isVideo) && \"border border-border\",\n          canPreview ? \"cursor-zoom-in\" : undefined,\n          className,\n        )}\n        onClick={() => {\n          if (canPreview) {\n            handleOpenPreview();\n          }\n        }}\n        onKeyDown={(event) => {\n          if (!canPreview) {\n            return;\n          }\n          if (event.key === \"Enter\" || event.key === \" \") {\n            event.preventDefault();\n            handleOpenPreview();\n          }\n        }}\n        role={canPreview ? \"button\" : undefined}\n        tabIndex={canPreview ? 0 : undefined}\n        {...props}\n      >\n        {isImage ? (\n          <>\n            <img\n              alt={filename || \"attachment\"}\n              className=\"size-full object-cover\"\n              height={160}\n              src={url}\n              width={160}\n            />\n            {typeBadge && (\n              <span className=\"pointer-events-none absolute bottom-2 right-2 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white shadow-sm\">\n                {typeBadge}\n              </span>\n            )}\n            {onRemove && (\n              <Button\n                aria-label=\"Remove attachment\"\n                className=\"hover-reveal absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onRemove();\n                }}\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <XIcon />\n                <span className=\"sr-only\">Remove</span>\n              </Button>\n            )}\n          </>\n        ) : isVideo ? (\n          <>\n            <video\n              className=\"size-full object-cover\"\n              height={160}\n              poster={videoPoster ?? undefined}\n              preload=\"metadata\"\n              src={url}\n              width={160}\n              muted\n              playsInline\n            />\n            {typeBadge && (\n              <span className=\"pointer-events-none absolute bottom-2 right-2 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white shadow-sm\">\n                {typeBadge}\n              </span>\n            )}\n            {onRemove && (\n              <Button\n                aria-label=\"Remove attachment\"\n                className=\"hover-reveal absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onRemove();\n                }}\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <XIcon />\n                <span className=\"sr-only\">Remove</span>\n              </Button>\n            )}\n          </>\n        ) : (\n          <>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <div className=\"flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\">\n                  {isVideoNoPreview ? (\n                    <VideoIcon className=\"size-4\" />\n                  ) : (\n                    <PaperclipIcon className=\"size-4\" />\n                  )}\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{attachmentLabel}</p>\n              </TooltipContent>\n            </Tooltip>\n            {onRemove && (\n              <Button\n                aria-label=\"Remove attachment\"\n                className=\"hover-reveal size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onRemove();\n                }}\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <XIcon />\n                <span className=\"sr-only\">Remove</span>\n              </Button>\n            )}\n          </>\n        )}\n      </div>\n\n      {canPreview ? (\n        <Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>\n          <DialogContent\n            className=\"max-w-[min(95vw,1100px)] overflow-hidden p-0 sm:max-w-[min(95vw,1100px)]\"\n            showCloseButton\n          >\n            <DialogHeader className={isText ? \"p-4 pb-0\" : \"sr-only\"}>\n              <DialogTitle>\n                {isText ? filename : \"Attachment preview\"}\n              </DialogTitle>\n            </DialogHeader>\n            <div className=\"bg-background\">\n              {isImage ? (\n                <img\n                  alt={filename || \"attachment\"}\n                  className=\"block max-h-[88vh] w-full object-contain\"\n                  src={url}\n                />\n              ) : isVideo ? (\n                <video\n                  className=\"block max-h-[88vh] w-full object-contain\"\n                  src={url}\n                  controls\n                  poster={videoPoster ?? undefined}\n                  autoPlay\n                  playsInline\n                />\n              ) : isText && textContent !== null ? (\n                <pre className=\"max-h-[80vh] overflow-auto p-4 pt-2 text-sm whitespace-pre-wrap wrap-break-word font-mono\">\n                  {textContent}\n                </pre>\n              ) : null}\n            </div>\n          </DialogContent>\n        </Dialog>\n      ) : null}\n    </>\n  );\n}\n\nexport type MessageAttachmentsProps = ComponentProps<\"div\">;\n\nexport function MessageAttachments({\n  children,\n  className,\n  ...props\n}: MessageAttachmentsProps) {\n  if (!children) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"ml-auto flex w-fit flex-wrap items-start gap-2\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport type MessageToolbarProps = ComponentProps<\"div\">;\n\nexport const MessageToolbar = ({\n  className,\n  children,\n  ...props\n}: MessageToolbarProps) => (\n  <div\n    className={cn(\n      \"mt-4 flex w-full items-center justify-between gap-4\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n  </div>\n);\n"
  },
  {
    "path": "web/src/components/ai-elements/model-selector.tsx",
    "content": "import {\n  Command,\n  CommandDialog,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n  CommandShortcut,\n} from \"@/components/ui/command\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\nimport type { ComponentProps, ReactNode } from \"react\";\n\nexport type ModelSelectorProps = ComponentProps<typeof Dialog>;\n\nexport const ModelSelector = (props: ModelSelectorProps) => (\n  <Dialog {...props} />\n);\n\nexport type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;\n\nexport const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (\n  <DialogTrigger {...props} />\n);\n\nexport type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {\n  title?: ReactNode;\n};\n\nexport const ModelSelectorContent = ({\n  className,\n  children,\n  title = \"Model Selector\",\n  ...props\n}: ModelSelectorContentProps) => (\n  <DialogContent className={cn(\"p-0\", className)} {...props}>\n    <DialogTitle className=\"sr-only\">{title}</DialogTitle>\n    <Command className=\"**:data-[slot=command-input-wrapper]:h-auto\">\n      {children}\n    </Command>\n  </DialogContent>\n);\n\nexport type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;\n\nexport const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (\n  <CommandDialog {...props} />\n);\n\nexport type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;\n\nexport const ModelSelectorInput = ({\n  className,\n  ...props\n}: ModelSelectorInputProps) => (\n  <CommandInput className={cn(\"h-auto py-3.5\", className)} {...props} />\n);\n\nexport type ModelSelectorListProps = ComponentProps<typeof CommandList>;\n\nexport const ModelSelectorList = (props: ModelSelectorListProps) => (\n  <CommandList {...props} />\n);\n\nexport type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;\n\nexport const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (\n  <CommandEmpty {...props} />\n);\n\nexport type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;\n\nexport const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (\n  <CommandGroup {...props} />\n);\n\nexport type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;\n\nexport const ModelSelectorItem = (props: ModelSelectorItemProps) => (\n  <CommandItem {...props} />\n);\n\nexport type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;\n\nexport const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (\n  <CommandShortcut {...props} />\n);\n\nexport type ModelSelectorSeparatorProps = ComponentProps<\n  typeof CommandSeparator\n>;\n\nexport const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (\n  <CommandSeparator {...props} />\n);\n\nexport type ModelSelectorLogoProps = Omit<\n  ComponentProps<\"img\">,\n  \"src\" | \"alt\"\n> & {\n  provider:\n    | \"moonshotai-cn\"\n    | \"lucidquery\"\n    | \"moonshotai\"\n    | \"zai-coding-plan\"\n    | \"alibaba\"\n    | \"xai\"\n    | \"vultr\"\n    | \"nvidia\"\n    | \"upstage\"\n    | \"groq\"\n    | \"github-copilot\"\n    | \"mistral\"\n    | \"vercel\"\n    | \"nebius\"\n    | \"deepseek\"\n    | \"alibaba-cn\"\n    | \"google-vertex-anthropic\"\n    | \"venice\"\n    | \"chutes\"\n    | \"cortecs\"\n    | \"github-models\"\n    | \"togetherai\"\n    | \"azure\"\n    | \"baseten\"\n    | \"huggingface\"\n    | \"opencode\"\n    | \"fastrouter\"\n    | \"google\"\n    | \"google-vertex\"\n    | \"cloudflare-workers-ai\"\n    | \"inception\"\n    | \"wandb\"\n    | \"openai\"\n    | \"zhipuai-coding-plan\"\n    | \"perplexity\"\n    | \"openrouter\"\n    | \"zenmux\"\n    | \"v0\"\n    | \"iflowcn\"\n    | \"synthetic\"\n    | \"deepinfra\"\n    | \"zhipuai\"\n    | \"submodel\"\n    | \"zai\"\n    | \"inference\"\n    | \"requesty\"\n    | \"morph\"\n    | \"lmstudio\"\n    | \"anthropic\"\n    | \"aihubmix\"\n    | \"fireworks-ai\"\n    | \"modelscope\"\n    | \"llama\"\n    | \"scaleway\"\n    | \"amazon-bedrock\"\n    | \"cerebras\"\n    | (string & {});\n};\n\nexport const ModelSelectorLogo = ({\n  provider,\n  className,\n  ...props\n}: ModelSelectorLogoProps) => (\n  <img\n    {...props}\n    alt={`${provider} logo`}\n    className={cn(\"size-3 dark:invert\", className)}\n    height={12}\n    src={`https://models.dev/logos/${provider}.svg`}\n    width={12}\n  />\n);\n\nexport type ModelSelectorLogoGroupProps = ComponentProps<\"div\">;\n\nexport const ModelSelectorLogoGroup = ({\n  className,\n  ...props\n}: ModelSelectorLogoGroupProps) => (\n  <div\n    className={cn(\n      \"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type ModelSelectorNameProps = ComponentProps<\"span\">;\n\nexport const ModelSelectorName = ({\n  className,\n  ...props\n}: ModelSelectorNameProps) => (\n  <span className={cn(\"flex-1 truncate text-left\", className)} {...props} />\n);\n"
  },
  {
    "path": "web/src/components/ai-elements/prompt-input.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\nimport { IMAGE_CONFIG, VIDEO_CONFIG, MEDIA_CONFIG } from \"@/config/media\";\nimport { useVideoThumbnail } from \"@/hooks/useVideoThumbnail\";\nimport type { ChatStatus, FileUIPart } from \"ai\";\nimport {\n  CornerDownLeftIcon,\n  ImageIcon,\n  Loader2Icon,\n  MicIcon,\n  PaperclipIcon,\n  PlusIcon,\n  SquareIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { nanoid } from \"nanoid\";\nimport {\n  type ChangeEvent,\n  type ChangeEventHandler,\n  Children,\n  type ClipboardEventHandler,\n  type ComponentProps,\n  createContext,\n  type FormEvent,\n  type FormEventHandler,\n  Fragment,\n  type HTMLAttributes,\n  type KeyboardEventHandler,\n  type PropsWithChildren,\n  type ReactNode,\n  type RefObject,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n// ============================================================================\n// Provider Context & Types\n// ============================================================================\n\nexport type AttachmentsContext = {\n  files: (FileUIPart & { id: string })[];\n  add: (files: File[] | FileList) => void;\n  remove: (id: string) => void;\n  clear: () => void;\n  openFileDialog: () => void;\n  fileInputRef: RefObject<HTMLInputElement | null>;\n};\n\nexport type TextInputContext = {\n  value: string;\n  setInput: (v: string) => void;\n  clear: () => void;\n};\n\nexport type PromptInputControllerProps = {\n  textInput: TextInputContext;\n  attachments: AttachmentsContext;\n  /** INTERNAL: Allows PromptInput to register its file textInput + \"open\" callback */\n  __registerFileInput: (\n    ref: RefObject<HTMLInputElement | null>,\n    open: () => void,\n  ) => void;\n};\n\nconst PromptInputController = createContext<PromptInputControllerProps | null>(\n  null,\n);\nconst ProviderAttachmentsContext = createContext<AttachmentsContext | null>(\n  null,\n);\n\nexport const usePromptInputController = () => {\n  const ctx = useContext(PromptInputController);\n  if (!ctx) {\n    throw new Error(\n      \"Wrap your component inside <PromptInputProvider> to use usePromptInputController().\",\n    );\n  }\n  return ctx;\n};\n\n// Optional variants (do NOT throw). Useful for dual-mode components.\nconst useOptionalPromptInputController = () =>\n  useContext(PromptInputController);\n\nexport const useProviderAttachments = () => {\n  const ctx = useContext(ProviderAttachmentsContext);\n  if (!ctx) {\n    throw new Error(\n      \"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().\",\n    );\n  }\n  return ctx;\n};\n\nconst useOptionalProviderAttachments = () =>\n  useContext(ProviderAttachmentsContext);\n\nexport type PromptInputProviderProps = PropsWithChildren<{\n  initialInput?: string;\n}>;\n\n/**\n * Optional global provider that lifts PromptInput state outside of PromptInput.\n * If you don't use it, PromptInput stays fully self-managed.\n */\nexport function PromptInputProvider({\n  initialInput: initialTextInput = \"\",\n  children,\n}: PromptInputProviderProps) {\n  // ----- textInput state\n  const [textInput, setTextInput] = useState(initialTextInput);\n  const clearInput = useCallback(() => setTextInput(\"\"), []);\n\n  // ----- attachments state (global when wrapped)\n  const [attachments, setAttachments] = useState<\n    (FileUIPart & { id: string })[]\n  >([]);\n  const fileInputRef = useRef<HTMLInputElement | null>(null);\n  const openRef = useRef<() => void>(() => {});\n\n  const add = useCallback((files: File[] | FileList) => {\n    const incoming = Array.from(files);\n    if (incoming.length === 0) {\n      return;\n    }\n\n    setAttachments((prev) =>\n      prev.concat(\n        incoming.map((file) => ({\n          id: nanoid(),\n          type: \"file\" as const,\n          url: URL.createObjectURL(file),\n          mediaType: file.type,\n          filename: file.name,\n        })),\n      ),\n    );\n  }, []);\n\n  const remove = useCallback((id: string) => {\n    setAttachments((prev) => {\n      const found = prev.find((f) => f.id === id);\n      if (found?.url) {\n        URL.revokeObjectURL(found.url);\n      }\n      return prev.filter((f) => f.id !== id);\n    });\n  }, []);\n\n  const clear = useCallback(() => {\n    setAttachments((prev) => {\n      for (const f of prev) {\n        if (f.url) {\n          URL.revokeObjectURL(f.url);\n        }\n      }\n      return [];\n    });\n  }, []);\n\n  const openFileDialog = useCallback(() => {\n    openRef.current?.();\n  }, []);\n\n  const attachmentsContext = useMemo<AttachmentsContext>(\n    () => ({\n      files: attachments,\n      add,\n      remove,\n      clear,\n      openFileDialog,\n      fileInputRef,\n    }),\n    [attachments, add, remove, clear, openFileDialog],\n  );\n\n  const __registerFileInput = useCallback(\n    (ref: RefObject<HTMLInputElement | null>, open: () => void) => {\n      fileInputRef.current = ref.current;\n      openRef.current = open;\n    },\n    [],\n  );\n\n  const controller = useMemo<PromptInputControllerProps>(\n    () => ({\n      textInput: {\n        value: textInput,\n        setInput: setTextInput,\n        clear: clearInput,\n      },\n      attachments: attachmentsContext,\n      __registerFileInput,\n    }),\n    [textInput, clearInput, attachmentsContext, __registerFileInput],\n  );\n\n  return (\n    <PromptInputController.Provider value={controller}>\n      <ProviderAttachmentsContext.Provider value={attachmentsContext}>\n        {children}\n      </ProviderAttachmentsContext.Provider>\n    </PromptInputController.Provider>\n  );\n}\n\n// ============================================================================\n// Component Context & Hooks\n// ============================================================================\n\nconst LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);\n\nexport const usePromptInputAttachments = () => {\n  // Dual-mode: prefer provider if present, otherwise use local\n  const provider = useOptionalProviderAttachments();\n  const local = useContext(LocalAttachmentsContext);\n  const context = provider ?? local;\n  if (!context) {\n    throw new Error(\n      \"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider\",\n    );\n  }\n  return context;\n};\n\nexport type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {\n  data: FileUIPart & { id: string };\n  className?: string;\n};\n\nexport function PromptInputAttachment({\n  data,\n  className,\n  ...props\n}: PromptInputAttachmentProps) {\n  const attachments = usePromptInputAttachments();\n\n  const filename = data.filename || \"\";\n\n  const isImage = data.mediaType?.startsWith(\"image/\") && data.url;\n  const isVideo = data.mediaType?.startsWith(\"video/\") && data.url;\n\n  const attachmentLabel = filename || (isImage ? \"Image\" : isVideo ? \"Video\" : \"Attachment\");\n  const typeBadge = isImage ? \"Image\" : isVideo ? \"Video\" : undefined;\n  const videoPoster = useVideoThumbnail(isVideo ? data.url : undefined);\n\n  if (isImage) {\n    return (\n      <div\n        className={cn(\"group relative\", className)}\n        key={data.id}\n        {...props}\n      >\n        <img\n          alt={filename || \"uploaded image\"}\n          className=\"h-14 w-14 rounded-lg border border-border/50 object-cover\"\n          src={data.url}\n        />\n        {typeBadge && (\n          <span className=\"pointer-events-none absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-[9px] font-semibold leading-none text-white shadow-sm\">\n            {typeBadge}\n          </span>\n        )}\n        <Button\n          aria-label=\"Remove attachment\"\n          className=\"hover-reveal absolute -right-1.5 -top-1.5 size-5 cursor-pointer rounded-full bg-background p-0 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3\"\n          onClick={(e) => {\n            e.stopPropagation();\n            attachments.remove(data.id);\n          }}\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <XIcon />\n          <span className=\"sr-only\">Remove</span>\n        </Button>\n      </div>\n    );\n  }\n\n  if (isVideo) {\n    return (\n      <div\n        className={cn(\"group relative\", className)}\n        key={data.id}\n        {...props}\n      >\n        <video\n          className=\"h-14 w-14 rounded-lg border border-border/50 object-cover\"\n          poster={videoPoster ?? undefined}\n          preload=\"metadata\"\n          src={data.url}\n          muted\n          playsInline\n        />\n        {typeBadge && (\n          <span className=\"pointer-events-none absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-[9px] font-semibold leading-none text-white shadow-sm\">\n            {typeBadge}\n          </span>\n        )}\n        <Button\n          aria-label=\"Remove attachment\"\n          className=\"hover-reveal absolute -right-1.5 -top-1.5 size-5 cursor-pointer rounded-full bg-background p-0 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3\"\n          onClick={(e) => {\n            e.stopPropagation();\n            attachments.remove(data.id);\n          }}\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <XIcon />\n          <span className=\"sr-only\">Remove</span>\n        </Button>\n      </div>\n    );\n  }\n\n  return (\n    <PromptInputHoverCard>\n      <HoverCardTrigger asChild>\n        <div\n          className={cn(\n            \"group relative flex h-8 cursor-default select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n            className,\n          )}\n          key={data.id}\n          {...props}\n        >\n          <div className=\"relative size-5 shrink-0\">\n            <div className=\"absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0\">\n              <div className=\"flex size-5 items-center justify-center text-muted-foreground\">\n                <PaperclipIcon className=\"size-3\" />\n              </div>\n            </div>\n            <Button\n              aria-label=\"Remove attachment\"\n              className=\"hover-reveal absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5\"\n              onClick={(e) => {\n                e.stopPropagation();\n                attachments.remove(data.id);\n              }}\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <XIcon />\n              <span className=\"sr-only\">Remove</span>\n            </Button>\n          </div>\n\n          <span className=\"flex-1 truncate\">{attachmentLabel}</span>\n        </div>\n      </HoverCardTrigger>\n      <PromptInputHoverCardContent className=\"w-auto p-2\">\n        <div className=\"w-auto space-y-3\">\n          <div className=\"flex items-center gap-2.5\">\n            <div className=\"min-w-0 flex-1 space-y-1 px-0.5\">\n              <h4 className=\"truncate font-semibold text-sm leading-none\">\n                {attachmentLabel}\n              </h4>\n              {data.mediaType && (\n                <p className=\"truncate font-mono text-muted-foreground text-xs\">\n                  {data.mediaType}\n                </p>\n              )}\n            </div>\n          </div>\n        </div>\n      </PromptInputHoverCardContent>\n    </PromptInputHoverCard>\n  );\n}\n\nexport type PromptInputAttachmentsProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  children: (attachment: FileUIPart & { id: string }) => ReactNode;\n};\n\nexport function PromptInputAttachments({\n  children,\n  className,\n  ...props\n}: PromptInputAttachmentsProps) {\n  const attachments = usePromptInputAttachments();\n\n  if (!attachments.files.length) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\"flex flex-wrap items-start gap-2 p-3 w-full\", className)}\n      {...props}\n    >\n      {attachments.files.map((file) => (\n        <Fragment key={file.id}>{children(file)}</Fragment>\n      ))}\n    </div>\n  );\n}\n\nexport type PromptInputActionAddAttachmentsProps = ComponentProps<\n  typeof DropdownMenuItem\n> & {\n  label?: string;\n};\n\nexport const PromptInputActionAddAttachments = ({\n  label = \"Add photos or files\",\n  ...props\n}: PromptInputActionAddAttachmentsProps) => {\n  const attachments = usePromptInputAttachments();\n\n  return (\n    <DropdownMenuItem\n      {...props}\n      onSelect={(e) => {\n        e.preventDefault();\n        attachments.openFileDialog();\n      }}\n    >\n      <ImageIcon className=\"mr-2 size-4\" /> {label}\n    </DropdownMenuItem>\n  );\n};\n\nexport type PromptInputMessage = {\n  text: string;\n  files: FileUIPart[];\n};\n\nexport type PromptInputProps = Omit<\n  HTMLAttributes<HTMLFormElement>,\n  \"onSubmit\" | \"onError\"\n> & {\n  accept?: string; // e.g., \"image/*\" or leave undefined for any\n  multiple?: boolean;\n  // When true, accepts drops anywhere on document. Default false (opt-in).\n  globalDrop?: boolean;\n  // Render a hidden input with given name and keep it in sync for native form posts. Default false.\n  syncHiddenInput?: boolean;\n  // Minimal constraints\n  maxFiles?: number;\n  maxFileSize?: number; // bytes\n  onError?: (err: {\n    code: \"max_files\" | \"max_file_size\" | \"accept\";\n    message: string;\n  }) => void;\n  onSubmit: (\n    message: PromptInputMessage,\n    event: FormEvent<HTMLFormElement>,\n  ) => void | Promise<void>;\n};\n\nexport const PromptInput = ({\n  className,\n  accept,\n  multiple,\n  globalDrop,\n  syncHiddenInput,\n  maxFiles,\n  maxFileSize,\n  onError,\n  onSubmit,\n  children,\n  ...props\n}: PromptInputProps) => {\n  // Try to use a provider controller if present\n  const controller = useOptionalPromptInputController();\n  const usingProvider = !!controller;\n\n  // Refs\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const formRef = useRef<HTMLFormElement | null>(null);\n\n  // ----- Local attachments (only used when no provider)\n  const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);\n  const files = usingProvider ? controller.attachments.files : items;\n\n  const openFileDialogLocal = useCallback(() => {\n    inputRef.current?.click();\n  }, []);\n\n  const matchesAccept = useCallback(\n    (f: File) => {\n      if (!accept || accept.trim() === \"\") {\n        return true;\n      }\n      if (accept.includes(\"image/*\")) {\n        return f.type.startsWith(\"image/\");\n      }\n      // NOTE: keep simple; expand as needed\n      return true;\n    },\n    [accept],\n  );\n\n  const validateMediaFile = useCallback(\n    (file: File): { valid: boolean; error?: string } => {\n      const isImage = file.type.startsWith(\"image/\");\n      const isVideo = file.type.startsWith(\"video/\");\n\n      if (isImage) {\n        if (!IMAGE_CONFIG.allowedTypes.includes(file.type as typeof IMAGE_CONFIG.allowedTypes[number])) {\n          return { valid: false, error: `Unsupported image type: ${file.type}` };\n        }\n        if (file.size > IMAGE_CONFIG.maxSizeBytes) {\n          return { valid: false, error: `Image too large: ${(file.size / 1024 / 1024).toFixed(1)}MB (max ${IMAGE_CONFIG.maxSizeBytes / 1024 / 1024}MB)` };\n        }\n      } else if (isVideo) {\n        if (!VIDEO_CONFIG.allowedTypes.includes(file.type as typeof VIDEO_CONFIG.allowedTypes[number])) {\n          return { valid: false, error: `Unsupported video type: ${file.type}` };\n        }\n        if (file.size > VIDEO_CONFIG.maxSizeBytes) {\n          return { valid: false, error: `Video too large: ${(file.size / 1024 / 1024).toFixed(1)}MB (max ${VIDEO_CONFIG.maxSizeBytes / 1024 / 1024}MB)` };\n        }\n      }\n      return { valid: true };\n    },\n    [],\n  );\n\n  /**\n   * Validates and filters files with unified error handling.\n   * Returns validated files and optionally reports the first error encountered.\n   */\n  const validateAndFilterFiles = useCallback(\n    (fileList: File[] | FileList): File[] => {\n      const incoming = Array.from(fileList);\n\n      // Filter by accept type\n      const accepted = incoming.filter((f) => matchesAccept(f));\n      if (incoming.length && accepted.length === 0) {\n        onError?.({\n          code: \"accept\",\n          message: \"No files match the accepted types.\",\n        });\n        return [];\n      }\n\n      // Validate media files (images/videos) and collect first error\n      const validatedFiles: File[] = [];\n      let firstValidationError: string | null = null;\n\n      for (const file of accepted) {\n        const validation = validateMediaFile(file);\n        if (!validation.valid) {\n          if (!firstValidationError) {\n            firstValidationError = validation.error ?? \"File validation failed\";\n          }\n        } else {\n          validatedFiles.push(file);\n        }\n      }\n\n      // Report first validation error if any files failed\n      if (firstValidationError && validatedFiles.length < accepted.length) {\n        const failedCount = accepted.length - validatedFiles.length;\n        const message = failedCount > 1\n          ? `${failedCount} files failed validation. First error: ${firstValidationError}`\n          : firstValidationError;\n        onError?.({\n          code: \"max_file_size\",\n          message,\n        });\n      }\n\n      if (validatedFiles.length === 0) {\n        return [];\n      }\n\n      // Filter by maxFileSize (if specified)\n      if (maxFileSize) {\n        const sized = validatedFiles.filter((f) => f.size <= maxFileSize);\n        if (sized.length === 0) {\n          onError?.({\n            code: \"max_file_size\",\n            message: \"All files exceed the maximum size.\",\n          });\n          return [];\n        }\n        return sized;\n      }\n\n      return validatedFiles;\n    },\n    [matchesAccept, validateMediaFile, maxFileSize, onError],\n  );\n\n  const addLocal = useCallback(\n    (fileList: File[] | FileList) => {\n      const validatedFiles = validateAndFilterFiles(fileList);\n      if (validatedFiles.length === 0) {\n        return;\n      }\n\n      setItems((prev) => {\n        const effectiveMaxFiles = maxFiles ?? MEDIA_CONFIG.maxCount;\n        const capacity = Math.max(0, effectiveMaxFiles - prev.length);\n        const capped = validatedFiles.slice(0, capacity);\n\n        if (validatedFiles.length > capacity) {\n          onError?.({\n            code: \"max_files\",\n            message: `Too many files. Maximum ${effectiveMaxFiles} files allowed.`,\n          });\n        }\n\n        const next: (FileUIPart & { id: string })[] = [];\n        for (const file of capped) {\n          next.push({\n            id: nanoid(),\n            type: \"file\",\n            url: URL.createObjectURL(file),\n            mediaType: file.type,\n            filename: file.name,\n          });\n        }\n        return prev.concat(next);\n      });\n    },\n    [validateAndFilterFiles, maxFiles, onError],\n  );\n\n  const addWithValidation = useCallback(\n    (fileList: File[] | FileList) => {\n      const validatedFiles = validateAndFilterFiles(fileList);\n      if (validatedFiles.length === 0) {\n        return;\n      }\n\n      // Check max files limit\n      const currentCount = controller?.attachments.files.length ?? 0;\n      const effectiveMaxFiles = maxFiles ?? MEDIA_CONFIG.maxCount;\n      const capacity = Math.max(0, effectiveMaxFiles - currentCount);\n      const capped = validatedFiles.slice(0, capacity);\n\n      if (validatedFiles.length > capacity) {\n        onError?.({\n          code: \"max_files\",\n          message: `Too many files. Maximum ${effectiveMaxFiles} files allowed.`,\n        });\n      }\n\n      if (capped.length > 0) {\n        controller?.attachments.add(capped);\n      }\n    },\n    [controller, maxFiles, onError, validateAndFilterFiles],\n  );\n\n  const add = usingProvider ? addWithValidation : addLocal;\n\n  const remove = usingProvider\n    ? (id: string) => controller.attachments.remove(id)\n    : (id: string) =>\n        setItems((prev) => {\n          const found = prev.find((file) => file.id === id);\n          if (found?.url) {\n            URL.revokeObjectURL(found.url);\n          }\n          return prev.filter((file) => file.id !== id);\n        });\n\n  const clear = usingProvider\n    ? () => controller.attachments.clear()\n    : () =>\n        setItems((prev) => {\n          for (const file of prev) {\n            if (file.url) {\n              URL.revokeObjectURL(file.url);\n            }\n          }\n          return [];\n        });\n\n  const openFileDialog = usingProvider\n    ? () => controller.attachments.openFileDialog()\n    : openFileDialogLocal;\n\n  // Let provider know about our hidden file input so external menus can call openFileDialog()\n  useEffect(() => {\n    if (!usingProvider) return;\n    controller.__registerFileInput(inputRef, () => inputRef.current?.click());\n  }, [usingProvider, controller]);\n\n  // Note: File input cannot be programmatically set for security reasons\n  // The syncHiddenInput prop is no longer functional\n  useEffect(() => {\n    if (syncHiddenInput && inputRef.current && files.length === 0) {\n      inputRef.current.value = \"\";\n    }\n  }, [files, syncHiddenInput]);\n\n  // Attach drop handlers on nearest form and document (opt-in)\n  useEffect(() => {\n    const form = formRef.current;\n    if (!form) return;\n\n    const onDragOver = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n    };\n    const onDrop = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {\n        add(e.dataTransfer.files);\n      }\n    };\n    form.addEventListener(\"dragover\", onDragOver);\n    form.addEventListener(\"drop\", onDrop);\n    return () => {\n      form.removeEventListener(\"dragover\", onDragOver);\n      form.removeEventListener(\"drop\", onDrop);\n    };\n  }, [add]);\n\n  useEffect(() => {\n    if (!globalDrop) return;\n\n    const onDragOver = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n    };\n    const onDrop = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {\n        add(e.dataTransfer.files);\n      }\n    };\n    document.addEventListener(\"dragover\", onDragOver);\n    document.addEventListener(\"drop\", onDrop);\n    return () => {\n      document.removeEventListener(\"dragover\", onDragOver);\n      document.removeEventListener(\"drop\", onDrop);\n    };\n  }, [add, globalDrop]);\n\n  useEffect(\n    () => () => {\n      if (!usingProvider) {\n        for (const f of files) {\n          if (f.url) URL.revokeObjectURL(f.url);\n        }\n      }\n    },\n    [usingProvider, files],\n  );\n\n  const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {\n    if (event.currentTarget.files) {\n      add(event.currentTarget.files);\n    }\n  };\n\n  const convertBlobUrlToDataUrl = async (url: string): Promise<string> => {\n    const response = await fetch(url);\n    const blob = await response.blob();\n    return new Promise((resolve, reject) => {\n      const reader = new FileReader();\n      reader.onloadend = () => resolve(reader.result as string);\n      reader.onerror = reject;\n      reader.readAsDataURL(blob);\n    });\n  };\n\n  const ctx = useMemo<AttachmentsContext>(\n    () => ({\n      files: files.map((item) => ({ ...item, id: item.id })),\n      add,\n      remove,\n      clear,\n      openFileDialog,\n      fileInputRef: inputRef,\n    }),\n    [files, add, remove, clear, openFileDialog],\n  );\n\n  const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {\n    event.preventDefault();\n\n    const form = event.currentTarget;\n    const text = usingProvider\n      ? controller.textInput.value\n      : (() => {\n          const formData = new FormData(form);\n          return (formData.get(\"message\") as string) || \"\";\n        })();\n\n    // Reset form immediately after capturing text to avoid race condition\n    // where user input during async blob conversion would be lost\n    if (!usingProvider) {\n      form.reset();\n    }\n\n    // Convert blob URLs to data URLs asynchronously\n    Promise.all(\n      files.map(async ({ id, ...item }) => {\n        if (item.url && item.url.startsWith(\"blob:\")) {\n          return {\n            ...item,\n            url: await convertBlobUrlToDataUrl(item.url),\n          };\n        }\n        return item;\n      }),\n    )\n      .then((convertedFiles: FileUIPart[]) => {\n        try {\n          const result = onSubmit({ text, files: convertedFiles }, event);\n\n          // Handle both sync and async onSubmit\n          if (result instanceof Promise) {\n            result\n              .then(() => {\n                clear();\n                if (usingProvider) {\n                  controller.textInput.clear();\n                }\n              })\n              .catch(() => {\n                // Don't clear on error - user may want to retry\n              });\n          } else {\n            // Sync function completed without throwing, clear attachments\n            clear();\n            if (usingProvider) {\n              controller.textInput.clear();\n            }\n          }\n        } catch {\n          // Don't clear on error - user may want to retry\n        }\n      })\n      .catch((error) => {\n        console.error(\"[PromptInput] Failed to convert files:\", error);\n        onError?.({\n          code: \"max_file_size\",\n          message: \"Failed to process file. Please try a smaller file.\",\n        });\n      });\n  };\n\n  // Render with or without local provider\n  const inner = (\n    <>\n      <input\n        accept={accept}\n        aria-label=\"Upload files\"\n        className=\"hidden\"\n        multiple={multiple}\n        onChange={handleChange}\n        ref={inputRef}\n        title=\"Upload files\"\n        type=\"file\"\n      />\n      <form\n        className={cn(\"w-full\", className)}\n        autoComplete=\"off\"\n        onSubmit={handleSubmit}\n        ref={formRef}\n        {...props}\n      >\n        <InputGroup className=\"overflow-visible\">{children}</InputGroup>\n      </form>\n    </>\n  );\n\n  return usingProvider ? (\n    inner\n  ) : (\n    <LocalAttachmentsContext.Provider value={ctx}>\n      {inner}\n    </LocalAttachmentsContext.Provider>\n  );\n};\n\nexport type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputBody = ({\n  className,\n  ...props\n}: PromptInputBodyProps) => (\n  <div className={cn(\"contents\", className)} {...props} />\n);\n\nexport type PromptInputTextareaProps = ComponentProps<\n  typeof InputGroupTextarea\n>;\n\nexport const PromptInputTextarea = forwardRef<\n  HTMLTextAreaElement,\n  PromptInputTextareaProps\n>(\n  (\n    {\n      onChange,\n      onKeyDown,\n      onPaste,\n      className,\n      placeholder = \"What would you like to know?\",\n      ...props\n    },\n    ref,\n  ) => {\n    const controller = useOptionalPromptInputController();\n    const attachments = usePromptInputAttachments();\n    const [isComposing, setIsComposing] = useState(false);\n\n    const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {\n      onKeyDown?.(e);\n      if (e.defaultPrevented) {\n        return;\n      }\n\n      if (e.key === \"Enter\") {\n        if (isComposing || e.nativeEvent.isComposing) {\n          return;\n        }\n        if (e.shiftKey) {\n          return;\n        }\n        e.preventDefault();\n\n        // Check if the submit button is disabled before submitting\n        const form = e.currentTarget.form;\n        const submitButton = form?.querySelector(\n          'button[type=\"submit\"]',\n        ) as HTMLButtonElement | null;\n        if (submitButton?.disabled) {\n          return;\n        }\n\n        form?.requestSubmit();\n      }\n\n      // Remove last attachment when Backspace is pressed and textarea is empty\n      if (\n        e.key === \"Backspace\" &&\n        e.currentTarget.value === \"\" &&\n        attachments.files.length > 0\n      ) {\n        e.preventDefault();\n        const lastAttachment = attachments.files.at(-1);\n        if (lastAttachment) {\n          attachments.remove(lastAttachment.id);\n        }\n      }\n    };\n\n    const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {\n      onPaste?.(event);\n      if (event.defaultPrevented) {\n        return;\n      }\n\n      const items = event.clipboardData?.items;\n\n      if (!items) {\n        return;\n      }\n\n      const files: File[] = [];\n\n      for (const item of items) {\n        if (item.kind === \"file\") {\n          const file = item.getAsFile();\n          if (file) {\n            files.push(file);\n          }\n        }\n      }\n\n      if (files.length > 0) {\n        event.preventDefault();\n        attachments.add(files);\n      }\n    };\n\n    const controlledProps = controller\n      ? {\n          value: controller.textInput.value,\n          onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {\n            controller.textInput.setInput(e.currentTarget.value);\n            onChange?.(e);\n          },\n        }\n      : {\n          onChange,\n        };\n\n    return (\n      <InputGroupTextarea\n        ref={ref}\n        className={cn(\"field-sizing-content max-h-48 min-h-16\", className)}\n        autoComplete=\"off\"\n        name=\"message\"\n        onBlur={() => setIsComposing(false)}\n        onCompositionEnd={() => setIsComposing(false)}\n        onCompositionStart={() => setIsComposing(true)}\n        onKeyDown={handleKeyDown}\n        onPaste={handlePaste}\n        placeholder={placeholder}\n        {...props}\n        {...controlledProps}\n      />\n    );\n  },\n);\n\nexport type PromptInputHeaderProps = Omit<\n  ComponentProps<typeof InputGroupAddon>,\n  \"align\"\n>;\n\nexport const PromptInputHeader = ({\n  className,\n  ...props\n}: PromptInputHeaderProps) => (\n  <InputGroupAddon\n    align=\"block-end\"\n    className={cn(\"order-first flex-wrap gap-1\", className)}\n    {...props}\n  />\n);\n\nexport type PromptInputFooterProps = Omit<\n  ComponentProps<typeof InputGroupAddon>,\n  \"align\"\n>;\n\nexport const PromptInputFooter = ({\n  className,\n  ...props\n}: PromptInputFooterProps) => (\n  <InputGroupAddon\n    align=\"block-end\"\n    className={cn(\"justify-between gap-1\", className)}\n    {...props}\n  />\n);\n\nexport type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTools = ({\n  className,\n  ...props\n}: PromptInputToolsProps) => (\n  <div className={cn(\"flex items-center gap-1\", className)} {...props} />\n);\n\nexport type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;\n\nexport const PromptInputButton = ({\n  variant = \"ghost\",\n  className,\n  size,\n  ...props\n}: PromptInputButtonProps) => {\n  const newSize =\n    size ?? (Children.count(props.children) > 1 ? \"sm\" : \"icon-sm\");\n\n  return (\n    <InputGroupButton\n      className={cn(className)}\n      size={newSize}\n      type=\"button\"\n      variant={variant}\n      {...props}\n    />\n  );\n};\n\nexport type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;\nexport const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (\n  <DropdownMenu {...props} />\n);\n\nexport type PromptInputActionMenuTriggerProps = PromptInputButtonProps;\n\nexport const PromptInputActionMenuTrigger = ({\n  className,\n  children,\n  ...props\n}: PromptInputActionMenuTriggerProps) => (\n  <DropdownMenuTrigger asChild>\n    <PromptInputButton className={className} {...props}>\n      {children ?? <PlusIcon className=\"size-4\" />}\n    </PromptInputButton>\n  </DropdownMenuTrigger>\n);\n\nexport type PromptInputActionMenuContentProps = ComponentProps<\n  typeof DropdownMenuContent\n>;\nexport const PromptInputActionMenuContent = ({\n  className,\n  ...props\n}: PromptInputActionMenuContentProps) => (\n  <DropdownMenuContent align=\"start\" className={cn(className)} {...props} />\n);\n\nexport type PromptInputActionMenuItemProps = ComponentProps<\n  typeof DropdownMenuItem\n>;\nexport const PromptInputActionMenuItem = ({\n  className,\n  ...props\n}: PromptInputActionMenuItemProps) => (\n  <DropdownMenuItem className={cn(className)} {...props} />\n);\n\n// Note: Actions that perform side-effects (like opening a file dialog)\n// are provided in opt-in modules (e.g., prompt-input-attachments).\n\nexport type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {\n  status?: ChatStatus;\n};\n\nexport const PromptInputSubmit = ({\n  className,\n  variant = \"default\",\n  size = \"icon-sm\",\n  status,\n  children,\n  ...props\n}: PromptInputSubmitProps) => {\n  let Icon = <CornerDownLeftIcon className=\"size-4\" />;\n\n  if (status === \"submitted\") {\n    Icon = <Loader2Icon className=\"size-4 animate-spin\" />;\n  } else if (status === \"streaming\") {\n    Icon = <SquareIcon className=\"size-4\" />;\n  } else if (status === \"error\") {\n    Icon = <XIcon className=\"size-4\" />;\n  }\n\n  return (\n    <InputGroupButton\n      aria-label=\"Submit\"\n      className={cn(className)}\n      size={size}\n      type=\"submit\"\n      variant={variant}\n      {...props}\n    >\n      {children ?? Icon}\n    </InputGroupButton>\n  );\n};\n\ninterface SpeechRecognition extends EventTarget {\n  continuous: boolean;\n  interimResults: boolean;\n  lang: string;\n  start(): void;\n  stop(): void;\n  onstart: ((this: SpeechRecognition, ev: Event) => any) | null;\n  onend: ((this: SpeechRecognition, ev: Event) => any) | null;\n  onresult:\n    | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)\n    | null;\n  onerror:\n    | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)\n    | null;\n}\n\ninterface SpeechRecognitionEvent extends Event {\n  results: SpeechRecognitionResultList;\n  resultIndex: number;\n}\n\ntype SpeechRecognitionResultList = {\n  readonly length: number;\n  item(index: number): SpeechRecognitionResult;\n  [index: number]: SpeechRecognitionResult;\n};\n\ntype SpeechRecognitionResult = {\n  readonly length: number;\n  item(index: number): SpeechRecognitionAlternative;\n  [index: number]: SpeechRecognitionAlternative;\n  isFinal: boolean;\n};\n\ntype SpeechRecognitionAlternative = {\n  transcript: string;\n  confidence: number;\n};\n\ninterface SpeechRecognitionErrorEvent extends Event {\n  error: string;\n}\n\ndeclare global {\n  interface Window {\n    SpeechRecognition: {\n      new (): SpeechRecognition;\n    };\n    webkitSpeechRecognition: {\n      new (): SpeechRecognition;\n    };\n  }\n}\n\nexport type PromptInputSpeechButtonProps = ComponentProps<\n  typeof PromptInputButton\n> & {\n  textareaRef?: RefObject<HTMLTextAreaElement | null>;\n  onTranscriptionChange?: (text: string) => void;\n};\n\nexport const PromptInputSpeechButton = ({\n  className,\n  textareaRef,\n  onTranscriptionChange,\n  ...props\n}: PromptInputSpeechButtonProps) => {\n  const [isListening, setIsListening] = useState(false);\n  const [recognition, setRecognition] = useState<SpeechRecognition | null>(\n    null,\n  );\n  const recognitionRef = useRef<SpeechRecognition | null>(null);\n\n  useEffect(() => {\n    if (\n      typeof window !== \"undefined\" &&\n      (\"SpeechRecognition\" in window || \"webkitSpeechRecognition\" in window)\n    ) {\n      const SpeechRecognition =\n        window.SpeechRecognition || window.webkitSpeechRecognition;\n      const speechRecognition = new SpeechRecognition();\n\n      speechRecognition.continuous = true;\n      speechRecognition.interimResults = true;\n      speechRecognition.lang = \"en-US\";\n\n      speechRecognition.onstart = () => {\n        setIsListening(true);\n      };\n\n      speechRecognition.onend = () => {\n        setIsListening(false);\n      };\n\n      speechRecognition.onresult = (event) => {\n        let finalTranscript = \"\";\n\n        for (let i = event.resultIndex; i < event.results.length; i++) {\n          const result = event.results[i];\n          if (result.isFinal) {\n            finalTranscript += result[0]?.transcript ?? \"\";\n          }\n        }\n\n        if (finalTranscript && textareaRef?.current) {\n          const textarea = textareaRef.current;\n          const currentValue = textarea.value;\n          const newValue =\n            currentValue + (currentValue ? \" \" : \"\") + finalTranscript;\n\n          textarea.value = newValue;\n          textarea.dispatchEvent(new Event(\"input\", { bubbles: true }));\n          onTranscriptionChange?.(newValue);\n        }\n      };\n\n      speechRecognition.onerror = (event) => {\n        console.error(\"Speech recognition error:\", event.error);\n        setIsListening(false);\n      };\n\n      recognitionRef.current = speechRecognition;\n      setRecognition(speechRecognition);\n    }\n\n    return () => {\n      if (recognitionRef.current) {\n        recognitionRef.current.stop();\n      }\n    };\n  }, [textareaRef, onTranscriptionChange]);\n\n  const toggleListening = useCallback(() => {\n    if (!recognition) {\n      return;\n    }\n\n    if (isListening) {\n      recognition.stop();\n    } else {\n      recognition.start();\n    }\n  }, [recognition, isListening]);\n\n  return (\n    <PromptInputButton\n      className={cn(\n        \"relative transition-all duration-200\",\n        isListening && \"animate-pulse bg-accent text-accent-foreground\",\n        className,\n      )}\n      disabled={!recognition}\n      onClick={toggleListening}\n      {...props}\n    >\n      <MicIcon className=\"size-4\" />\n    </PromptInputButton>\n  );\n};\n\nexport type PromptInputSelectProps = ComponentProps<typeof Select>;\n\nexport const PromptInputSelect = (props: PromptInputSelectProps) => (\n  <Select {...props} />\n);\n\nexport type PromptInputSelectTriggerProps = ComponentProps<\n  typeof SelectTrigger\n>;\n\nexport const PromptInputSelectTrigger = ({\n  className,\n  ...props\n}: PromptInputSelectTriggerProps) => (\n  <SelectTrigger\n    className={cn(\n      \"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors\",\n      \"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type PromptInputSelectContentProps = ComponentProps<\n  typeof SelectContent\n>;\n\nexport const PromptInputSelectContent = ({\n  className,\n  ...props\n}: PromptInputSelectContentProps) => (\n  <SelectContent className={cn(className)} {...props} />\n);\n\nexport type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;\n\nexport const PromptInputSelectItem = ({\n  className,\n  ...props\n}: PromptInputSelectItemProps) => (\n  <SelectItem className={cn(className)} {...props} />\n);\n\nexport type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;\n\nexport const PromptInputSelectValue = ({\n  className,\n  ...props\n}: PromptInputSelectValueProps) => (\n  <SelectValue className={cn(className)} {...props} />\n);\n\nexport type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;\n\nexport const PromptInputHoverCard = ({\n  openDelay = 0,\n  closeDelay = 0,\n  ...props\n}: PromptInputHoverCardProps) => (\n  <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />\n);\n\nexport type PromptInputHoverCardTriggerProps = ComponentProps<\n  typeof HoverCardTrigger\n>;\n\nexport const PromptInputHoverCardTrigger = (\n  props: PromptInputHoverCardTriggerProps,\n) => <HoverCardTrigger {...props} />;\n\nexport type PromptInputHoverCardContentProps = ComponentProps<\n  typeof HoverCardContent\n>;\n\nexport const PromptInputHoverCardContent = ({\n  align = \"start\",\n  ...props\n}: PromptInputHoverCardContentProps) => (\n  <HoverCardContent align={align} {...props} />\n);\n\nexport type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTabsList = ({\n  className,\n  ...props\n}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;\n\nexport type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTab = ({\n  className,\n  ...props\n}: PromptInputTabProps) => <div className={cn(className)} {...props} />;\n\nexport type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;\n\nexport const PromptInputTabLabel = ({\n  className,\n  ...props\n}: PromptInputTabLabelProps) => (\n  <h3\n    className={cn(\n      \"mb-2 px-3 font-medium text-muted-foreground text-xs\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTabBody = ({\n  className,\n  ...props\n}: PromptInputTabBodyProps) => (\n  <div className={cn(\"space-y-1\", className)} {...props} />\n);\n\nexport type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTabItem = ({\n  className,\n  ...props\n}: PromptInputTabItemProps) => (\n  <div\n    className={cn(\n      \"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type PromptInputCommandProps = ComponentProps<typeof Command>;\n\nexport const PromptInputCommand = ({\n  className,\n  ...props\n}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;\n\nexport type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;\n\nexport const PromptInputCommandInput = ({\n  className,\n  ...props\n}: PromptInputCommandInputProps) => (\n  <CommandInput className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandListProps = ComponentProps<typeof CommandList>;\n\nexport const PromptInputCommandList = ({\n  className,\n  ...props\n}: PromptInputCommandListProps) => (\n  <CommandList className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;\n\nexport const PromptInputCommandEmpty = ({\n  className,\n  ...props\n}: PromptInputCommandEmptyProps) => (\n  <CommandEmpty className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;\n\nexport const PromptInputCommandGroup = ({\n  className,\n  ...props\n}: PromptInputCommandGroupProps) => (\n  <CommandGroup className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;\n\nexport const PromptInputCommandItem = ({\n  className,\n  ...props\n}: PromptInputCommandItemProps) => (\n  <CommandItem className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandSeparatorProps = ComponentProps<\n  typeof CommandSeparator\n>;\n\nexport const PromptInputCommandSeparator = ({\n  className,\n  ...props\n}: PromptInputCommandSeparatorProps) => (\n  <CommandSeparator className={cn(className)} {...props} />\n);\n"
  },
  {
    "path": "web/src/components/ai-elements/reasoning.tsx",
    "content": "\"use client\";\n\nimport { useControllableState } from \"@radix-ui/react-use-controllable-state\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { cn } from \"@/lib/utils\";\nimport type { ComponentProps } from \"react\";\nimport { createContext, memo, useContext, useEffect, useState } from \"react\";\nimport { Streamdown } from \"streamdown\";\nimport { ChevronRightIcon, SparklesIcon } from \"lucide-react\";\nimport { Shimmer } from \"./shimmer\";\nimport {\n  escapeHtmlOutsideCodeBlocks,\n  safeRehypePlugins,\n  safeRemarkPlugins,\n  streamdownComponents,\n  streamdownRootClass,\n} from \"./streamdown\";\n\ntype ReasoningContextValue = {\n  isStreaming: boolean;\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n  duration: number | undefined;\n};\n\nconst ReasoningContext = createContext<ReasoningContextValue | null>(null);\n\nconst useReasoning = () => {\n  const context = useContext(ReasoningContext);\n  if (!context) {\n    throw new Error(\"Reasoning components must be used within Reasoning\");\n  }\n  return context;\n};\n\nexport type ReasoningProps = ComponentProps<typeof Collapsible> & {\n  isStreaming?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  duration?: number;\n  /** Disable auto-close behavior when streaming ends */\n  disableAutoClose?: boolean;\n};\n\nconst AUTO_CLOSE_DELAY = 1000;\nconst MS_IN_S = 1000;\n\nexport const Reasoning = memo(\n  ({\n    className,\n    isStreaming = false,\n    open,\n    defaultOpen = true,\n    onOpenChange,\n    duration: durationProp,\n    disableAutoClose = false,\n    children,\n    ...props\n  }: ReasoningProps) => {\n    const [isOpen, setIsOpen] = useControllableState({\n      prop: open,\n      defaultProp: defaultOpen,\n      onChange: onOpenChange,\n    });\n    const [duration, setDuration] = useControllableState({\n      prop: durationProp,\n      defaultProp: undefined,\n    });\n\n    const [hasAutoClosed, setHasAutoClosed] = useState(false);\n    const [startTime, setStartTime] = useState<number | null>(null);\n\n    // Track duration when streaming starts and ends\n    useEffect(() => {\n      if (isStreaming) {\n        if (startTime === null) {\n          setStartTime(Date.now());\n        }\n      } else if (startTime !== null) {\n        setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));\n        setStartTime(null);\n      }\n    }, [isStreaming, startTime, setDuration]);\n\n    // Auto-open when streaming starts, auto-close when streaming ends (once only)\n    useEffect(() => {\n      if (\n        !disableAutoClose &&\n        defaultOpen &&\n        !isStreaming &&\n        isOpen &&\n        !hasAutoClosed\n      ) {\n        // Add a small delay before closing to allow user to see the content\n        const timer = setTimeout(() => {\n          setIsOpen(false);\n          setHasAutoClosed(true);\n        }, AUTO_CLOSE_DELAY);\n\n        return () => clearTimeout(timer);\n      }\n    }, [\n      isStreaming,\n      isOpen,\n      defaultOpen,\n      setIsOpen,\n      hasAutoClosed,\n      disableAutoClose,\n    ]);\n\n    const handleOpenChange = (newOpen: boolean) => {\n      setIsOpen(newOpen);\n    };\n\n    return (\n      <ReasoningContext.Provider\n        value={{ isStreaming, isOpen, setIsOpen, duration }}\n      >\n        <Collapsible\n          className={cn(\"not-prose mb-2\", className)}\n          onOpenChange={handleOpenChange}\n          open={isOpen}\n          {...props}\n        >\n          {children}\n        </Collapsible>\n      </ReasoningContext.Provider>\n    );\n  },\n);\n\nexport type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;\n\nconst getThinkingLabel = (isStreaming: boolean, duration?: number) => {\n  if (isStreaming || duration === 0) {\n    return (\n      <>\n        Thinking\n        <Shimmer as=\"span\" duration={1} className=\"text-muted-foreground ml-0.5\">\n          ...\n        </Shimmer>\n      </>\n    );\n  }\n  if (duration === undefined) {\n    return \"Thought\";\n  }\n  return `Thought for ${duration}s`;\n};\n\nexport const ReasoningTrigger = memo(\n  ({ className, children, ...props }: ReasoningTriggerProps) => {\n    const { isStreaming, isOpen, duration } = useReasoning();\n\n    return (\n      <CollapsibleTrigger\n        className={cn(\n          \"flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer\",\n          className,\n        )}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <SparklesIcon\n              className={cn(\n                \"size-3.5 shrink-0 transition-colors\",\n                isStreaming\n                  ? \"text-amber-500 dark:text-amber-400 animate-pulse\"\n                  : \"text-muted-foreground/60\",\n              )}\n            />\n            <span className={cn(\"italic\", isStreaming && \"text-foreground/70\")}>\n              {getThinkingLabel(isStreaming, duration)}\n            </span>\n            <ChevronRightIcon\n              className={cn(\n                \"size-3 text-muted-foreground/50 transition-transform duration-200\",\n                isOpen && \"rotate-90\",\n              )}\n            />\n          </>\n        )}\n      </CollapsibleTrigger>\n    );\n  },\n);\n\nexport type ReasoningContentProps = ComponentProps<\n  typeof CollapsibleContent\n> & {\n  children: string;\n};\n\nexport const ReasoningContent = memo(\n  ({ className, children, ...props }: ReasoningContentProps) => {\n    const escaped = escapeHtmlOutsideCodeBlocks(children);\n    return (\n      <CollapsibleContent\n        className={cn(\n          \"pl-4 mt-1.5 text-sm text-muted-foreground border-l-2 border-border\",\n          \"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in\",\n          className,\n        )}\n        {...props}\n      >\n        <Streamdown\n          className={streamdownRootClass}\n          components={streamdownComponents}\n          rehypePlugins={safeRehypePlugins}\n          remarkPlugins={safeRemarkPlugins}\n        >\n          {escaped}\n        </Streamdown>\n      </CollapsibleContent>\n    );\n  },\n);\n\nReasoning.displayName = \"Reasoning\";\nReasoningTrigger.displayName = \"ReasoningTrigger\";\nReasoningContent.displayName = \"ReasoningContent\";\n"
  },
  {
    "path": "web/src/components/ai-elements/shimmer.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ElementType,\n  type JSX,\n  memo,\n  useMemo,\n} from \"react\";\n\nexport type TextShimmerProps = {\n  children: string;\n  as?: ElementType;\n  className?: string;\n  duration?: number;\n  spread?: number;\n};\n\nconst ShimmerComponent = ({\n  children,\n  as: Component = \"p\",\n  className,\n  duration = 2,\n  spread = 2,\n}: TextShimmerProps) => {\n  const MotionComponent = motion.create(\n    Component as keyof JSX.IntrinsicElements,\n  );\n\n  const dynamicSpread = useMemo(\n    () => (children?.length ?? 0) * spread,\n    [children, spread],\n  );\n\n  return (\n    <MotionComponent\n      animate={{ backgroundPosition: \"0% center\" }}\n      className={cn(\n        \"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent\",\n        \"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]\",\n        className,\n      )}\n      initial={{ backgroundPosition: \"100% center\" }}\n      style={\n        {\n          \"--spread\": `${dynamicSpread}px`,\n          backgroundImage:\n            \"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))\",\n        } as CSSProperties\n      }\n      transition={{\n        repeat: Number.POSITIVE_INFINITY,\n        duration,\n        ease: \"linear\",\n      }}\n    >\n      {children}\n    </MotionComponent>\n  );\n};\n\nexport const Shimmer = memo(ShimmerComponent);\n"
  },
  {
    "path": "web/src/components/ai-elements/streamdown.tsx",
    "content": "import { cn } from \"@/lib/utils\";\nimport type { Element } from \"hast\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { isValidElement } from \"react\";\nimport type { StreamdownProps } from \"streamdown\";\nimport { defaultRehypePlugins, defaultRemarkPlugins } from \"streamdown\";\nimport { CodeBlock } from \"./code-block\";\n\n// Selectively enable rehype plugins while maintaining security.\n// - 'raw': renders raw HTML embedded in markdown (XSS risk - DISABLED)\n// - 'harden': sanitizes HTML (strips tags - DISABLED)\n// - 'katex': math rendering (SAFE - only processes math delimiters)\nexport const safeRehypePlugins: StreamdownProps[\"rehypePlugins\"] = [\n  defaultRehypePlugins.katex,\n];\n\n// Override remark-math to enable single-dollar inline math ($...$).\n// Streamdown defaults to singleDollarTextMath: false, which only allows\n// block math ($$...$$). We enable it so both $...$ and $$...$$ work.\nconst mathPlugin = defaultRemarkPlugins.math;\nconst remarkMathWithInline = (\n  Array.isArray(mathPlugin)\n    ? [mathPlugin[0], { ...mathPlugin[1], singleDollarTextMath: true }]\n    : [mathPlugin, { singleDollarTextMath: true }]\n) as typeof mathPlugin;\n\nexport const safeRemarkPlugins: StreamdownProps[\"remarkPlugins\"] = [\n  defaultRemarkPlugins.gfm,\n  remarkMathWithInline,\n  defaultRemarkPlugins.cjkFriendly,\n  defaultRemarkPlugins.cjkFriendlyGfmStrikethrough,\n];\n\n/**\n * Escape HTML-like tags outside of code blocks to prevent XSS and preserve\n * markdown structure. HTML tags like <script>, <img>, etc. break markdown\n * parsing (especially numbered lists) because remark treats them as HTML nodes.\n *\n * This function:\n * 1. Preserves content inside code blocks (```...``` and `...`)\n * 2. Preserves content inside math delimiters ($...$ and $$...$$)\n * 3. Escapes both < and > in HTML-like tags elsewhere\n */\nexport const escapeHtmlOutsideCodeBlocks = (text: string): string => {\n  // Match regions that should NOT be escaped:\n  // 1. Fenced code blocks: ``` at line start, optional language, content, then \\n```\n  // 2. Inline code: `..` (single backticks, no newlines inside)\n  // 3. Display math: $$...$$ (can span multiple lines)\n  // 4. Inline math: $...$ (no newlines, non-empty, no leading/trailing space)\n  const codeBlockRegex =\n    /(^|\\n)```[a-z]*\\n[\\s\\S]*?\\n```|`[^`\\n]+`|\\$\\$[\\s\\S]*?\\$\\$|\\$(?!\\s)[^$\\n]+(?<!\\s)\\$/g;\n  const codeBlocks: { start: number; end: number }[] = [];\n\n  // Find all valid code blocks\n  let match;\n  while ((match = codeBlockRegex.exec(text)) !== null) {\n    // Adjust start position if match includes leading newline\n    const startsWithNewline = match[0].startsWith(\"\\n\");\n    const start = startsWithNewline ? match.index + 1 : match.index;\n    codeBlocks.push({ start, end: match.index + match[0].length });\n  }\n\n  // Escape content outside code blocks to prevent markdown from misinterpreting it.\n  const escapeForMarkdown = (str: string): string => {\n    // 1. Escape HTML-like tags with fullwidth Unicode equivalents (＜ and ＞)\n    //    which look nearly identical but won't be parsed as HTML tags.\n    let result = str.replace(/<(?=[a-zA-Z/!?])/g, \"＜\");\n    // Exclude line-start > (blockquotes) and arrows (-> and =>)\n    result = result.replace(/(?<!^)(?<![-=])>/gm, \"＞\");\n\n    // 2. Prevent indented code blocks: insert zero-width space after newline\n    //    before 4+ spaces. This breaks the CommonMark indented code block pattern.\n    result = result.replace(/\\n([ ]{4,})/g, \"\\n\\u200B$1\");\n\n    return result;\n  };\n\n  // Process text, escaping content outside code blocks\n  const result: string[] = [];\n  let lastEnd = 0;\n\n  for (const block of codeBlocks) {\n    // Process text before this code block\n    const before = text.slice(lastEnd, block.start);\n    result.push(escapeForMarkdown(before));\n    // Keep code block unchanged\n    result.push(text.slice(block.start, block.end));\n    lastEnd = block.end;\n  }\n\n  // Process remaining text after last code block\n  const after = text.slice(lastEnd);\n  result.push(escapeForMarkdown(after));\n\n  return result.join(\"\");\n};\n\n// Prevent Streamdown margins from collapsing, which can break Virtuoso height measurement.\nexport const streamdownRootClass = [\n  \"flow-root\",\n  \"[&_p]:m-0\",\n  \"[&_h1]:m-0\",\n  \"[&_h2]:m-0\",\n  \"[&_h3]:m-0\",\n  \"[&_h4]:m-0\",\n  \"[&_h5]:m-0\",\n  \"[&_h6]:m-0\",\n  \"[&_ul]:m-0\",\n  \"[&_ol]:m-0\",\n  \"[&_li]:m-0\",\n  \"[&_blockquote]:m-0\",\n  \"[&_hr]:m-0\",\n  \"[&_pre]:m-0\",\n].join(\" \");\n\nconst LANGUAGE_CLASS_RE = /language-([^\\s]+)/;\n\nconst getCodeLanguage = (className?: string): string | undefined => {\n  const match = className?.match(LANGUAGE_CLASS_RE);\n  return match?.[1];\n};\n\nconst getCodeText = (children: ReactNode): string => {\n  if (typeof children === \"string\") {\n    return children;\n  }\n  if (Array.isArray(children)) {\n    return children.map(getCodeText).join(\"\");\n  }\n  if (isValidElement<{ children?: ReactNode }>(children)) {\n    return getCodeText(children.props.children);\n  }\n  return \"\";\n};\n\ntype StreamdownCodeProps = ComponentProps<\"code\"> & { node?: Element };\n\nconst StreamdownCode = ({\n  className,\n  children,\n  node,\n  ...props\n}: StreamdownCodeProps) => {\n  const isInline = node?.position?.start?.line === node?.position?.end?.line;\n\n  if (isInline) {\n    return (\n      <code\n        className={cn(\n          \"rounded bg-secondary px-1 py-0.5 font-mono text-xs\",\n          className,\n        )}\n        data-streamdown=\"inline-code\"\n        {...props}\n      >\n        {children}\n      </code>\n    );\n  }\n\n  return (\n    <CodeBlock\n      className={cn(\"my-2\", className)}\n      code={getCodeText(children)}\n      language={getCodeLanguage(className)}\n      {...props}\n    />\n  );\n};\n\nconst StreamdownPre = ({ children }: ComponentProps<\"pre\">) => children;\n\nexport const streamdownComponents: StreamdownProps[\"components\"] = {\n  code: StreamdownCode,\n  pre: StreamdownPre,\n};\n"
  },
  {
    "path": "web/src/components/ai-elements/subagent-steps.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { SubagentStep } from \"@/hooks/types\";\nimport type { ComponentProps } from \"react\";\nimport { memo, useState } from \"react\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { Shimmer } from \"./shimmer\";\nimport {\n  CheckIcon,\n  ChevronRightIcon,\n  Loader2Icon,\n  XIcon,\n} from \"lucide-react\";\n\n// ---------------------------------------------------------------------------\n// SubagentActivity — top-level wrapper rendered inside Tool's ToolContent area\n// ---------------------------------------------------------------------------\n\nexport type SubagentActivityProps = ComponentProps<\"div\"> & {\n  steps: SubagentStep[];\n  isRunning?: boolean;\n  defaultOpen?: boolean;\n};\n\nexport const SubagentActivity = memo(\n  ({\n    className,\n    steps,\n    isRunning = false,\n    defaultOpen = false,\n    ...props\n  }: SubagentActivityProps) => {\n    const [isOpen, setIsOpen] = useState(defaultOpen);\n\n    const toolCallCount = steps.filter((s) => s.kind === \"tool-call\").length;\n    const hasError = steps.some(\n      (s) => s.kind === \"tool-call\" && s.status === \"error\",\n    );\n\n    return (\n      <Collapsible\n        className={cn(\"mt-2\", className)}\n        open={isOpen}\n        onOpenChange={setIsOpen}\n        {...props}\n      >\n        <CollapsibleTrigger className=\"flex items-center gap-1.5 text-xs text-muted-foreground group cursor-pointer\">\n          <span\n            className={cn(\n              \"size-1.5 rounded-full shrink-0\",\n              isRunning\n                ? \"bg-blue-500 animate-pulse\"\n                : hasError\n                  ? \"bg-destructive\"\n                  : \"bg-success\",\n            )}\n          />\n          <span>\n            {isRunning ? (\n              <>\n                Agent working\n                <Shimmer\n                  as=\"span\"\n                  duration={1}\n                  className=\"text-muted-foreground ml-0.5\"\n                >\n                  ...\n                </Shimmer>\n              </>\n            ) : toolCallCount > 0 ? (\n              `Agent completed · ${toolCallCount} tool call${toolCallCount !== 1 ? \"s\" : \"\"}`\n            ) : (\n              \"Agent completed\"\n            )}\n          </span>\n          <ChevronRightIcon\n            className={cn(\n              \"size-3 text-muted-foreground transition-transform duration-200\",\n              isOpen && \"rotate-90\",\n            )}\n          />\n        </CollapsibleTrigger>\n\n        <CollapsibleContent\n          className={cn(\n            \"mt-1.5 space-y-0.5 border-l-2 border-border pl-3\",\n            \"data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-1 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in\",\n          )}\n        >\n          {steps.map((step, index) => (\n            <SubagentStepItem key={`sa-step-${index}`} step={step} />\n          ))}\n        </CollapsibleContent>\n      </Collapsible>\n    );\n  },\n);\n\nSubagentActivity.displayName = \"SubagentActivity\";\n\n// ---------------------------------------------------------------------------\n// SubagentStepItem — renders a single step based on kind\n// ---------------------------------------------------------------------------\n\nconst SubagentStepItem = ({ step }: { step: SubagentStep }) => {\n  switch (step.kind) {\n    case \"thinking\":\n      return (\n        <div className=\"text-muted-foreground/60 italic text-xs line-clamp-2\">\n          {step.text}\n        </div>\n      );\n\n    case \"text\":\n      return (\n        <div className=\"text-foreground/70 text-xs line-clamp-2\">\n          {step.text}\n        </div>\n      );\n\n    case \"tool-call\":\n      return <SubToolCallItem step={step} />;\n\n    default:\n      return null;\n  }\n};\n\n// ---------------------------------------------------------------------------\n// SubToolCallItem — expandable sub-tool-call with status + output\n// ---------------------------------------------------------------------------\n\n/** Extract a primary parameter value for inline display */\nconst getPrimaryParam = (input: unknown): string | null => {\n  if (!input || typeof input !== \"object\") return null;\n  const keys = [\"path\", \"command\", \"pattern\", \"url\", \"query\", \"file_path\"];\n  for (const key of keys) {\n    const val = (input as Record<string, unknown>)[key];\n    if (typeof val === \"string\" && val.length > 0) {\n      return val.length > 50 ? `${val.slice(0, 50)}…` : val;\n    }\n  }\n  return null;\n};\n\nconst getSubToolStatusIcon = (status: string) => {\n  switch (status) {\n    case \"success\":\n      return <CheckIcon className=\"size-2.5 text-success shrink-0\" />;\n    case \"error\":\n      return <XIcon className=\"size-2.5 text-destructive shrink-0\" />;\n    default:\n      return <Loader2Icon className=\"size-2.5 text-muted-foreground animate-spin shrink-0\" />;\n  }\n};\n\nconst SubToolCallItem = ({\n  step,\n}: {\n  step: Extract<SubagentStep, { kind: \"tool-call\" }>;\n}) => {\n  const [expanded, setExpanded] = useState(false);\n  const primaryParam = getPrimaryParam(step.input);\n  const hasExpandableContent = Boolean(step.output || step.errorText);\n\n  return (\n    <div>\n      <div\n        className={cn(\n          \"flex items-center gap-1 text-xs\",\n          hasExpandableContent && \"cursor-pointer\",\n        )}\n        onClick={() => hasExpandableContent && setExpanded(!expanded)}\n        onKeyDown={(e) => {\n          if (hasExpandableContent && (e.key === \"Enter\" || e.key === \" \")) {\n            e.preventDefault();\n            setExpanded(!expanded);\n          }\n        }}\n        role={hasExpandableContent ? \"button\" : undefined}\n        tabIndex={hasExpandableContent ? 0 : undefined}\n      >\n        {getSubToolStatusIcon(step.status)}\n        <span className=\"text-primary/80 font-medium\">{step.toolName}</span>\n        {primaryParam && !expanded && (\n          <span className=\"text-muted-foreground truncate\">\n            ({primaryParam})\n          </span>\n        )}\n        {hasExpandableContent && (\n          <ChevronRightIcon\n            className={cn(\n              \"size-2.5 text-muted-foreground/50 transition-transform duration-200\",\n              expanded && \"rotate-90\",\n            )}\n          />\n        )}\n      </div>\n      {expanded && (\n        <div className=\"ml-4 mt-0.5\">\n          {step.errorText && (\n            <pre className=\"text-xs text-destructive whitespace-pre-wrap max-h-24 overflow-y-auto\">\n              {step.errorText}\n            </pre>\n          )}\n          {step.output && !step.errorText && (\n            <pre className=\"text-xs text-foreground/60 whitespace-pre-wrap max-h-24 overflow-y-auto\">\n              {step.output.length > 500\n                ? `${step.output.slice(0, 500)}…`\n                : step.output}\n            </pre>\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "web/src/components/ai-elements/tool.tsx",
    "content": "\"use client\";\n\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\nimport type { ToolUIPart } from \"ai\";\nimport {\n  BotIcon,\n  BrainIcon,\n  CheckIcon,\n  ChevronRightIcon,\n  FileIcon,\n  FilePenIcon,\n  FolderSearchIcon,\n  GlobeIcon,\n  ImageOffIcon,\n  LinkIcon,\n  ListChecksIcon,\n  Loader2Icon,\n  MailIcon,\n  MinusIcon,\n  SearchIcon,\n  TerminalIcon,\n  ImageIcon,\n  WorkflowIcon,\n  XIcon,\n} from \"lucide-react\";\nimport type { ComponentProps, JSX, MouseEvent, ReactNode } from \"react\";\nimport { createContext, isValidElement, useCallback, useMemo, useState } from \"react\";\nimport { isMacOS } from \"@/hooks/utils\";\nimport { useVideoThumbnail } from \"@/hooks/useVideoThumbnail\";\nimport { CodeBlock } from \"./code-block\";\nimport {\n  DisplayContent,\n  type DisplayItem,\n} from \"@/features/tool/components/display-content\";\n\nexport type ToolProps = ComponentProps<typeof Collapsible>;\n\ntype ToolContextValue = {\n  isOpen: boolean;\n};\n\nconst ToolContext = createContext<ToolContextValue>({ isOpen: false });\n\nexport const Tool = ({ className, defaultOpen, ...props }: ToolProps) => (\n  <ToolContext.Provider value={{ isOpen: defaultOpen ?? false }}>\n    <Collapsible\n      className={cn(\"not-prose mb-1 w-full text-sm\", className)}\n      defaultOpen={defaultOpen}\n      {...props}\n    />\n  </ToolContext.Provider>\n);\n\n/** Extended tool state that includes approval states beyond the base ToolUIPart[\"state\"] */\nexport type ToolState =\n  | ToolUIPart[\"state\"]\n  | \"approval-requested\"\n  | \"approval-responded\"\n  | \"question-requested\"\n  | \"question-responded\"\n  | \"output-denied\";\n\nconst getStatusIcon = (status: ToolState): ReactNode => {\n  switch (status) {\n    case \"input-streaming\":\n    case \"input-available\":\n      return <Loader2Icon className=\"size-3 text-muted-foreground animate-spin\" />;\n    case \"approval-requested\":\n    case \"question-requested\":\n      return <Loader2Icon className=\"size-3 text-warning animate-spin\" />;\n    case \"approval-responded\":\n    case \"question-responded\":\n    case \"output-available\":\n      return <CheckIcon className=\"size-3 text-success\" />;\n    case \"output-error\":\n      return <XIcon className=\"size-3 text-destructive\" />;\n    case \"output-denied\":\n      return <MinusIcon className=\"size-3 text-warning\" />;\n    default:\n      return null;\n  }\n};\n\n/** Get primary parameter value for inline display */\nconst getPrimaryParam = (input: ToolUIPart[\"input\"]): string | null => {\n  if (!input || typeof input !== \"object\") return null;\n  const entries = Object.entries(input as Record<string, unknown>);\n  if (entries.length === 0) return null;\n\n  // Priority order: path, command, pattern, url, query, then first param\n  const priorityKeys = [\"path\", \"command\", \"pattern\", \"url\", \"query\"];\n  for (const key of priorityKeys) {\n    const value = (input as Record<string, unknown>)[key];\n    if (typeof value === \"string\" && value.length > 0) {\n      return value.length > 50 ? `${value.slice(0, 50)}…` : value;\n    }\n  }\n\n  // Fall back to first string param\n  const firstString = entries.find(([, v]) => typeof v === \"string\");\n  if (firstString) {\n    const value = firstString[1] as string;\n    return value.length > 50 ? `${value.slice(0, 50)}…` : value;\n  }\n\n  return null;\n};\n\n/** Map backend tool names to lucide icons */\nconst TOOL_ICONS: Record<string, ReactNode> = {\n  ReadFile: <FileIcon className=\"size-3.5\" />,\n  ReadMediaFile: <ImageIcon className=\"size-3.5\" />,\n  WriteFile: <FilePenIcon className=\"size-3.5\" />,\n  StrReplaceFile: <FilePenIcon className=\"size-3.5\" />,\n  Glob: <FolderSearchIcon className=\"size-3.5\" />,\n  Grep: <SearchIcon className=\"size-3.5\" />,\n  Shell: <TerminalIcon className=\"size-3.5\" />,\n  SearchWeb: <GlobeIcon className=\"size-3.5\" />,\n  FetchURL: <LinkIcon className=\"size-3.5\" />,\n  Task: <WorkflowIcon className=\"size-3.5\" />,\n  CreateSubagent: <BotIcon className=\"size-3.5\" />,\n  Think: <BrainIcon className=\"size-3.5\" />,\n  SetTodoList: <ListChecksIcon className=\"size-3.5\" />,\n  SendDMail: <MailIcon className=\"size-3.5\" />,\n};\n\n/** Map backend tool names to human-readable display names */\nconst TOOL_DISPLAY_NAMES: Record<string, string> = {\n  ReadFile: \"Read\",\n  ReadMediaFile: \"Read Media\",\n  WriteFile: \"Write\",\n  StrReplaceFile: \"Edit\",\n  Glob: \"Find Files\",\n  Grep: \"Search\",\n  Shell: \"Shell\",\n  SearchWeb: \"Web Search\",\n  FetchURL: \"Fetch URL\",\n  Task: \"Agent Task\",\n  CreateSubagent: \"Create Agent\",\n  Think: \"Think\",\n  SetTodoList: \"Todo List\",\n  SendDMail: \"Send Mail\",\n};\n\nexport type ToolHeaderProps = {\n  title?: string;\n  type: ToolUIPart[\"type\"];\n  state: ToolState;\n  input?: ToolUIPart[\"input\"];\n  className?: string;\n};\n\nexport const ToolHeader = ({\n  className,\n  title,\n  type,\n  state,\n  input,\n  ...props\n}: ToolHeaderProps) => {\n  const rawName = title ?? type.split(\"-\").slice(1).join(\"-\");\n  const displayName = TOOL_DISPLAY_NAMES[rawName] ?? rawName;\n  const icon = TOOL_ICONS[rawName];\n  const primaryParam = getPrimaryParam(input);\n\n  const fullUrl =\n    rawName === \"FetchURL\" &&\n    input &&\n    typeof input === \"object\" &&\n    typeof (input as Record<string, unknown>).url === \"string\"\n      ? ((input as Record<string, unknown>).url as string)\n      : null;\n\n  const handleParamClick = useCallback(\n    (e: MouseEvent) => {\n      if (fullUrl && (e.metaKey || e.ctrlKey)) {\n        e.stopPropagation();\n        e.preventDefault();\n        window.open(fullUrl, \"_blank\", \"noopener,noreferrer\");\n      }\n    },\n    [fullUrl],\n  );\n\n  return (\n    <CollapsibleTrigger\n      className={cn(\"flex items-center gap-1.5 text-sm group\", className)}\n      {...props}\n    >\n      {icon ? (\n        <span className=\"text-muted-foreground shrink-0\">{icon}</span>\n      ) : (\n        <span className=\"size-2 rounded-full bg-muted-foreground/60 shrink-0\" />\n      )}\n      <span className=\"text-primary font-medium\">{displayName}</span>\n      {/* Hide params when expanded via CSS data-state selector */}\n      {primaryParam && (\n        <span\n          className={cn(\n            \"text-muted-foreground group-data-[state=open]:hidden\",\n            fullUrl && \"cursor-pointer hover:underline\",\n          )}\n          title={fullUrl ? (isMacOS() ? \"⌘+Click to open URL\" : \"Ctrl+Click to open URL\") : undefined}\n          onClick={fullUrl ? handleParamClick : undefined}\n        >\n          ({primaryParam})\n        </span>\n      )}\n      <span className=\"ml-0.5\">{getStatusIcon(state)}</span>\n    </CollapsibleTrigger>\n  );\n};\n\nexport type ToolDisplayProps = ComponentProps<\"div\"> & {\n  display?: DisplayItem[];\n  isError?: boolean;\n};\n\n/** Display content shown outside the collapsible area (always visible) */\nexport const ToolDisplay = ({\n  className,\n  display,\n  isError,\n  ...props\n}: ToolDisplayProps): JSX.Element | null => {\n  if (!display || display.length === 0) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\"mt-1 pl-4\", isError && \"text-destructive\", className)}\n      {...props}\n    >\n      <DisplayContent display={display} />\n    </div>\n  );\n};\n\nexport type ToolContentProps = ComponentProps<typeof CollapsibleContent>;\n\nexport const ToolContent = ({ className, ...props }: ToolContentProps) => (\n  <CollapsibleContent\n    className={cn(\n      \"pl-4 mt-1 text-sm\",\n      \"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type ToolInputProps = ComponentProps<\"div\"> & {\n  input: ToolUIPart[\"input\"];\n};\n\ntype TreeParam = {\n  key: string;\n  value: string;\n  fullValue: string;\n  isTruncated: boolean;\n  valueType: \"string\" | \"boolean\" | \"number\" | \"object\";\n  isLast: boolean;\n};\n\n// ANSI escape code stripping\nconst ANSI_REGEX =\n  /[\\x1b\\x9b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><~]/g;\nconst stripAnsi = (s: string): string => s.replace(ANSI_REGEX, \"\");\n\n// CodeBlock language inference from parameter key\nconst inferLanguage = (key: string): string => {\n  const map: Record<string, string> = {\n    command: \"bash\",\n    content: \"text\",\n    code: \"text\",\n    old_string: \"text\",\n    new_string: \"text\",\n  };\n  return map[key] ?? \"text\";\n};\n\n// Classify whether a param should render as short (inline) or long (expandable)\nconst isShortParam = (param: TreeParam): boolean => {\n  if (param.valueType === \"boolean\" || param.valueType === \"number\") return true;\n  if (param.valueType === \"object\") return false;\n  const raw = stripAnsi(param.fullValue);\n  return raw.length <= 120 && !raw.includes(\"\\n\");\n};\n\n/** Format tool input as structured parameters */\nconst formatTreeParams = (input: ToolUIPart[\"input\"]): TreeParam[] => {\n  if (!input || typeof input !== \"object\") {\n    return [];\n  }\n\n  const entries = Object.entries(input as Record<string, unknown>);\n  return entries.map(([key, value], index) => {\n    let displayValue: string;\n    let fullValue: string;\n    let isTruncated = false;\n    let valueType: TreeParam[\"valueType\"] = \"string\";\n\n    if (typeof value === \"string\") {\n      const clean = stripAnsi(value);\n      fullValue = clean;\n      displayValue = clean;\n      if (clean.length > 120 || clean.includes(\"\\n\")) {\n        isTruncated = true;\n      }\n    } else if (typeof value === \"boolean\") {\n      valueType = \"boolean\";\n      displayValue = String(value);\n      fullValue = displayValue;\n    } else if (typeof value === \"number\") {\n      valueType = \"number\";\n      displayValue = String(value);\n      fullValue = displayValue;\n    } else if (typeof value === \"object\" && value !== null) {\n      valueType = \"object\";\n      fullValue = JSON.stringify(value, null, 2);\n      displayValue = JSON.stringify(value);\n      isTruncated = true;\n    } else {\n      displayValue = String(value);\n      fullValue = displayValue;\n    }\n    return {\n      key,\n      value: displayValue,\n      fullValue,\n      isTruncated,\n      valueType,\n      isLast: index === entries.length - 1,\n    };\n  });\n};\n\nconst getValueColorClass = (valueType: TreeParam[\"valueType\"]): string => {\n  switch (valueType) {\n    case \"boolean\":\n      return \"text-blue-500 dark:text-blue-400\";\n    case \"number\":\n      return \"text-amber-600 dark:text-amber-400\";\n    default:\n      return \"text-foreground/80\";\n  }\n};\n\nconst ShortParam = ({ param }: { param: TreeParam }) => (\n  <div className=\"flex items-baseline gap-2\">\n    <span className=\"text-muted-foreground shrink-0 select-none\">\n      {param.key}\n    </span>\n    <span className={getValueColorClass(param.valueType)}>\n      {param.valueType === \"string\" ? (\n        <>\n          <span className=\"text-muted-foreground/50\">&quot;</span>\n          {param.value}\n          <span className=\"text-muted-foreground/50\">&quot;</span>\n        </>\n      ) : (\n        param.value\n      )}\n    </span>\n  </div>\n);\n\nconst LongParam = ({ param }: { param: TreeParam }) => {\n  const [expanded, setExpanded] = useState(false);\n  const language =\n    param.valueType === \"object\" ? \"json\" : inferLanguage(param.key);\n  const cleanValue =\n    param.valueType === \"object\" ? param.fullValue : stripAnsi(param.fullValue);\n  const preview = cleanValue.split(\"\\n\")[0].slice(0, 80);\n\n  return (\n    <div className=\"space-y-1\">\n      <div\n        className=\"flex items-baseline gap-2 cursor-pointer group\"\n        onClick={() => setExpanded(!expanded)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" || e.key === \" \") {\n            e.preventDefault();\n            setExpanded(!expanded);\n          }\n        }}\n        role=\"button\"\n        tabIndex={0}\n      >\n        <span className=\"text-muted-foreground shrink-0 select-none\">\n          {param.key}\n        </span>\n        <ChevronRightIcon\n          className={cn(\n            \"size-3 shrink-0 text-muted-foreground transition-transform duration-200\",\n            expanded && \"rotate-90\",\n          )}\n        />\n        {!expanded && (\n          <span className=\"text-foreground/40 truncate group-hover:text-foreground/60\">\n            {preview}\n            {cleanValue.length > 80 ? \"...\" : \"\"}\n          </span>\n        )}\n      </div>\n      {expanded && (\n        <div className=\"ml-4\">\n          <CodeBlock code={cleanValue} language={language} />\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport const ToolInput = ({ className, input, ...props }: ToolInputProps) => {\n  const params = useMemo(() => formatTreeParams(input), [input]);\n\n  if (params.length === 0) {\n    return null;\n  }\n\n  const shortParams = params.filter(isShortParam);\n  const longParams = params.filter((p) => !isShortParam(p));\n\n  return (\n    <div className={cn(\"space-y-1 text-xs font-mono\", className)} {...props}>\n      {shortParams.length > 0 && (\n        <div className=\"space-y-0.5\">\n          {shortParams.map((p) => (\n            <ShortParam key={p.key} param={p} />\n          ))}\n        </div>\n      )}\n      {longParams.map((p) => (\n        <LongParam key={p.key} param={p} />\n      ))}\n    </div>\n  );\n};\n\nexport type MediaPart = { type: \"image_url\" | \"video_url\"; url: string };\n\nexport type ToolMediaPreviewProps = ComponentProps<\"div\"> & {\n  mediaParts?: MediaPart[];\n};\n\nconst ALLOWED_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"data:\", \"blob:\"]);\n\nconst isAllowedMediaUrl = (url: string): boolean => {\n  if (url.startsWith(\"/\")) return true;\n  try {\n    const parsed = new URL(url);\n    return ALLOWED_URL_PROTOCOLS.has(parsed.protocol);\n  } catch {\n    return false;\n  }\n};\n\n/** Single media thumbnail tile — mirrors MessageAttachment style */\nconst MediaTile = ({\n  part,\n  onPreview,\n}: {\n  part: MediaPart;\n  onPreview: (part: MediaPart) => void;\n}) => {\n  const [error, setError] = useState(false);\n  const isVideo = part.type === \"video_url\";\n  const videoPoster = useVideoThumbnail(isVideo ? part.url : undefined);\n  const typeBadge = isVideo ? \"Video\" : \"Image\";\n\n  return (\n    <div\n      className=\"group relative size-24 overflow-hidden rounded-lg border border-border cursor-zoom-in\"\n      onClick={() => onPreview(part)}\n      onKeyDown={(e) => {\n        if (e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          onPreview(part);\n        }\n      }}\n      role=\"button\"\n      tabIndex={0}\n    >\n      {error ? (\n        <div className=\"size-full flex items-center justify-center bg-muted\">\n          <ImageOffIcon className=\"size-5 text-muted-foreground\" />\n        </div>\n      ) : isVideo ? (\n        <video\n          className=\"size-full object-cover\"\n          height={160}\n          poster={videoPoster ?? undefined}\n          preload=\"metadata\"\n          src={part.url}\n          width={160}\n          muted\n          playsInline\n          onError={() => setError(true)}\n        />\n      ) : (\n        <img\n          alt=\"Tool output\"\n          className=\"size-full object-cover\"\n          height={160}\n          src={part.url}\n          width={160}\n          onError={() => setError(true)}\n        />\n      )}\n      <span className=\"pointer-events-none absolute bottom-2 right-2 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white shadow-sm\">\n        {typeBadge}\n      </span>\n    </div>\n  );\n};\n\n/** Preview media content (images/videos) returned by tool results */\nexport const ToolMediaPreview = ({\n  className,\n  mediaParts,\n  ...props\n}: ToolMediaPreviewProps): JSX.Element | null => {\n  const [previewPart, setPreviewPart] = useState<MediaPart | null>(null);\n  const [previewError, setPreviewError] = useState(false);\n  const previewPoster = useVideoThumbnail(\n    previewPart?.type === \"video_url\" ? previewPart.url : undefined,\n  );\n\n  const safeParts = useMemo(\n    () => mediaParts?.filter((part) => isAllowedMediaUrl(part.url)) ?? [],\n    [mediaParts],\n  );\n\n  const openPreview = useCallback((part: MediaPart) => {\n    setPreviewError(false);\n    setPreviewPart(part);\n  }, []);\n\n  const closePreview = useCallback((open: boolean) => {\n    if (!open) setPreviewPart(null);\n  }, []);\n\n  if (safeParts.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className={cn(\"mt-1 ml-4 flex flex-wrap gap-2\", className)} {...props}>\n      {safeParts.map((part, index) => (\n        <MediaTile\n          key={`media-${index}`}\n          part={part}\n          onPreview={openPreview}\n        />\n      ))}\n      <Dialog open={previewPart !== null} onOpenChange={closePreview}>\n        <DialogContent\n          className=\"max-w-[min(95vw,1100px)] overflow-hidden p-0 sm:max-w-[min(95vw,1100px)]\"\n          showCloseButton\n        >\n          <DialogHeader className=\"sr-only\">\n            <DialogTitle>Media preview</DialogTitle>\n          </DialogHeader>\n          <div className=\"bg-background\">\n            {previewError ? (\n              <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground\">\n                <ImageOffIcon className=\"size-8\" />\n                <span className=\"text-sm\">Failed to load media</span>\n              </div>\n            ) : previewPart?.type === \"image_url\" ? (\n              <img\n                alt=\"Full size preview\"\n                className=\"block max-h-[88vh] w-full object-contain\"\n                src={previewPart.url}\n                onError={() => setPreviewError(true)}\n              />\n            ) : previewPart?.type === \"video_url\" ? (\n              <video\n                className=\"block max-h-[88vh] w-full object-contain\"\n                src={previewPart.url}\n                controls\n                poster={previewPoster ?? undefined}\n                autoPlay\n                playsInline\n                onError={() => setPreviewError(true)}\n              />\n            ) : null}\n          </div>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n\nexport type ToolOutputProps = ComponentProps<\"div\"> & {\n  output: ToolUIPart[\"output\"];\n  errorText: ToolUIPart[\"errorText\"];\n  message?: string;\n};\n\nexport const ToolOutput = ({\n  className,\n  output,\n  errorText,\n  message,\n  ...props\n}: ToolOutputProps): JSX.Element | null => {\n  const hasOutput = Boolean(output || errorText);\n  const hasMessage = Boolean(message);\n\n  if (!hasOutput && !hasMessage) {\n    return null;\n  }\n\n  const isError = Boolean(errorText);\n\n  let OutputContent: ReactNode = null;\n  if (hasOutput) {\n    let Output = <div className=\"text-sm\">{output as ReactNode}</div>;\n\n    if (typeof output === \"object\" && !isValidElement(output)) {\n      Output = (\n        <CodeBlock code={JSON.stringify(output, null, 2)} language=\"json\" />\n      );\n    } else if (typeof output === \"string\") {\n      if (output.length > 200) {\n        Output = <CodeBlock code={output} language=\"text\" />;\n      } else {\n        Output = (\n          <pre className=\"whitespace-pre-wrap text-xs text-foreground/80\">\n            {output}\n          </pre>\n        );\n      }\n    }\n\n    OutputContent = (\n      <div className=\"text-xs font-mono\">\n        <span className={isError ? \"text-destructive\" : \"text-muted-foreground\"}>\n          {isError ? \"error:\" : \"result:\"}\n        </span>\n        <div\n          className={cn(\n            \"ml-4 mt-0.5 rounded text-xs\",\n            isError ? \"text-destructive\" : \"\",\n          )}\n        >\n          {errorText && <div className=\"text-destructive\">{errorText}</div>}\n          {Output}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"mt-1 space-y-1\", className)} {...props}>\n      {OutputContent}\n      {hasMessage && (\n        <div className=\"text-xs font-mono\">\n          <span className=\"text-muted-foreground\">message:</span>\n          <div className=\"ml-4 mt-0.5 text-xs text-foreground/80\">\n            {message}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "web/src/components/error-boundary.tsx",
    "content": "import { useState } from \"react\";\nimport { ErrorBoundary as ReactErrorBoundary, type FallbackProps } from \"react-error-boundary\";\nimport { AlertTriangle, RefreshCw, Copy, Check } from \"lucide-react\";\nimport { Button } from \"./ui/button\";\n\nfunction ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {\n  const [copied, setCopied] = useState(false);\n\n  const copyError = async () => {\n    const errorObj = error instanceof Error ? error : new Error(String(error));\n    const text = `${errorObj.name}: ${errorObj.message}\\n\\n${errorObj.stack ?? \"\"}`;\n    await navigator.clipboard.writeText(text);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  const errorMessage = error instanceof Error ? error.message : String(error);\n  const errorStack = error instanceof Error ? error.stack : undefined;\n\n  return (\n    <div className=\"flex h-screen w-full items-center justify-center bg-background\">\n      <div className=\"flex max-w-md flex-col items-center gap-4 rounded-lg border border-destructive/20 bg-destructive/5 p-8 text-center\">\n        <AlertTriangle className=\"h-12 w-12 text-destructive\" />\n        <h2 className=\"text-xl font-semibold text-foreground\">\n          Something went wrong\n        </h2>\n        <p className=\"text-sm text-muted-foreground\">\n          {errorMessage || \"An unexpected error occurred\"}\n        </p>\n        {import.meta.env.DEV && errorStack && (\n          <pre className=\"max-h-40 w-full overflow-auto rounded bg-muted p-2 text-left text-xs\">\n            {errorStack}\n          </pre>\n        )}\n        <div className=\"flex gap-2\">\n          <Button onClick={copyError} variant=\"outline\">\n            {copied ? (\n              <Check className=\"mr-2 h-4 w-4\" />\n            ) : (\n              <Copy className=\"mr-2 h-4 w-4\" />\n            )}\n            {copied ? \"Copied\" : \"Copy error\"}\n          </Button>\n          <Button onClick={resetErrorBoundary} variant=\"outline\">\n            <RefreshCw className=\"mr-2 h-4 w-4\" />\n            Try again\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function ErrorBoundary({ children }: { children: React.ReactNode }) {\n  return (\n    <ReactErrorBoundary\n      FallbackComponent={ErrorFallback}\n      onReset={() => {\n        // Optionally reload the page or reset app state\n        window.location.reload();\n      }}\n      onError={(error, info) => {\n        // Log error to console in development\n        console.error(\"ErrorBoundary caught an error:\", error, info);\n        // TODO: Send to error tracking service in production\n      }}\n    >\n      {children}\n    </ReactErrorBoundary>\n  );\n}\n"
  },
  {
    "path": "web/src/components/kimi-cli-brand.tsx",
    "content": "import { kimiCliVersion } from \"@/lib/version\";\nimport { cn } from \"@/lib/utils\";\n\ntype KimiCliBrandProps = {\n  className?: string;\n  size?: \"sm\" | \"md\";\n  showVersion?: boolean;\n};\n\nexport function KimiCliBrand({\n  className,\n  size = \"md\",\n  showVersion = true,\n}: KimiCliBrandProps) {\n  const textSizeClass = size === \"sm\" ? \"text-base\" : \"text-lg\";\n  const versionPadding = size === \"sm\" ? \"text-xs\" : \"text-sm\";\n  const logoSize = size === \"sm\" ? \"size-6\" : \"size-7\";\n  const logoPx = size === \"sm\" ? 24 : 28;\n\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      <a\n        href=\"https://www.kimi.com/code\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"flex items-center gap-2 hover:opacity-80 transition-opacity\"\n      >\n        <img\n          src=\"/logo.png\"\n          alt=\"Kimi\"\n          width={logoPx}\n          height={logoPx}\n          className={logoSize}\n        />\n        <span className={cn(textSizeClass, \"font-semibold text-foreground\")}>\n          Kimi Code\n        </span>\n      </a>\n      {showVersion && (\n        <span\n          className={cn(\"text-muted-foreground font-medium\", versionPadding)}\n        >\n          v{kimiCliVersion}\n        </span>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/components/ui/alert-dialog.tsx",
    "content": "import * as React from \"react\"\nimport { AlertDialog as AlertDialogPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n  )\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n  )\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogContent({\n  className,\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {\n  size?: \"default\" | \"sm\"\n}) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        data-size={size}\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  )\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\n        \"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\n        \"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogMedia({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-media\"\n      className={cn(\n        \"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogAction({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &\n  Pick<React.ComponentProps<typeof Button>, \"variant\" | \"size\">) {\n  return (\n    <Button variant={variant} size={size} asChild>\n      <AlertDialogPrimitive.Action\n        data-slot=\"alert-dialog-action\"\n        className={cn(className)}\n        {...props}\n      />\n    </Button>\n  )\n}\n\nfunction AlertDialogCancel({\n  className,\n  variant = \"outline\",\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &\n  Pick<React.ComponentProps<typeof Button>, \"variant\" | \"size\">) {\n  return (\n    <Button variant={variant} size={size} asChild>\n      <AlertDialogPrimitive.Cancel\n        data-slot=\"alert-dialog-cancel\"\n        className={cn(className)}\n        {...props}\n      />\n    </Button>\n  )\n}\n\nexport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogMedia,\n  AlertDialogOverlay,\n  AlertDialogPortal,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n}\n"
  },
  {
    "path": "web/src/components/ui/alert.tsx",
    "content": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst alertVariants = cva(\n  \"relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-card text-card-foreground\",\n        destructive:\n          \"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nfunction Alert({\n  className,\n  variant,\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof alertVariants>) {\n  return (\n    <div\n      data-slot=\"alert\"\n      role=\"alert\"\n      className={cn(alertVariants({ variant }), className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-title\"\n      className={cn(\n        \"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDescription({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-description\"\n      className={cn(\n        \"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Alert, AlertTitle, AlertDescription };\n"
  },
  {
    "path": "web/src/components/ui/badge.tsx",
    "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst badgeVariants = cva(\n  \"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nfunction Badge({\n  className,\n  variant,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"span\"> &\n  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"span\";\n\n  return (\n    <Comp\n      data-slot=\"badge\"\n      className={cn(badgeVariants({ variant }), className)}\n      {...props}\n    />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "web/src/components/ui/button-group.tsx",
    "content": "import { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Separator } from \"@/components/ui/separator\";\n\nconst buttonGroupVariants = cva(\n  \"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2\",\n  {\n    variants: {\n      orientation: {\n        horizontal:\n          \"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none\",\n        vertical:\n          \"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none\",\n      },\n    },\n    defaultVariants: {\n      orientation: \"horizontal\",\n    },\n  },\n);\n\nfunction ButtonGroup({\n  className,\n  orientation,\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof buttonGroupVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"button-group\"\n      data-orientation={orientation}\n      className={cn(buttonGroupVariants({ orientation }), className)}\n      {...props}\n    />\n  );\n}\n\nfunction ButtonGroupText({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  asChild?: boolean;\n}) {\n  const Comp = asChild ? Slot : \"div\";\n\n  return (\n    <Comp\n      className={cn(\n        \"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ButtonGroupSeparator({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"button-group-separator\"\n      orientation={orientation}\n      className={cn(\n        \"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  ButtonGroup,\n  ButtonGroupSeparator,\n  ButtonGroupText,\n  buttonGroupVariants,\n};\n"
  },
  {
    "path": "web/src/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        xs: \"h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3\",\n        sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n        \"icon-xs\": \"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3\",\n        \"icon-sm\": \"size-8\",\n        \"icon-lg\": \"size-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Button({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  const Comp = asChild ? Slot.Root : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "web/src/components/ui/card.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        \"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn(\"leading-none font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-content\"\n      className={cn(\"px-6\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn(\"flex items-center px-6 [.border-t]:pt-6\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "web/src/components/ui/carousel.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport useEmblaCarousel, {\n  type UseEmblaCarouselType,\n} from \"embla-carousel-react\";\nimport { ArrowLeft, ArrowRight } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\n\ntype CarouselApi = UseEmblaCarouselType[1];\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>;\ntype CarouselOptions = UseCarouselParameters[0];\ntype CarouselPlugin = UseCarouselParameters[1];\n\ntype CarouselProps = {\n  opts?: CarouselOptions;\n  plugins?: CarouselPlugin;\n  orientation?: \"horizontal\" | \"vertical\";\n  setApi?: (api: CarouselApi) => void;\n};\n\ntype CarouselContextProps = {\n  carouselRef: ReturnType<typeof useEmblaCarousel>[0];\n  api: ReturnType<typeof useEmblaCarousel>[1];\n  scrollPrev: () => void;\n  scrollNext: () => void;\n  canScrollPrev: boolean;\n  canScrollNext: boolean;\n} & CarouselProps;\n\nconst CarouselContext = React.createContext<CarouselContextProps | null>(null);\n\nfunction useCarousel() {\n  const context = React.useContext(CarouselContext);\n\n  if (!context) {\n    throw new Error(\"useCarousel must be used within a <Carousel />\");\n  }\n\n  return context;\n}\n\nfunction Carousel({\n  orientation = \"horizontal\",\n  opts,\n  setApi,\n  plugins,\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & CarouselProps) {\n  const [carouselRef, api] = useEmblaCarousel(\n    {\n      ...opts,\n      axis: orientation === \"horizontal\" ? \"x\" : \"y\",\n    },\n    plugins,\n  );\n  const [canScrollPrev, setCanScrollPrev] = React.useState(false);\n  const [canScrollNext, setCanScrollNext] = React.useState(false);\n\n  const onSelect = React.useCallback((api: CarouselApi) => {\n    if (!api) return;\n    setCanScrollPrev(api.canScrollPrev());\n    setCanScrollNext(api.canScrollNext());\n  }, []);\n\n  const scrollPrev = React.useCallback(() => {\n    api?.scrollPrev();\n  }, [api]);\n\n  const scrollNext = React.useCallback(() => {\n    api?.scrollNext();\n  }, [api]);\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        scrollPrev();\n      } else if (event.key === \"ArrowRight\") {\n        event.preventDefault();\n        scrollNext();\n      }\n    },\n    [scrollPrev, scrollNext],\n  );\n\n  React.useEffect(() => {\n    if (!api || !setApi) return;\n    setApi(api);\n  }, [api, setApi]);\n\n  React.useEffect(() => {\n    if (!api) return;\n    onSelect(api);\n    api.on(\"reInit\", onSelect);\n    api.on(\"select\", onSelect);\n\n    return () => {\n      api?.off(\"select\", onSelect);\n    };\n  }, [api, onSelect]);\n\n  return (\n    <CarouselContext.Provider\n      value={{\n        carouselRef,\n        api: api,\n        opts,\n        orientation:\n          orientation || (opts?.axis === \"y\" ? \"vertical\" : \"horizontal\"),\n        scrollPrev,\n        scrollNext,\n        canScrollPrev,\n        canScrollNext,\n      }}\n    >\n      <div\n        onKeyDownCapture={handleKeyDown}\n        className={cn(\"relative\", className)}\n        role=\"region\"\n        aria-roledescription=\"carousel\"\n        data-slot=\"carousel\"\n        {...props}\n      >\n        {children}\n      </div>\n    </CarouselContext.Provider>\n  );\n}\n\nfunction CarouselContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { carouselRef, orientation } = useCarousel();\n\n  return (\n    <div\n      ref={carouselRef}\n      className=\"overflow-hidden\"\n      data-slot=\"carousel-content\"\n    >\n      <div\n        className={cn(\n          \"flex\",\n          orientation === \"horizontal\" ? \"-ml-4\" : \"-mt-4 flex-col\",\n          className,\n        )}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction CarouselItem({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { orientation } = useCarousel();\n\n  return (\n    <div\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      data-slot=\"carousel-item\"\n      className={cn(\n        \"min-w-0 shrink-0 grow-0 basis-full\",\n        orientation === \"horizontal\" ? \"pl-4\" : \"pt-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CarouselPrevious({\n  className,\n  variant = \"outline\",\n  size = \"icon\",\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { orientation, scrollPrev, canScrollPrev } = useCarousel();\n\n  return (\n    <Button\n      data-slot=\"carousel-previous\"\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute size-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"top-1/2 -left-12 -translate-y-1/2\"\n          : \"-top-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className,\n      )}\n      disabled={!canScrollPrev}\n      onClick={scrollPrev}\n      {...props}\n    >\n      <ArrowLeft />\n      <span className=\"sr-only\">Previous slide</span>\n    </Button>\n  );\n}\n\nfunction CarouselNext({\n  className,\n  variant = \"outline\",\n  size = \"icon\",\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { orientation, scrollNext, canScrollNext } = useCarousel();\n\n  return (\n    <Button\n      data-slot=\"carousel-next\"\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute size-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"top-1/2 -right-12 -translate-y-1/2\"\n          : \"-bottom-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className,\n      )}\n      disabled={!canScrollNext}\n      onClick={scrollNext}\n      {...props}\n    >\n      <ArrowRight />\n      <span className=\"sr-only\">Next slide</span>\n    </Button>\n  );\n}\n\nexport {\n  type CarouselApi,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselPrevious,\n  CarouselNext,\n};\n"
  },
  {
    "path": "web/src/components/ui/checkbox.tsx",
    "content": "import * as React from \"react\"\nimport { CheckIcon } from \"lucide-react\"\nimport { Checkbox as CheckboxPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Checkbox({\n  className,\n  ...props\n}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n  return (\n    <CheckboxPrimitive.Root\n      data-slot=\"checkbox\"\n      className={cn(\n        \"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        data-slot=\"checkbox-indicator\"\n        className=\"grid place-content-center text-current transition-none\"\n      >\n        <CheckIcon className=\"size-3.5\" />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  )\n}\n\nexport { Checkbox }\n"
  },
  {
    "path": "web/src/components/ui/collapsible.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\";\n\nfunction Collapsible({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />;\n}\n\nfunction CollapsibleTrigger({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleTrigger\n      data-slot=\"collapsible-trigger\"\n      {...props}\n      className={cn(props.className, \"cursor-pointer\")}\n    />\n  );\n}\n\nfunction CollapsibleContent({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleContent\n      data-slot=\"collapsible-content\"\n      {...props}\n    />\n  );\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent };\n"
  },
  {
    "path": "web/src/components/ui/command.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { SearchIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\n\nfunction Command({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive>) {\n  return (\n    <CommandPrimitive\n      data-slot=\"command\"\n      className={cn(\n        \"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandDialog({\n  title = \"Command Palette\",\n  description = \"Search for a command to run...\",\n  children,\n  className,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof Dialog> & {\n  title?: string\n  description?: string\n  className?: string\n  showCloseButton?: boolean\n}) {\n  return (\n    <Dialog {...props}>\n      <DialogHeader className=\"sr-only\">\n        <DialogTitle>{title}</DialogTitle>\n        <DialogDescription>{description}</DialogDescription>\n      </DialogHeader>\n      <DialogContent\n        className={cn(\"overflow-hidden p-0\", className)}\n        showCloseButton={showCloseButton}\n      >\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nfunction CommandInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Input>) {\n  return (\n    <div\n      data-slot=\"command-input-wrapper\"\n      className=\"flex h-9 items-center gap-2 border-b px-3\"\n    >\n      <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n      <CommandPrimitive.Input\n        data-slot=\"command-input\"\n        className={cn(\n          \"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction CommandList({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.List>) {\n  return (\n    <CommandPrimitive.List\n      data-slot=\"command-list\"\n      className={cn(\n        \"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandEmpty({\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n  return (\n    <CommandPrimitive.Empty\n      data-slot=\"command-empty\"\n      className=\"py-6 text-center text-sm\"\n      {...props}\n    />\n  )\n}\n\nfunction CommandGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Group>) {\n  return (\n    <CommandPrimitive.Group\n      data-slot=\"command-group\"\n      className={cn(\n        \"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n  return (\n    <CommandPrimitive.Separator\n      data-slot=\"command-separator\"\n      className={cn(\"bg-border -mx-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CommandItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Item>) {\n  return (\n    <CommandPrimitive.Item\n      data-slot=\"command-item\"\n      className={cn(\n        \"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"command-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n}\n"
  },
  {
    "path": "web/src/components/ui/context-menu.tsx",
    "content": "import * as React from \"react\"\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\"\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ContextMenu({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n  return <ContextMenuPrimitive.Root data-slot=\"context-menu\" {...props} />\n}\n\nfunction ContextMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {\n  return (\n    <ContextMenuPrimitive.Trigger data-slot=\"context-menu-trigger\" {...props} />\n  )\n}\n\nfunction ContextMenuGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {\n  return (\n    <ContextMenuPrimitive.Group data-slot=\"context-menu-group\" {...props} />\n  )\n}\n\nfunction ContextMenuPortal({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {\n  return (\n    <ContextMenuPrimitive.Portal data-slot=\"context-menu-portal\" {...props} />\n  )\n}\n\nfunction ContextMenuSub({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {\n  return <ContextMenuPrimitive.Sub data-slot=\"context-menu-sub\" {...props} />\n}\n\nfunction ContextMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {\n  return (\n    <ContextMenuPrimitive.RadioGroup\n      data-slot=\"context-menu-radio-group\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.SubTrigger\n      data-slot=\"context-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto\" />\n    </ContextMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction ContextMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {\n  return (\n    <ContextMenuPrimitive.SubContent\n      data-slot=\"context-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n  return (\n    <ContextMenuPrimitive.Portal>\n      <ContextMenuPrimitive.Content\n        data-slot=\"context-menu-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md\",\n          className\n        )}\n        {...props}\n      />\n    </ContextMenuPrimitive.Portal>\n  )\n}\n\nfunction ContextMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <ContextMenuPrimitive.Item\n      data-slot=\"context-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {\n  return (\n    <ContextMenuPrimitive.CheckboxItem\n      data-slot=\"context-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction ContextMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {\n  return (\n    <ContextMenuPrimitive.RadioItem\n      data-slot=\"context-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.RadioItem>\n  )\n}\n\nfunction ContextMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.Label\n      data-slot=\"context-menu-label\"\n      data-inset={inset}\n      className={cn(\n        \"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {\n  return (\n    <ContextMenuPrimitive.Separator\n      data-slot=\"context-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"context-menu-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n}\n"
  },
  {
    "path": "web/src/components/ui/dialog.tsx",
    "content": "import * as React from \"react\"\nimport { XIcon } from \"lucide-react\"\nimport { Dialog as DialogPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nfunction Dialog({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />\n}\n\nfunction DialogTrigger({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\nfunction DialogPortal({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />\n}\n\nfunction DialogClose({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean\n}) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            data-slot=\"dialog-close\"\n            className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n          >\n            <XIcon />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  )\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DialogFooter({\n  className,\n  showCloseButton = false,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  showCloseButton?: boolean\n}) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {showCloseButton && (\n        <DialogPrimitive.Close asChild>\n          <Button variant=\"outline\">Close</Button>\n        </DialogPrimitive.Close>\n      )}\n    </div>\n  )\n}\n\nfunction DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn(\"text-lg leading-none font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n}\n"
  },
  {
    "path": "web/src/components/ui/diff/index.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { refractor } from \"refractor/all\";\nimport \"./theme.css\";\nimport { ChevronsUpDown } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  guessLang,\n  type Hunk as HunkType,\n  type SkipBlock,\n  type File,\n  type Line as LineType,\n} from \"./utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                                — Context —                                 */\n/* -------------------------------------------------------------------------- */\n\ninterface DiffContextValue {\n  language: string;\n}\n\nconst DiffContext = React.createContext<DiffContextValue | null>(null);\n\nfunction useDiffContext() {\n  const context = React.useContext(DiffContext);\n  if (!context) {\n    throw new Error(\"useDiffContext must be used within a Diff component\");\n  }\n  return context;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                — Helpers —                                 */\n/* -------------------------------------------------------------------------- */\n\nfunction hastToReact(\n  node: ReturnType<typeof refractor.highlight>[\"children\"][number],\n  key: string,\n): React.ReactNode {\n  if (node.type === \"text\") return node.value;\n  if (node.type === \"element\") {\n    const { tagName, properties, children } = node;\n    return React.createElement(\n      tagName,\n      {\n        key,\n        className: (properties.className as string[] | undefined)?.join(\" \"),\n      },\n      children.map((c, i) => hastToReact(c, `${key}-${i}`)),\n    );\n  }\n  return null;\n}\n\nfunction highlight(code: string, lang: string): React.ReactNode[] {\n  const id = `${lang}:${code}`;\n  const tree = refractor.highlight(code, lang);\n  const nodes = tree.children.map((c, i) => hastToReact(c, `${id}-${i}`));\n  return nodes;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               — Root —                                     */\n/* -------------------------------------------------------------------------- */\nexport interface DiffSelectionRange {\n  startLine: number;\n  endLine: number;\n}\n\nexport interface DiffProps\n  extends\n    React.TableHTMLAttributes<HTMLTableElement>,\n    Pick<File, \"hunks\" | \"type\"> {\n  fileName?: string;\n  language?: string;\n}\n\nexport const Hunk = ({ hunk }: { hunk: HunkType | SkipBlock }) => {\n  return hunk.type === \"hunk\" ? (\n    <>\n      {hunk.lines.map((line, index) => (\n        <Line key={index} line={line} />\n      ))}\n    </>\n  ) : (\n    <SkipBlockRow lines={hunk.count} content={hunk.content} />\n  );\n};\n\nexport const Diff: React.FC<DiffProps> = ({\n  fileName,\n  language = guessLang(fileName),\n  hunks,\n  className,\n  children,\n  ...props\n}) => {\n  return (\n    <DiffContext.Provider value={{ language }}>\n      <table\n        {...props}\n        className={cn(\n          \"[--code-added:var(--color-green-500)] [--code-removed:var(--color-orange-600)] font-mono text-[0.8rem] w-full m-0 border-separate border-0 outline-none overflow-x-auto border-spacing-0\",\n          className,\n        )}\n      >\n        <tbody className=\"w-full box-border\">\n          {children ??\n            hunks.map((hunk, index) => <Hunk key={index} hunk={hunk} />)}\n        </tbody>\n      </table>\n    </DiffContext.Provider>\n  );\n};\n\nconst SkipBlockRow: React.FC<{\n  lines: number;\n  content?: string;\n}> = ({ lines, content }) => (\n  <>\n    <tr className=\"h-4\" />\n    <tr className={cn(\"h-10 font-mono bg-muted text-muted-foreground\")}>\n      <td />\n      <td className=\"opacity-50 select-none\">\n        <ChevronsUpDown className=\"size-4 mx-auto\" />\n      </td>\n      <td>\n        <span className=\"px-0 sticky left-2 italic opacity-50\">\n          {content || `${lines} lines hidden`}\n        </span>\n      </td>\n    </tr>\n    <tr className=\"h-4\" />\n  </>\n);\n\nconst Line: React.FC<{\n  line: LineType;\n}> = ({ line }) => {\n  const { language } = useDiffContext();\n  const Tag =\n    line.type === \"insert\" ? \"ins\" : line.type === \"delete\" ? \"del\" : \"span\";\n  const lineNumberNew =\n    line.type === \"normal\" ? line.newLineNumber : line.lineNumber;\n  const lineNumberOld = line.type === \"normal\" ? line.oldLineNumber : undefined;\n\n  return (\n    <tr\n      data-line-new={lineNumberNew ?? undefined}\n      data-line-old={lineNumberOld ?? undefined}\n      data-line-kind={line.type}\n      className={cn(\"whitespace-pre-wrap box-border border-none h-5 min-h-5\", {\n        \"bg-[var(--code-added)]/10\": line.type === \"insert\",\n        \"bg-[var(--code-removed)]/10\": line.type === \"delete\",\n      })}\n    >\n      <td\n        className={cn(\"border-transparent w-1 border-l-3\", {\n          \"border-[color:var(--code-added)]/60\": line.type === \"insert\",\n          \"border-[color:var(--code-removed)]/80\": line.type === \"delete\",\n        })}\n      />\n      <td className=\"tabular-nums text-center opacity-50 px-2 text-xs select-none\">\n        {line.type === \"delete\" ? \"–\" : lineNumberNew}\n      </td>\n      <td className=\"text-nowrap pr-6\">\n        <Tag>\n          {line.content.map((seg, i) => (\n            <span\n              key={i}\n              className={cn({\n                \"bg-[var(--code-added)]/20\": seg.type === \"insert\",\n                \"bg-[var(--code-removed)]/20\": seg.type === \"delete\",\n              })}\n            >\n              {highlight(seg.value, language).map((n, idx) => (\n                <React.Fragment key={idx}>{n}</React.Fragment>\n              ))}\n            </span>\n          ))}\n        </Tag>\n      </td>\n    </tr>\n  );\n};\n"
  },
  {
    "path": "web/src/components/ui/diff/lazy.tsx",
    "content": "import { lazy } from \"react\";\nimport type { ComponentType } from \"react\";\nimport type { DiffProps } from \"./index\";\nimport type { Hunk as HunkType, SkipBlock } from \"./utils\";\n\ntype DiffModule = typeof import(\"./index\");\n\nlet diffModulePromise: Promise<DiffModule> | null = null;\n\nconst loadDiffModule = async (): Promise<DiffModule> => {\n  if (!diffModulePromise) {\n    diffModulePromise = import(\"./index\");\n  }\n  return diffModulePromise;\n};\n\nexport const LazyDiff = lazy(\n  async (): Promise<{ default: ComponentType<DiffProps> }> => {\n    const module = await loadDiffModule();\n    return { default: module.Diff };\n  },\n);\n\nexport const LazyHunk = lazy(\n  async (): Promise<{\n    default: ComponentType<{ hunk: HunkType | SkipBlock }>;\n  }> => {\n    const module = await loadDiffModule();\n    return { default: module.Hunk };\n  },\n);\n"
  },
  {
    "path": "web/src/components/ui/diff/theme.css",
    "content": ":root {\n  /**\n * One Light theme for prism.js\n * Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax\n */\n\n  /**\n * One Light colours (accurate as of commit eb064bf on 19 Feb 2021)\n * From colors.less\n * --mono-1: hsl(230, 8%, 24%);\n * --mono-2: hsl(230, 6%, 44%);\n * --mono-3: hsl(230, 4%, 64%)\n * --hue-1: hsl(198, 99%, 37%);\n * --hue-2: hsl(221, 87%, 60%);\n * --hue-3: hsl(301, 63%, 40%);\n * --hue-4: hsl(119, 34%, 47%);\n * --hue-5: hsl(5, 74%, 59%);\n * --hue-5-2: hsl(344, 84%, 43%);\n * --hue-6: hsl(35, 99%, 36%);\n * --hue-6-2: hsl(35, 99%, 40%);\n * --syntax-fg: hsl(230, 8%, 24%);\n * --syntax-bg: hsl(230, 1%, 98%);\n * --syntax-gutter: hsl(230, 1%, 62%);\n * --syntax-guide: hsla(230, 8%, 24%, 0.2);\n * --syntax-accent: hsl(230, 100%, 66%);\n * From syntax-variables.less\n * --syntax-selection-color: hsl(230, 1%, 90%);\n * --syntax-gutter-background-color-selected: hsl(230, 1%, 90%);\n * --syntax-cursor-line: hsla(230, 8%, 24%, 0.05);\n */\n\n  code[class*=\"language-\"],\n  pre[class*=\"language-\"] {\n    background: hsl(230, 1%, 98%);\n    color: hsl(230, 8%, 24%);\n    font-family:\n      \"Fira Code\", \"Fira Mono\", Menlo, Consolas, \"DejaVu Sans Mono\", monospace;\n    direction: ltr;\n    text-align: left;\n    white-space: pre;\n    word-spacing: normal;\n    word-break: normal;\n    line-height: 1.5;\n    -moz-tab-size: 2;\n    -o-tab-size: 2;\n    tab-size: 2;\n    -webkit-hyphens: none;\n    -moz-hyphens: none;\n    -ms-hyphens: none;\n    hyphens: none;\n  }\n\n  /* Selection */\n  code[class*=\"language-\"]::-moz-selection,\n  code[class*=\"language-\"] *::-moz-selection,\n  pre[class*=\"language-\"] *::-moz-selection {\n    background: hsl(230, 1%, 90%);\n    color: inherit;\n  }\n\n  code[class*=\"language-\"]::selection,\n  code[class*=\"language-\"] *::selection,\n  pre[class*=\"language-\"] *::selection {\n    background: hsl(230, 1%, 90%);\n    color: inherit;\n  }\n\n  /* Code blocks */\n  pre[class*=\"language-\"] {\n    padding: 1em;\n    margin: 0.5em 0;\n    overflow: auto;\n    border-radius: 0.3em;\n  }\n\n  /* Inline code */\n  :not(pre) > code[class*=\"language-\"] {\n    padding: 0.2em 0.3em;\n    border-radius: 0.3em;\n    white-space: normal;\n  }\n\n  .token.comment,\n  .token.prolog,\n  .token.cdata {\n    color: hsl(230, 4%, 64%);\n  }\n\n  .token.doctype,\n  .token.punctuation,\n  .token.entity {\n    color: hsl(230, 8%, 24%);\n  }\n\n  .token.attr-name,\n  .token.class-name,\n  .token.boolean,\n  .token.constant,\n  .token.number,\n  .token.atrule {\n    color: hsl(35, 99%, 36%);\n  }\n\n  .token.keyword {\n    color: hsl(301, 63%, 40%);\n  }\n\n  .token.property,\n  .token.tag,\n  .token.symbol,\n  .token.deleted,\n  .token.important {\n    color: hsl(5, 74%, 59%);\n  }\n\n  .token.selector,\n  .token.string,\n  .token.char,\n  .token.builtin,\n  .token.inserted,\n  .token.regex,\n  .token.attr-value,\n  .token.attr-value > .token.punctuation {\n    color: hsl(119, 34%, 47%);\n  }\n\n  ins,\n  del {\n    text-decoration: none;\n  }\n\n  .token.variable,\n  .token.operator,\n  .token.function {\n    color: hsl(221, 87%, 60%);\n  }\n\n  .token.url {\n    color: hsl(198, 99%, 37%);\n  }\n\n  /* HTML overrides */\n  .token.attr-value > .token.punctuation.attr-equals,\n  .token.special-attr > .token.attr-value > .token.value.css {\n    color: hsl(230, 8%, 24%);\n  }\n\n  /* CSS overrides */\n  .language-css .token.selector {\n    color: hsl(5, 74%, 59%);\n  }\n\n  .language-css .token.property {\n    color: hsl(230, 8%, 24%);\n  }\n\n  .language-css .token.function,\n  .language-css .token.url > .token.function {\n    color: hsl(198, 99%, 37%);\n  }\n\n  .language-css .token.url > .token.string.url {\n    color: hsl(119, 34%, 47%);\n  }\n\n  .language-css .token.important,\n  .language-css .token.atrule .token.rule {\n    color: hsl(301, 63%, 40%);\n  }\n\n  /* JS overrides */\n  .language-javascript .token.operator {\n    color: hsl(301, 63%, 40%);\n  }\n\n  .language-javascript\n    .token.template-string\n    > .token.interpolation\n    > .token.interpolation-punctuation.punctuation {\n    color: hsl(344, 84%, 43%);\n  }\n\n  /* JSON overrides */\n  .language-json .token.operator {\n    color: hsl(230, 8%, 24%);\n  }\n\n  .language-json .token.null.keyword {\n    color: hsl(35, 99%, 36%);\n  }\n\n  /* MD overrides */\n  .language-markdown .token.url,\n  .language-markdown .token.url > .token.operator,\n  .language-markdown .token.url-reference.url > .token.string {\n    color: hsl(230, 8%, 24%);\n  }\n\n  .language-markdown .token.url > .token.content {\n    color: hsl(221, 87%, 60%);\n  }\n\n  .language-markdown .token.url > .token.url,\n  .language-markdown .token.url-reference.url {\n    color: hsl(198, 99%, 37%);\n  }\n\n  .language-markdown .token.blockquote.punctuation,\n  .language-markdown .token.hr.punctuation {\n    color: hsl(230, 4%, 64%);\n    font-style: italic;\n  }\n\n  .language-markdown .token.code-snippet {\n    color: hsl(119, 34%, 47%);\n  }\n\n  .language-markdown .token.bold .token.content {\n    color: hsl(35, 99%, 36%);\n  }\n\n  .language-markdown .token.italic .token.content {\n    color: hsl(301, 63%, 40%);\n  }\n\n  .language-markdown .token.strike .token.content,\n  .language-markdown .token.strike .token.punctuation,\n  .language-markdown .token.list.punctuation,\n  .language-markdown .token.title.important > .token.punctuation {\n    color: hsl(5, 74%, 59%);\n  }\n\n  /* General */\n  .token.bold {\n    font-weight: bold;\n  }\n\n  .token.comment,\n  .token.italic {\n    font-style: italic;\n  }\n\n  .token.entity {\n    cursor: help;\n  }\n\n  .token.namespace {\n    opacity: 0.8;\n  }\n\n  /* Plugin overrides */\n  /* Selectors should have higher specificity than those in the plugins' default stylesheets */\n\n  /* Show Invisibles plugin overrides */\n  .token.token.tab:not(:empty):before,\n  .token.token.cr:before,\n  .token.token.lf:before,\n  .token.token.space:before {\n    color: hsla(230, 8%, 24%, 0.2);\n  }\n\n  /* Toolbar plugin overrides */\n  /* Space out all buttons and move them away from the right edge of the code block */\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item {\n    margin-right: 0.4em;\n  }\n\n  /* Styling the buttons */\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {\n    background: hsl(230, 1%, 90%);\n    color: hsl(230, 6%, 44%);\n    padding: 0.1em 0.4em;\n    border-radius: 0.3em;\n  }\n\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {\n    background: hsl(230, 1%, 78%); /* custom: darken(--syntax-bg, 20%) */\n    color: hsl(230, 8%, 24%);\n  }\n\n  /* Line Highlight plugin overrides */\n  /* The highlighted line itself */\n  .line-highlight.line-highlight {\n    background: hsla(230, 8%, 24%, 0.05);\n  }\n\n  /* Default line numbers in Line Highlight plugin */\n  .line-highlight.line-highlight:before,\n  .line-highlight.line-highlight[data-end]:after {\n    background: hsl(230, 1%, 90%);\n    color: hsl(230, 8%, 24%);\n    padding: 0.1em 0.6em;\n    border-radius: 0.3em;\n    box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */\n  }\n\n  /* Hovering over a linkable line number (in the gutter area) */\n  /* Requires Line Numbers plugin as well */\n  pre[id].linkable-line-numbers.linkable-line-numbers\n    span.line-numbers-rows\n    > span:hover:before {\n    background-color: hsla(230, 8%, 24%, 0.05);\n  }\n\n  /* Line Numbers and Command Line plugins overrides */\n  /* Line separating gutter from coding area */\n  .line-numbers.line-numbers .line-numbers-rows,\n  .command-line .command-line-prompt {\n    border-right-color: hsla(230, 8%, 24%, 0.2);\n  }\n\n  /* Stuff in the gutter */\n  .line-numbers .line-numbers-rows > span:before,\n  .command-line .command-line-prompt > span:before {\n    color: hsl(230, 1%, 62%);\n  }\n\n  /* Match Braces plugin overrides */\n  /* Note: Outline colour is inherited from the braces */\n  .rainbow-braces .token.token.punctuation.brace-level-1,\n  .rainbow-braces .token.token.punctuation.brace-level-5,\n  .rainbow-braces .token.token.punctuation.brace-level-9 {\n    color: hsl(5, 74%, 59%);\n  }\n\n  .rainbow-braces .token.token.punctuation.brace-level-2,\n  .rainbow-braces .token.token.punctuation.brace-level-6,\n  .rainbow-braces .token.token.punctuation.brace-level-10 {\n    color: hsl(119, 34%, 47%);\n  }\n\n  .rainbow-braces .token.token.punctuation.brace-level-3,\n  .rainbow-braces .token.token.punctuation.brace-level-7,\n  .rainbow-braces .token.token.punctuation.brace-level-11 {\n    color: hsl(221, 87%, 60%);\n  }\n\n  .rainbow-braces .token.token.punctuation.brace-level-4,\n  .rainbow-braces .token.token.punctuation.brace-level-8,\n  .rainbow-braces .token.token.punctuation.brace-level-12 {\n    color: hsl(301, 63%, 40%);\n  }\n\n  /* Diff Highlight plugin overrides */\n  /* Taken from https://github.com/atom/github/blob/master/styles/variables.less */\n  pre.diff-highlight > code .token.token.deleted:not(.prefix),\n  pre > code.diff-highlight .token.token.deleted:not(.prefix) {\n    background-color: hsla(353, 100%, 66%, 0.15);\n  }\n\n  pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,\n  pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,\n  pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,\n  pre\n    > code.diff-highlight\n    .token.token.deleted:not(.prefix)\n    *::-moz-selection {\n    background-color: hsla(353, 95%, 66%, 0.25);\n  }\n\n  pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,\n  pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,\n  pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,\n  pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {\n    background-color: hsla(353, 95%, 66%, 0.25);\n  }\n\n  pre.diff-highlight > code .token.token.inserted:not(.prefix),\n  pre > code.diff-highlight .token.token.inserted:not(.prefix) {\n    background-color: hsla(137, 100%, 55%, 0.15);\n  }\n\n  pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,\n  pre.diff-highlight\n    > code\n    .token.token.inserted:not(.prefix)\n    *::-moz-selection,\n  pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,\n  pre\n    > code.diff-highlight\n    .token.token.inserted:not(.prefix)\n    *::-moz-selection {\n    background-color: hsla(135, 73%, 55%, 0.25);\n  }\n\n  pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,\n  pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,\n  pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,\n  pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {\n    background-color: hsla(135, 73%, 55%, 0.25);\n  }\n\n  /* Previewers plugin overrides */\n  /* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-light-ui */\n  /* Border around popup */\n  .prism-previewer.prism-previewer:before,\n  .prism-previewer-gradient.prism-previewer-gradient div {\n    border-color: hsl(0, 0, 95%);\n  }\n\n  /* Angle and time should remain as circles and are hence not included */\n  .prism-previewer-color.prism-previewer-color:before,\n  .prism-previewer-gradient.prism-previewer-gradient div,\n  .prism-previewer-easing.prism-previewer-easing:before {\n    border-radius: 0.3em;\n  }\n\n  /* Triangles pointing to the code */\n  .prism-previewer.prism-previewer:after {\n    border-top-color: hsl(0, 0, 95%);\n  }\n\n  .prism-previewer-flipped.prism-previewer-flipped.after {\n    border-bottom-color: hsl(0, 0, 95%);\n  }\n\n  /* Background colour within the popup */\n  .prism-previewer-angle.prism-previewer-angle:before,\n  .prism-previewer-time.prism-previewer-time:before,\n  .prism-previewer-easing.prism-previewer-easing {\n    background: hsl(0, 0%, 100%);\n  }\n\n  /* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */\n  /* For time, this is the alternate colour */\n  .prism-previewer-angle.prism-previewer-angle circle,\n  .prism-previewer-time.prism-previewer-time circle {\n    stroke: hsl(230, 8%, 24%);\n    stroke-opacity: 1;\n  }\n\n  /* Stroke colours of the handle, direction point, and vector itself */\n  .prism-previewer-easing.prism-previewer-easing circle,\n  .prism-previewer-easing.prism-previewer-easing path,\n  .prism-previewer-easing.prism-previewer-easing line {\n    stroke: hsl(230, 8%, 24%);\n  }\n\n  /* Fill colour of the handle */\n  .prism-previewer-easing.prism-previewer-easing circle {\n    fill: transparent;\n  }\n}\n\n.dark {\n  /**\n * One Dark theme for prism.js\n * Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax\n */\n\n  /**\n * One Dark colours (accurate as of commit 8ae45ca on 6 Sep 2018)\n * From colors.less\n * --mono-1: hsl(220, 14%, 71%);\n * --mono-2: hsl(220, 9%, 55%);\n * --mono-3: hsl(220, 10%, 40%);\n * --hue-1: hsl(187, 47%, 55%);\n * --hue-2: hsl(207, 82%, 66%);\n * --hue-3: hsl(286, 60%, 67%);\n * --hue-4: hsl(95, 38%, 62%);\n * --hue-5: hsl(355, 65%, 65%);\n * --hue-5-2: hsl(5, 48%, 51%);\n * --hue-6: hsl(29, 54%, 61%);\n * --hue-6-2: hsl(39, 67%, 69%);\n * --syntax-fg: hsl(220, 14%, 71%);\n * --syntax-bg: hsl(220, 13%, 18%);\n * --syntax-gutter: hsl(220, 14%, 45%);\n * --syntax-guide: hsla(220, 14%, 71%, 0.15);\n * --syntax-accent: hsl(220, 100%, 66%);\n * From syntax-variables.less\n * --syntax-selection-color: hsl(220, 13%, 28%);\n * --syntax-gutter-background-color-selected: hsl(220, 13%, 26%);\n * --syntax-cursor-line: hsla(220, 100%, 80%, 0.04);\n */\n\n  code[class*=\"language-\"],\n  pre[class*=\"language-\"] {\n    background: hsl(220, 13%, 18%);\n    color: hsl(220, 14%, 71%);\n    text-shadow: 0 1px rgba(0, 0, 0, 0.3);\n    font-family:\n      \"Fira Code\", \"Fira Mono\", Menlo, Consolas, \"DejaVu Sans Mono\", monospace;\n    direction: ltr;\n    text-align: left;\n    white-space: pre;\n    word-spacing: normal;\n    word-break: normal;\n    line-height: 1.5;\n    -moz-tab-size: 2;\n    -o-tab-size: 2;\n    tab-size: 2;\n    -webkit-hyphens: none;\n    -moz-hyphens: none;\n    -ms-hyphens: none;\n    hyphens: none;\n  }\n\n  /* Selection */\n  code[class*=\"language-\"]::-moz-selection,\n  code[class*=\"language-\"] *::-moz-selection,\n  pre[class*=\"language-\"] *::-moz-selection {\n    background: hsl(220, 13%, 28%);\n    color: inherit;\n    text-shadow: none;\n  }\n\n  code[class*=\"language-\"]::selection,\n  code[class*=\"language-\"] *::selection,\n  pre[class*=\"language-\"] *::selection {\n    background: hsl(220, 13%, 28%);\n    color: inherit;\n    text-shadow: none;\n  }\n\n  /* Code blocks */\n  pre[class*=\"language-\"] {\n    padding: 1em;\n    margin: 0.5em 0;\n    overflow: auto;\n    border-radius: 0.3em;\n  }\n\n  /* Inline code */\n  :not(pre) > code[class*=\"language-\"] {\n    padding: 0.2em 0.3em;\n    border-radius: 0.3em;\n    white-space: normal;\n  }\n\n  /* Print */\n  @media print {\n    code[class*=\"language-\"],\n    pre[class*=\"language-\"] {\n      text-shadow: none;\n    }\n  }\n\n  .token.comment,\n  .token.prolog,\n  .token.cdata {\n    color: hsl(220, 10%, 40%);\n  }\n\n  .token.doctype,\n  .token.punctuation,\n  .token.entity {\n    color: hsl(220, 14%, 71%);\n  }\n\n  .token.attr-name,\n  .token.class-name,\n  .token.boolean,\n  .token.constant,\n  .token.number,\n  .token.atrule {\n    color: hsl(29, 54%, 61%);\n  }\n\n  .token.keyword {\n    color: hsl(286, 60%, 67%);\n  }\n\n  .token.property,\n  .token.tag,\n  .token.symbol,\n  .token.deleted,\n  .token.important {\n    color: hsl(355, 65%, 65%);\n  }\n\n  .token.selector,\n  .token.string,\n  .token.char,\n  .token.builtin,\n  .token.inserted,\n  .token.regex,\n  .token.attr-value,\n  .token.attr-value > .token.punctuation {\n    color: hsl(95, 38%, 62%);\n  }\n\n  .token.variable,\n  .token.operator,\n  .token.function {\n    color: hsl(207, 82%, 66%);\n  }\n\n  .token.url {\n    color: hsl(187, 47%, 55%);\n  }\n\n  /* HTML overrides */\n  .token.attr-value > .token.punctuation.attr-equals,\n  .token.special-attr > .token.attr-value > .token.value.css {\n    color: hsl(220, 14%, 71%);\n  }\n\n  /* CSS overrides */\n  .language-css .token.selector {\n    color: hsl(355, 65%, 65%);\n  }\n\n  .language-css .token.property {\n    color: hsl(220, 14%, 71%);\n  }\n\n  .language-css .token.function,\n  .language-css .token.url > .token.function {\n    color: hsl(187, 47%, 55%);\n  }\n\n  .language-css .token.url > .token.string.url {\n    color: hsl(95, 38%, 62%);\n  }\n\n  .language-css .token.important,\n  .language-css .token.atrule .token.rule {\n    color: hsl(286, 60%, 67%);\n  }\n\n  /* JS overrides */\n  .language-javascript .token.operator {\n    color: hsl(286, 60%, 67%);\n  }\n\n  .language-javascript\n    .token.template-string\n    > .token.interpolation\n    > .token.interpolation-punctuation.punctuation {\n    color: hsl(5, 48%, 51%);\n  }\n\n  /* JSON overrides */\n  .language-json .token.operator {\n    color: hsl(220, 14%, 71%);\n  }\n\n  .language-json .token.null.keyword {\n    color: hsl(29, 54%, 61%);\n  }\n\n  /* MD overrides */\n  .language-markdown .token.url,\n  .language-markdown .token.url > .token.operator,\n  .language-markdown .token.url-reference.url > .token.string {\n    color: hsl(220, 14%, 71%);\n  }\n\n  .language-markdown .token.url > .token.content {\n    color: hsl(207, 82%, 66%);\n  }\n\n  .language-markdown .token.url > .token.url,\n  .language-markdown .token.url-reference.url {\n    color: hsl(187, 47%, 55%);\n  }\n\n  .language-markdown .token.blockquote.punctuation,\n  .language-markdown .token.hr.punctuation {\n    color: hsl(220, 10%, 40%);\n    font-style: italic;\n  }\n\n  .language-markdown .token.code-snippet {\n    color: hsl(95, 38%, 62%);\n  }\n\n  .language-markdown .token.bold .token.content {\n    color: hsl(29, 54%, 61%);\n  }\n\n  .language-markdown .token.italic .token.content {\n    color: hsl(286, 60%, 67%);\n  }\n\n  .language-markdown .token.strike .token.content,\n  .language-markdown .token.strike .token.punctuation,\n  .language-markdown .token.list.punctuation,\n  .language-markdown .token.title.important > .token.punctuation {\n    color: hsl(355, 65%, 65%);\n  }\n\n  /* General */\n  .token.bold {\n    font-weight: bold;\n  }\n\n  .token.comment,\n  .token.italic {\n    font-style: italic;\n  }\n\n  .token.entity {\n    cursor: help;\n  }\n\n  .token.namespace {\n    opacity: 0.8;\n  }\n\n  /* Plugin overrides */\n  /* Selectors should have higher specificity than those in the plugins' default stylesheets */\n\n  /* Show Invisibles plugin overrides */\n  .token.token.tab:not(:empty):before,\n  .token.token.cr:before,\n  .token.token.lf:before,\n  .token.token.space:before {\n    color: hsla(220, 14%, 71%, 0.15);\n    text-shadow: none;\n  }\n\n  /* Toolbar plugin overrides */\n  /* Space out all buttons and move them away from the right edge of the code block */\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item {\n    margin-right: 0.4em;\n  }\n\n  /* Styling the buttons */\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {\n    background: hsl(220, 13%, 26%);\n    color: hsl(220, 9%, 55%);\n    padding: 0.1em 0.4em;\n    border-radius: 0.3em;\n  }\n\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,\n  div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {\n    background: hsl(220, 13%, 28%);\n    color: hsl(220, 14%, 71%);\n  }\n\n  /* Line Highlight plugin overrides */\n  /* The highlighted line itself */\n  .line-highlight.line-highlight {\n    background: hsla(220, 100%, 80%, 0.04);\n  }\n\n  /* Default line numbers in Line Highlight plugin */\n  .line-highlight.line-highlight:before,\n  .line-highlight.line-highlight[data-end]:after {\n    background: hsl(220, 13%, 26%);\n    color: hsl(220, 14%, 71%);\n    padding: 0.1em 0.6em;\n    border-radius: 0.3em;\n    box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */\n  }\n\n  /* Hovering over a linkable line number (in the gutter area) */\n  /* Requires Line Numbers plugin as well */\n  pre[id].linkable-line-numbers.linkable-line-numbers\n    span.line-numbers-rows\n    > span:hover:before {\n    background-color: hsla(220, 100%, 80%, 0.04);\n  }\n\n  /* Line Numbers and Command Line plugins overrides */\n  /* Line separating gutter from coding area */\n  .line-numbers.line-numbers .line-numbers-rows,\n  .command-line .command-line-prompt {\n    border-right-color: hsla(220, 14%, 71%, 0.15);\n  }\n\n  /* Stuff in the gutter */\n  .line-numbers .line-numbers-rows > span:before,\n  .command-line .command-line-prompt > span:before {\n    color: hsl(220, 14%, 45%);\n  }\n\n  /* Match Braces plugin overrides */\n  /* Note: Outline colour is inherited from the braces */\n  .rainbow-braces .token.token.punctuation.brace-level-1,\n  .rainbow-braces .token.token.punctuation.brace-level-5,\n  .rainbow-braces .token.token.punctuation.brace-level-9 {\n    color: hsl(355, 65%, 65%);\n  }\n\n  .rainbow-braces .token.token.punctuation.brace-level-2,\n  .rainbow-braces .token.token.punctuation.brace-level-6,\n  .rainbow-braces .token.token.punctuation.brace-level-10 {\n    color: hsl(95, 38%, 62%);\n  }\n\n  .rainbow-braces .token.token.punctuation.brace-level-3,\n  .rainbow-braces .token.token.punctuation.brace-level-7,\n  .rainbow-braces .token.token.punctuation.brace-level-11 {\n    color: hsl(207, 82%, 66%);\n  }\n\n  .rainbow-braces .token.token.punctuation.brace-level-4,\n  .rainbow-braces .token.token.punctuation.brace-level-8,\n  .rainbow-braces .token.token.punctuation.brace-level-12 {\n    color: hsl(286, 60%, 67%);\n  }\n\n  /* Diff Highlight plugin overrides */\n  /* Taken from https://github.com/atom/github/blob/master/styles/variables.less */\n  pre.diff-highlight > code .token.token.deleted:not(.prefix),\n  pre > code.diff-highlight .token.token.deleted:not(.prefix) {\n    background-color: hsla(353, 100%, 66%, 0.15);\n  }\n\n  pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,\n  pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,\n  pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,\n  pre\n    > code.diff-highlight\n    .token.token.deleted:not(.prefix)\n    *::-moz-selection {\n    background-color: hsla(353, 95%, 66%, 0.25);\n  }\n\n  pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,\n  pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,\n  pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,\n  pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {\n    background-color: hsla(353, 95%, 66%, 0.25);\n  }\n\n  pre.diff-highlight > code .token.token.inserted:not(.prefix),\n  pre > code.diff-highlight .token.token.inserted:not(.prefix) {\n    background-color: hsla(137, 100%, 55%, 0.15);\n  }\n\n  pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,\n  pre.diff-highlight\n    > code\n    .token.token.inserted:not(.prefix)\n    *::-moz-selection,\n  pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,\n  pre\n    > code.diff-highlight\n    .token.token.inserted:not(.prefix)\n    *::-moz-selection {\n    background-color: hsla(135, 73%, 55%, 0.25);\n  }\n\n  pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,\n  pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,\n  pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,\n  pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {\n    background-color: hsla(135, 73%, 55%, 0.25);\n  }\n\n  /* Previewers plugin overrides */\n  /* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */\n  /* Border around popup */\n  .prism-previewer.prism-previewer:before,\n  .prism-previewer-gradient.prism-previewer-gradient div {\n    border-color: hsl(224, 13%, 17%);\n  }\n\n  /* Angle and time should remain as circles and are hence not included */\n  .prism-previewer-color.prism-previewer-color:before,\n  .prism-previewer-gradient.prism-previewer-gradient div,\n  .prism-previewer-easing.prism-previewer-easing:before {\n    border-radius: 0.3em;\n  }\n\n  /* Triangles pointing to the code */\n  .prism-previewer.prism-previewer:after {\n    border-top-color: hsl(224, 13%, 17%);\n  }\n\n  .prism-previewer-flipped.prism-previewer-flipped.after {\n    border-bottom-color: hsl(224, 13%, 17%);\n  }\n\n  /* Background colour within the popup */\n  .prism-previewer-angle.prism-previewer-angle:before,\n  .prism-previewer-time.prism-previewer-time:before,\n  .prism-previewer-easing.prism-previewer-easing {\n    background: hsl(219, 13%, 22%);\n  }\n\n  /* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */\n  /* For time, this is the alternate colour */\n  .prism-previewer-angle.prism-previewer-angle circle,\n  .prism-previewer-time.prism-previewer-time circle {\n    stroke: hsl(220, 14%, 71%);\n    stroke-opacity: 1;\n  }\n\n  /* Stroke colours of the handle, direction point, and vector itself */\n  .prism-previewer-easing.prism-previewer-easing circle,\n  .prism-previewer-easing.prism-previewer-easing path,\n  .prism-previewer-easing.prism-previewer-easing line {\n    stroke: hsl(220, 14%, 71%);\n  }\n\n  /* Fill colour of the handle */\n  .prism-previewer-easing.prism-previewer-easing circle {\n    fill: transparent;\n  }\n}\n\ntr[data-comment-highlight=\"true\"] {\n  @apply !bg-[var(--color-yellow)]/20;\n  transition: background-color 150ms ease;\n}\n\n.dark tr[data-comment-highlight=\"true\"] {\n  @apply !bg-[var(--color-yellow)]/20;\n  background-color: rgba(188, 133, 255, 0.22);\n}\n\ntr[data-comment-draft=\"true\"] {\n  background-color: rgba(59, 130, 246, 0.12);\n}\n\ntr[data-comment-draft=\"true\"] td:first-child {\n  border-left-color: rgba(59, 130, 246, 0.4);\n}\n"
  },
  {
    "path": "web/src/components/ui/diff/utils/guess-lang.ts",
    "content": "const extToLang: Record<string, string> = {\n  // JavaScript/TypeScript\n  js: \"javascript\",\n  jsx: \"jsx\",\n  ts: \"typescript\",\n  tsx: \"tsx\",\n  mjs: \"javascript\",\n  cjs: \"javascript\",\n\n  // Web\n  html: \"markup\",\n  htm: \"markup\",\n  xml: \"markup\",\n  svg: \"markup\",\n  css: \"css\",\n  scss: \"scss\",\n  sass: \"sass\",\n  less: \"less\",\n  stylus: \"stylus\",\n\n  // Python\n  py: \"python\",\n  pyw: \"python\",\n  pyi: \"python\",\n\n  // Java/JVM\n  java: \"java\",\n  kt: \"kotlin\",\n  kts: \"kotlin\",\n  scala: \"scala\",\n  groovy: \"groovy\",\n\n  // C/C++\n  c: \"c\",\n  cpp: \"cpp\",\n  cc: \"cpp\",\n  cxx: \"cpp\",\n  h: \"cpp\",\n  hpp: \"cpp\",\n  hh: \"cpp\",\n  hxx: \"cpp\",\n\n  // C#/.NET\n  cs: \"csharp\",\n  vb: \"vbnet\",\n  fs: \"fsharp\",\n\n  // Rust\n  rs: \"rust\",\n\n  // Go\n  go: \"go\",\n\n  // Ruby\n  rb: \"ruby\",\n  rake: \"ruby\",\n\n  // PHP\n  php: \"php\",\n  phtml: \"php\",\n\n  // Shell\n  sh: \"bash\",\n  bash: \"bash\",\n  zsh: \"bash\",\n  fish: \"bash\",\n\n  // Data formats\n  json: \"json\",\n  json5: \"json5\",\n  yml: \"yaml\",\n  yaml: \"yaml\",\n  toml: \"toml\",\n  ini: \"ini\",\n  csv: \"csv\",\n\n  // Markdown/Docs\n  md: \"markdown\",\n  markdown: \"markdown\",\n  tex: \"latex\",\n\n  // Swift/Objective-C\n  swift: \"swift\",\n  m: \"objectivec\",\n  mm: \"objectivec\",\n\n  // SQL\n  sql: \"sql\",\n\n  // Other languages\n  r: \"r\",\n  lua: \"lua\",\n  perl: \"perl\",\n  pl: \"perl\",\n  dart: \"dart\",\n  elm: \"elm\",\n  ex: \"elixir\",\n  exs: \"elixir\",\n  erl: \"erlang\",\n  clj: \"clojure\",\n  cljs: \"clojure\",\n  lisp: \"lisp\",\n  hs: \"haskell\",\n  ml: \"ocaml\",\n\n  // Config files\n  dockerfile: \"docker\",\n  gitignore: \"ignore\",\n\n  // Other\n  graphql: \"graphql\",\n  proto: \"protobuf\",\n  wasm: \"wasm\",\n  vim: \"vim\",\n  zig: \"zig\",\n  mermaid: \"mermaid\",\n};\n\nexport const guessLang = (filename?: string): string => {\n  const ext = filename?.split(\".\").pop()?.toLowerCase() ?? \"\";\n  return extToLang[ext] ?? \"tsx\";\n};\n"
  },
  {
    "path": "web/src/components/ui/diff/utils/index.ts",
    "content": "export {\n  parseDiff,\n  mergeModifiedLines,\n  type LineSegment,\n  type Line,\n  type Hunk,\n  type SkipBlock,\n  type File,\n  type ParseOptions,\n} from \"./parse\";\nexport * from \"./guess-lang\";\n"
  },
  {
    "path": "web/src/components/ui/diff/utils/parse.ts",
    "content": "import gitDiffParser, {\n  type Hunk as _Hunk,\n  type File as _File,\n  type Change as _Change,\n  type DeleteChange,\n  type InsertChange,\n} from \"gitdiff-parser\";\nimport { diffChars, diffWords } from \"diff\";\n\nexport interface LineSegment {\n  value: string;\n  type: \"insert\" | \"delete\" | \"normal\";\n}\n\ntype ReplaceKey<T, K extends PropertyKey, V> = T extends unknown\n  ? Omit<T, K> & Record<K, V>\n  : never;\n\nexport type Line = ReplaceKey<_Change, \"content\", LineSegment[]>;\n\nexport interface Hunk extends Omit<_Hunk, \"changes\"> {\n  type: \"hunk\";\n  lines: Line[];\n}\n\nexport interface SkipBlock {\n  count: number;\n  type: \"skip\";\n  content: string;\n}\n\nexport interface File extends Omit<_File, \"hunks\"> {\n  hunks: (Hunk | SkipBlock)[];\n}\n\nexport interface ParseOptions {\n  maxDiffDistance: number;\n  maxChangeRatio: number;\n  mergeModifiedLines: boolean;\n  inlineMaxCharEdits: number;\n}\n\nconst calculateChangeRatio = (a: string, b: string): number => {\n  const totalChars = a.length + b.length;\n  if (totalChars === 0) return 1;\n  const tokens = diffWords(a, b);\n  const changedChars = tokens\n    .filter((token) => token.added || token.removed)\n    .reduce((sum, token) => sum + token.value.length, 0);\n  return changedChars / totalChars;\n};\n\nconst isSimilarEnough = (\n  a: string,\n  b: string,\n  maxChangeRatio: number,\n): boolean => {\n  if (maxChangeRatio <= 0) return a === b;\n  if (maxChangeRatio >= 1) return true;\n  return calculateChangeRatio(a, b) <= maxChangeRatio;\n};\n\nconst changeToLine = (change: _Change): Line => ({\n  ...change,\n  content: [\n    {\n      value: change.content,\n      type: \"normal\",\n    },\n  ],\n});\n\nfunction diffCharsIfWithinEditLimit(\n  a: string,\n  b: string,\n  maxEdits = 4,\n):\n  | {\n      exceededLimit: true;\n    }\n  | {\n      exceededLimit: false;\n      diffs: LineSegment[];\n    } {\n  const diffs = diffChars(a, b);\n\n  let edits = 0;\n  for (const part of diffs) {\n    if (part.added || part.removed) {\n      edits += part.value.length;\n      if (edits > maxEdits) return { exceededLimit: true };\n    }\n  }\n\n  return {\n    exceededLimit: false,\n    diffs: diffs.map((d) => ({\n      value: d.value,\n      type: d.added ? \"insert\" : d.removed ? \"delete\" : \"normal\",\n    })),\n  };\n}\n\nconst buildInlineDiffSegments = (\n  current: _Change,\n  next: _Change,\n  options: ParseOptions,\n): Line[\"content\"] => {\n  const segments: LineSegment[] = diffWords(current.content, next.content).map(\n    (token) => ({\n      value: token.value,\n      type: token.added ? \"insert\" : token.removed ? \"delete\" : \"normal\",\n    }),\n  );\n\n  const result: LineSegment[] = [];\n\n  const mergeIntoResult = (segment: LineSegment) => {\n    const last = result[result.length - 1];\n    if (last && last.type === segment.type) {\n      last.value += segment.value;\n    } else {\n      result.push(segment);\n    }\n  };\n\n  for (let i = 0; i < segments.length; i++) {\n    const current = segments[i];\n    const next = segments[i + 1];\n    if (current.type === \"delete\" && next?.type === \"insert\") {\n      const charDiff = diffCharsIfWithinEditLimit(\n        current.value,\n        next.value,\n        options.inlineMaxCharEdits,\n      );\n\n      if (!charDiff.exceededLimit) {\n        charDiff.diffs.forEach(mergeIntoResult);\n\n        i++;\n      } else {\n        result.push(current);\n      }\n    } else {\n      mergeIntoResult(current);\n    }\n  }\n\n  return result;\n};\n\nconst mergeAdjacentLines = (\n  changes: _Change[],\n  options: ParseOptions,\n): Line[] => {\n  const out: Line[] = [];\n  for (let i = 0; i < changes.length; i++) {\n    const current = changes[i];\n    const next = changes[i + 1];\n    if (\n      next &&\n      current.type === \"delete\" &&\n      next.type === \"insert\" &&\n      isSimilarEnough(current.content, next.content, options.maxChangeRatio)\n    ) {\n      out.push({\n        ...current,\n        type: \"normal\",\n        isNormal: true,\n        oldLineNumber: current.lineNumber,\n        newLineNumber: next.lineNumber,\n        content: buildInlineDiffSegments(current, next, options),\n      });\n      i++;\n    } else {\n      out.push(changeToLine(current));\n    }\n  }\n\n  return out;\n};\n\nconst UNPAIRED = -1;\n\nfunction buildChangeIndices(changes: _Change[]) {\n  const insertIdxs: number[] = [];\n  const deleteIdxs: number[] = [];\n\n  for (let i = 0; i < changes.length; i++) {\n    const c = changes[i];\n    if (c.type === \"insert\") insertIdxs.push(i);\n    else if (c.type === \"delete\") deleteIdxs.push(i);\n  }\n  return { insertIdxs, deleteIdxs };\n}\n\n// TODO: slight penalty for distance?\n// TODO: improve performance w binary search?\nfunction findBestInsertForDelete(\n  changes: _Change[],\n  delIdx: number,\n  insertIdxs: number[],\n  pairOfAdd: Int32Array,\n  options: ParseOptions,\n): number {\n  const del = changes[delIdx] as DeleteChange;\n\n  const lower = del.lineNumber - options.maxDiffDistance;\n  const upper = del.lineNumber + options.maxDiffDistance;\n\n  let bestAddIdx = UNPAIRED;\n  let bestRatio = Infinity;\n\n  for (const addIdx of insertIdxs) {\n    const add = changes[addIdx] as InsertChange;\n\n    if (pairOfAdd[addIdx] !== UNPAIRED) continue;\n\n    if (add.lineNumber < lower) continue;\n    if (add.lineNumber > upper) break;\n\n    const ratio = calculateChangeRatio(del.content, add.content);\n\n    if (ratio > options.maxChangeRatio) continue;\n\n    if (ratio < bestRatio) {\n      bestRatio = ratio;\n      bestAddIdx = addIdx;\n    }\n  }\n\n  return bestAddIdx;\n}\n\nfunction buildInitialPairs(\n  changes: _Change[],\n  insertIdxs: number[],\n  deleteIdxs: number[],\n  options: ParseOptions,\n) {\n  const n = changes.length;\n  const pairOfDel = new Int32Array(n).fill(UNPAIRED);\n  const pairOfAdd = new Int32Array(n).fill(UNPAIRED);\n\n  for (const di of deleteIdxs) {\n    const bestAddIdx = findBestInsertForDelete(\n      changes,\n      di,\n      insertIdxs,\n      pairOfAdd,\n      options,\n    );\n    if (bestAddIdx !== UNPAIRED) {\n      pairOfDel[di] = bestAddIdx;\n      pairOfAdd[bestAddIdx] = di;\n    }\n  }\n\n  return { pairOfDel, pairOfAdd };\n}\n\nfunction buildUnpairedDeletePrefix(changes: _Change[], pairOfDel: Int32Array) {\n  const n = changes.length;\n  const prefix = new Int32Array(n + 1);\n\n  for (let i = 0; i < n; i++) {\n    const c = changes[i];\n    const isInitiallyUnpairedDelete =\n      c.type === \"delete\" && pairOfDel[i] === UNPAIRED;\n    prefix[i + 1] = prefix[i] + (isInitiallyUnpairedDelete ? 1 : 0);\n  }\n\n  return prefix;\n}\n\nfunction hasUnpairedDeleteBetween(\n  unpairedDelPrefix: Int32Array,\n  deleteIdx: number,\n  insertIdx: number,\n) {\n  const lower = Math.max(0, deleteIdx);\n  const upper = Math.max(lower, insertIdx);\n  return unpairedDelPrefix[upper] - unpairedDelPrefix[lower] > 0;\n}\n\nfunction emitNormal(out: Line[], c: _Change) {\n  out.push(changeToLine(c));\n}\n\nfunction emitModified(\n  out: Line[],\n  del: DeleteChange,\n  add: InsertChange,\n  options: ParseOptions,\n) {\n  out.push({\n    oldLineNumber: del.lineNumber,\n    newLineNumber: add.lineNumber,\n    type: \"normal\",\n    isNormal: true,\n    content: buildInlineDiffSegments(del, add, options),\n  });\n}\n\nfunction emitLines(\n  changes: _Change[],\n  pairOfDel: Int32Array,\n  pairOfAdd: Int32Array,\n  unpairedDelPrefix: Int32Array,\n  options: ParseOptions,\n): Line[] {\n  const out: Line[] = [];\n  const processed = new Uint8Array(changes.length);\n\n  for (let i = 0; i < changes.length; i++) {\n    if (processed[i]) continue;\n    const c = changes[i];\n\n    if (c.type === \"normal\") {\n      processed[i] = 1;\n      emitNormal(out, c);\n    } else if (c.type === \"delete\") {\n      const pairedAddIdx = pairOfDel[i];\n\n      if (pairedAddIdx === UNPAIRED) {\n        processed[i] = 1;\n        emitNormal(out, c);\n      } else if (pairedAddIdx > i) {\n        const shouldUnpair = hasUnpairedDeleteBetween(\n          unpairedDelPrefix,\n          i + 1,\n          pairedAddIdx,\n        );\n\n        if (shouldUnpair) {\n          pairOfAdd[pairedAddIdx] = UNPAIRED;\n          processed[i] = 1;\n          emitNormal(out, c);\n        } else {\n          // Defer emission to paired insert\n          processed[i] = 1;\n        }\n      } else {\n        const add = changes[pairedAddIdx] as InsertChange;\n        emitModified(out, c, add, options);\n        processed[i] = 1;\n        processed[pairedAddIdx] = 1;\n      }\n    } else {\n      const pairedDelIdx = pairOfAdd[i];\n\n      if (pairedDelIdx === UNPAIRED) {\n        processed[i] = 1;\n        emitNormal(out, c);\n      } else {\n        const del = changes[pairedDelIdx] as DeleteChange;\n        emitModified(out, del, c, options);\n        processed[i] = 1;\n        processed[pairedDelIdx] = 1;\n      }\n    }\n  }\n\n  return out;\n}\n\nexport function mergeModifiedLines(\n  changes: _Change[],\n  options: ParseOptions,\n): Line[] {\n  const { insertIdxs, deleteIdxs } = buildChangeIndices(changes);\n\n  const { pairOfDel, pairOfAdd } = buildInitialPairs(\n    changes,\n    insertIdxs,\n    deleteIdxs,\n    options,\n  );\n\n  const unpairedDelPrefix = buildUnpairedDeletePrefix(changes, pairOfDel);\n\n  return emitLines(changes, pairOfDel, pairOfAdd, unpairedDelPrefix, options);\n}\n\nconst parseHunk = (hunk: _Hunk, options: ParseOptions): Hunk => {\n  if (options.mergeModifiedLines) {\n    return {\n      ...hunk,\n      type: \"hunk\",\n      lines:\n        options.maxDiffDistance === 1\n          ? mergeAdjacentLines(hunk.changes, options)\n          : mergeModifiedLines(hunk.changes, options),\n    };\n  }\n\n  return {\n    ...hunk,\n    type: \"hunk\",\n    lines: hunk.changes.map(changeToLine),\n  };\n};\n\nconst HUNK_HEADER_REGEX = /^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@(.*)/;\n\nconst extractHunkContext = (header: string): string =>\n  HUNK_HEADER_REGEX.exec(header)?.[5]?.trim() ?? \"\";\n\nconst insertSkipBlocks = (hunks: Hunk[]): (Hunk | SkipBlock)[] => {\n  const result: (Hunk | SkipBlock)[] = [];\n  let lastHunkLine = 1;\n\n  for (const hunk of hunks) {\n    const distanceToLastHunk = hunk.oldStart - lastHunkLine;\n\n    const context = extractHunkContext(hunk.content);\n    if (distanceToLastHunk > 0) {\n      result.push({\n        count: distanceToLastHunk,\n        type: \"skip\",\n        content: context ?? hunk.content,\n      });\n    }\n    lastHunkLine = Math.max(hunk.oldStart + hunk.oldLines, lastHunkLine);\n    result.push(hunk);\n  }\n\n  return result;\n};\n\nconst defaultOptions: ParseOptions = {\n  maxDiffDistance: 30,\n  maxChangeRatio: 0.45,\n  mergeModifiedLines: true,\n  inlineMaxCharEdits: 4,\n};\n\nexport const parseDiff = (\n  diff: string,\n  options?: Partial<ParseOptions>,\n): File[] => {\n  const opts = { ...defaultOptions, ...options };\n  const files = gitDiffParser.parse(diff);\n\n  return files.map((file) => ({\n    ...file,\n    hunks: insertSkipBlocks(file.hunks.map((hunk) => parseHunk(hunk, opts))),\n  }));\n};\n"
  },
  {
    "path": "web/src/components/ui/dropdown-menu.tsx",
    "content": "import * as React from \"react\";\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />;\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n  );\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md\",\n          className,\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  );\n}\n\nfunction DropdownMenuGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return (\n    <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n  );\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean;\n  variant?: \"default\" | \"destructive\";\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot=\"dropdown-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot=\"dropdown-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  );\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot=\"dropdown-menu-label\"\n      data-inset={inset}\n      className={cn(\n        \"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSub({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />;\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot=\"dropdown-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  );\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot=\"dropdown-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n};\n"
  },
  {
    "path": "web/src/components/ui/hover-card.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction HoverCard({\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {\n  return <HoverCardPrimitive.Root data-slot=\"hover-card\" {...props} />;\n}\n\nfunction HoverCardTrigger({\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {\n  return (\n    <HoverCardPrimitive.Trigger data-slot=\"hover-card-trigger\" {...props} />\n  );\n}\n\nfunction HoverCardContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {\n  return (\n    <HoverCardPrimitive.Portal data-slot=\"hover-card-portal\">\n      <HoverCardPrimitive.Content\n        data-slot=\"hover-card-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden\",\n          className,\n        )}\n        {...props}\n      />\n    </HoverCardPrimitive.Portal>\n  );\n}\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent };\n"
  },
  {
    "path": "web/src/components/ui/input-group.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nfunction InputGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"input-group\"\n      role=\"group\"\n      className={cn(\n        \"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none\",\n        \"h-9 min-w-0 has-[>textarea]:h-auto\",\n\n        // Variants based on alignment.\n        \"has-[>[data-align=inline-start]]:[&>input]:pl-2\",\n        \"has-[>[data-align=inline-end]]:[&>input]:pr-2\",\n        \"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3\",\n        \"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3\",\n\n        // Focus state.\n        \"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] max-sm:has-[[data-slot=input-group-control]:focus-visible]:ring-0 max-sm:has-[[data-slot=input-group-control]:focus-visible]:border-input\",\n\n        // Error state.\n        \"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40\",\n\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nconst inputGroupAddonVariants = cva(\n  \"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50\",\n  {\n    variants: {\n      align: {\n        \"inline-start\":\n          \"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]\",\n        \"inline-end\":\n          \"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]\",\n        \"block-start\":\n          \"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5\",\n        \"block-end\":\n          \"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5\",\n      },\n    },\n    defaultVariants: {\n      align: \"inline-start\",\n    },\n  },\n);\n\nfunction InputGroupAddon({\n  className,\n  align = \"inline-start\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof inputGroupAddonVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"input-group-addon\"\n      data-align={align}\n      className={cn(inputGroupAddonVariants({ align }), className)}\n      onClick={(e) => {\n        if ((e.target as HTMLElement).closest(\"button\")) {\n          return;\n        }\n        e.currentTarget.parentElement?.querySelector(\"input\")?.focus();\n      }}\n      {...props}\n    />\n  );\n}\n\nconst inputGroupButtonVariants = cva(\n  \"text-sm shadow-none flex gap-2 items-center\",\n  {\n    variants: {\n      size: {\n        xs: \"h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2\",\n        sm: \"h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5\",\n        \"icon-xs\":\n          \"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0\",\n        \"icon-sm\": \"size-8 p-0 has-[>svg]:p-0\",\n      },\n    },\n    defaultVariants: {\n      size: \"xs\",\n    },\n  },\n);\n\nfunction InputGroupButton({\n  className,\n  type = \"button\",\n  variant = \"ghost\",\n  size = \"xs\",\n  ...props\n}: Omit<React.ComponentProps<typeof Button>, \"size\"> &\n  VariantProps<typeof inputGroupButtonVariants>) {\n  return (\n    <Button\n      type={type}\n      data-size={size}\n      variant={variant}\n      className={cn(inputGroupButtonVariants({ size }), className)}\n      {...props}\n    />\n  );\n}\n\nfunction InputGroupText({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      className={cn(\n        \"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction InputGroupInput({\n  className,\n  ...props\n}: React.ComponentProps<\"input\">) {\n  return (\n    <Input\n      data-slot=\"input-group-control\"\n      className={cn(\n        \"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction InputGroupTextarea({\n  className,\n  ...props\n}: React.ComponentProps<\"textarea\">) {\n  return (\n    <Textarea\n      data-slot=\"input-group-control\"\n      className={cn(\n        \"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupText,\n  InputGroupInput,\n  InputGroupTextarea,\n};\n"
  },
  {
    "path": "web/src/components/ui/input.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Input };\n"
  },
  {
    "path": "web/src/components/ui/kbd.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Kbd({ className, ...props }: React.ComponentProps<\"kbd\">) {\n  return (\n    <kbd\n      data-slot=\"kbd\"\n      className={cn(\n        \"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none\",\n        \"[&_svg:not([class*='size-'])]:size-3\",\n        \"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction KbdGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <kbd\n      data-slot=\"kbd-group\"\n      className={cn(\"inline-flex items-center gap-1\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Kbd, KbdGroup }\n"
  },
  {
    "path": "web/src/components/ui/progress.tsx",
    "content": "import * as React from \"react\";\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Progress({\n  className,\n  value,\n  ...props\n}: React.ComponentProps<typeof ProgressPrimitive.Root>) {\n  return (\n    <ProgressPrimitive.Root\n      data-slot=\"progress\"\n      className={cn(\n        \"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full\",\n        className,\n      )}\n      {...props}\n    >\n      <ProgressPrimitive.Indicator\n        data-slot=\"progress-indicator\"\n        className=\"bg-primary h-full w-full flex-1 transition-all\"\n        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n      />\n    </ProgressPrimitive.Root>\n  );\n}\n\nexport { Progress };\n"
  },
  {
    "path": "web/src/components/ui/resizable.tsx",
    "content": "import * as React from \"react\";\nimport { GripVerticalIcon } from \"lucide-react\";\nimport {\n  Group,\n  Panel,\n  Separator,\n  type GroupProps,\n  type PanelProps,\n  type SeparatorProps,\n} from \"react-resizable-panels\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction ResizablePanelGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof Group> & GroupProps) {\n  return (\n    <Group\n      data-slot=\"resizable-panel-group\"\n      className={cn(\n        \"flex h-full w-full data-[orientation=vertical]:flex-col\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ResizablePanel({ ...props }: PanelProps) {\n  return <Panel data-slot=\"resizable-panel\" {...props} />;\n}\n\nfunction ResizableHandle({\n  withHandle,\n  className,\n  ...props\n}: SeparatorProps & {\n  withHandle?: boolean;\n}) {\n  return (\n    <Separator\n      data-slot=\"resizable-handle\"\n      className={cn(\n        \"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90\",\n        className,\n      )}\n      {...props}\n    >\n      {withHandle && (\n        <div className=\"z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border\">\n          <GripVerticalIcon className=\"size-2.5\" />\n        </div>\n      )}\n    </Separator>\n  );\n}\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle };\n"
  },
  {
    "path": "web/src/components/ui/scroll-area.tsx",
    "content": "import * as React from \"react\";\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {\n  return (\n    <ScrollAreaPrimitive.Root\n      data-slot=\"scroll-area\"\n      className={cn(\"relative\", className)}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        data-slot=\"scroll-area-viewport\"\n        className=\"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  );\n}\n\nfunction ScrollBar({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      className={cn(\n        \"flex touch-none p-px transition-colors select-none\",\n        orientation === \"vertical\" &&\n          \"h-full w-2.5 border-l border-l-transparent\",\n        orientation === \"horizontal\" &&\n          \"h-2.5 flex-col border-t border-t-transparent\",\n        className,\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        data-slot=\"scroll-area-thumb\"\n        className=\"bg-border relative flex-1 rounded-full\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  );\n}\n\nexport { ScrollArea, ScrollBar };\n"
  },
  {
    "path": "web/src/components/ui/select.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as SelectPrimitive from \"@radix-ui/react-select\";\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />;\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />;\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />;\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\";\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  );\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"popper\",\n  align = \"center\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className,\n        )}\n        position={position}\n        align={align}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\",\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"text-muted-foreground px-2 py-1.5 text-xs\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"bg-border pointer-events-none -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className,\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  );\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className,\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
  },
  {
    "path": "web/src/components/ui/separator.tsx",
    "content": "import * as React from \"react\";\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Separator };\n"
  },
  {
    "path": "web/src/components/ui/sonner.tsx",
    "content": "import { Toaster as Sonner } from \"sonner\";\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>;\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  return (\n    <Sonner\n      className=\"toaster group\"\n      toastOptions={{\n        classNames: {\n          toast:\n            \"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg\",\n          description: \"group-[.toast]:text-muted-foreground\",\n          actionButton:\n            \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground\",\n          cancelButton:\n            \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground\",\n          error:\n            \"group-[.toaster]:bg-destructive group-[.toaster]:text-destructive-foreground group-[.toaster]:border-destructive/50\",\n          success:\n            \"group-[.toaster]:bg-success group-[.toaster]:text-success-foreground group-[.toaster]:border-success/50\",\n          warning:\n            \"group-[.toaster]:bg-warning group-[.toaster]:text-warning-foreground group-[.toaster]:border-warning/50\",\n          info: \"group-[.toaster]:bg-info group-[.toaster]:text-info-foreground group-[.toaster]:border-info/50\",\n        },\n      }}\n      {...props}\n    />\n  );\n};\n\nexport { Toaster };\n"
  },
  {
    "path": "web/src/components/ui/switch.tsx",
    "content": "import * as React from \"react\";\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\";\n\nimport { cn } from \"@/lib/utils\";\n\n// Track: 36×20 (h-5 w-9), padding 2px → inner 32×16\n// Thumb: 16×16 (size-4), travel 16px (translate-x-4)\n// Radix sets an inline `transform` on checked thumbs that conflicts with the\n// CSS `translate` property; resetting it via `style` keeps a single source of\n// positioning truth.\nfunction Switch({\n  className,\n  ...props\n}: React.ComponentProps<typeof SwitchPrimitive.Root>) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      className={cn(\n        \"peer inline-flex h-5 w-9 shrink-0 items-center rounded-full p-0.5 shadow-xs transition-all outline-none\",\n        \"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80\",\n        \"focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className={cn(\n          \"pointer-events-none block size-4 rounded-full ring-0\",\n          \"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground\",\n          \"transition-[translate] duration-200 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0\",\n        )}\n        style={{ transform: \"none\" }}\n      />\n    </SwitchPrimitive.Root>\n  );\n}\n\nexport { Switch };\n"
  },
  {
    "path": "web/src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Textarea };\n"
  },
  {
    "path": "web/src/components/ui/theme-toggle.tsx",
    "content": "import { MoonIcon, SunIcon } from \"lucide-react\";\n\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"./button\";\n\ntype ThemeToggleProps = {\n  className?: string;\n};\n\nexport function ThemeToggle({ className }: ThemeToggleProps) {\n  const { theme, toggleThemeWithTransition } = useTheme();\n  const isDark = theme === \"dark\";\n\n  return (\n    <Button\n      aria-label={isDark ? \"Switch to light mode\" : \"Switch to dark mode\"}\n      className={cn(\n        \"size-9 p-0 text-foreground hover:text-foreground dark:hover:text-foreground hover:bg-accent/20 dark:hover:bg-accent/20\",\n        \"cursor-pointer\",\n        className,\n      )}\n      onClick={(e) => {\n        toggleThemeWithTransition(e);\n      }}\n      size=\"icon\"\n      variant=\"outline\"\n    >\n      {isDark ? (\n        <SunIcon className=\"size-4\" />\n      ) : (\n        <MoonIcon className=\"size-4\" />\n      )}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "web/src/components/ui/toggle-group.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ToggleGroupPrimitive from \"@radix-ui/react-toggle-group\"\nimport { type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { toggleVariants } from \"@/components/ui/toggle\"\n\nconst ToggleGroupContext = React.createContext<\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number\n  }\n>({\n  size: \"default\",\n  variant: \"default\",\n  spacing: 0,\n})\n\nfunction ToggleGroup({\n  className,\n  variant,\n  size,\n  spacing = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number\n  }) {\n  return (\n    <ToggleGroupPrimitive.Root\n      data-slot=\"toggle-group\"\n      data-variant={variant}\n      data-size={size}\n      data-spacing={spacing}\n      style={{ \"--gap\": spacing } as React.CSSProperties}\n      className={cn(\n        \"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs\",\n        className\n      )}\n      {...props}\n    >\n      <ToggleGroupContext.Provider value={{ variant, size, spacing }}>\n        {children}\n      </ToggleGroupContext.Provider>\n    </ToggleGroupPrimitive.Root>\n  )\n}\n\nfunction ToggleGroupItem({\n  className,\n  children,\n  variant,\n  size,\n  ...props\n}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &\n  VariantProps<typeof toggleVariants>) {\n  const context = React.useContext(ToggleGroupContext)\n\n  return (\n    <ToggleGroupPrimitive.Item\n      data-slot=\"toggle-group-item\"\n      data-variant={context.variant || variant}\n      data-size={context.size || size}\n      data-spacing={context.spacing}\n      className={cn(\n        toggleVariants({\n          variant: context.variant || variant,\n          size: context.size || size,\n        }),\n        \"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10\",\n        \"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </ToggleGroupPrimitive.Item>\n  )\n}\n\nexport { ToggleGroup, ToggleGroupItem }\n"
  },
  {
    "path": "web/src/components/ui/toggle.tsx",
    "content": "import * as React from \"react\"\nimport * as TogglePrimitive from \"@radix-ui/react-toggle\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst toggleVariants = cva(\n  \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline:\n          \"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground\",\n      },\n      size: {\n        default: \"h-9 px-2 min-w-9\",\n        sm: \"h-8 px-1.5 min-w-8\",\n        lg: \"h-10 px-2.5 min-w-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Toggle({\n  className,\n  variant,\n  size,\n  ...props\n}: React.ComponentProps<typeof TogglePrimitive.Root> &\n  VariantProps<typeof toggleVariants>) {\n  return (\n    <TogglePrimitive.Root\n      data-slot=\"toggle\"\n      className={cn(toggleVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Toggle, toggleVariants }\n"
  },
  {
    "path": "web/src/components/ui/tooltip.tsx",
    "content": "import * as React from \"react\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  );\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  );\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />;\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-foreground text-background [&_p]:text-inherit animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  );\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n"
  },
  {
    "path": "web/src/config/media.ts",
    "content": "export const IMAGE_CONFIG = {\n  maxSizeBytes: 5 * 1024 * 1024,\n  maxDimension: 4096,\n  compressThresholdBytes: 5 * 1024 * 1024,\n  targetCompressedBytes: 2 * 1024 * 1024,\n  allowedTypes: [\"image/png\", \"image/jpeg\", \"image/gif\", \"image/webp\", \"image/heic\", \"image/heif\"] as const,\n} as const;\n\nexport const IMAGE_EXTENSIONS = new Set([\"png\", \"jpg\", \"jpeg\", \"gif\", \"webp\", \"heic\", \"heif\", \"svg\", \"bmp\"]);\n\nexport const VIDEO_CONFIG = {\n  maxSizeBytes: 40 * 1024 * 1024,\n  allowedTypes: [\"video/mp4\", \"video/webm\", \"video/quicktime\"] as const,\n} as const;\n\nexport const VIDEO_EXTENSIONS = new Set([\"mp4\", \"webm\", \"mov\"]);\n\nexport const MEDIA_CONFIG = {\n  maxCount: 9,\n  maxTotalBytes: 80 * 1024 * 1024,\n} as const;\n"
  },
  {
    "path": "web/src/features/chat/chat-workspace-container.tsx",
    "content": "/**\n * Container component that subscribes to useSessionStream.\n *\n * This exists to isolate high-frequency message updates from App, preventing\n * unnecessary re-renders of SessionsSidebar. When messages update, only this\n * container and ChatWorkspace re-render, not App.\n *\n * TODO: This layer could be simplified by moving useSessionStream directly\n * into ChatWorkspace. The Container/Presentational split here doesn't provide\n * much value since ChatWorkspace receives `messages` as a prop and re-renders\n * on every update anyway.\n */\nimport { type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { ChatStatus, FileUIPart } from \"ai\";\nimport type { PromptInputMessage } from \"@ai-elements\";\nimport { toast } from \"sonner\";\nimport type {\n  Session,\n  SessionStatus,\n  UploadSessionFileResponse,\n} from \"@/lib/api/models\";\nimport { useGlobalConfig } from \"@/hooks/useGlobalConfig\";\nimport type { SessionFileEntry } from \"@/hooks/useSessions\";\nimport { getApiBaseUrl, isMacOS } from \"@/hooks/utils\";\nimport { useSessionStream } from \"@/hooks/useSessionStream\";\nimport { useToolEventsStore } from \"@/features/tool/store\";\nimport { useQueueStore } from \"./queue-store\";\nimport { ChatWorkspace } from \"./chat\";\n\ntype PendingMessage = {\n  text: string;\n  targetSessionId: string;\n};\n\ntype ChatWorkspaceContainerProps = {\n  selectedSessionId: string;\n  currentSession?: Session;\n  sessionDescription?: string;\n  onSessionStatus: (status: SessionStatus) => void;\n  onStreamStatusChange?: (status: ChatStatus) => void;\n  uploadSessionFile: (\n    sessionId: string,\n    file: File,\n  ) => Promise<UploadSessionFileResponse>;\n  onListSessionDirectory?: (\n    sessionId: string,\n    path?: string,\n  ) => Promise<SessionFileEntry[]>;\n  onGetSessionFileUrl?: (sessionId: string, path: string) => string;\n  onGetSessionFile?: (sessionId: string, path: string) => Promise<Blob>;\n  onOpenCreateDialog?: () => void;\n  onOpenSidebar?: () => void;\n  generateTitle?: (sessionId: string) => Promise<string | null>;\n  onRenameSession?: (sessionId: string, newTitle: string) => Promise<boolean>;\n  onForkSession?: (sessionId: string, turnIndex: number) => Promise<void>;\n};\n\nexport function ChatWorkspaceContainer({\n  selectedSessionId,\n  currentSession,\n  sessionDescription,\n  onSessionStatus,\n  onStreamStatusChange,\n  uploadSessionFile,\n  onListSessionDirectory,\n  onGetSessionFileUrl,\n  onGetSessionFile,\n  onOpenCreateDialog,\n  onOpenSidebar,\n  generateTitle,\n  onRenameSession,\n  onForkSession,\n}: ChatWorkspaceContainerProps): ReactElement {\n  const [isUploadingFiles, setIsUploadingFiles] = useState(false);\n  // Pending message state for when we need to create a session first\n  const [pendingMessage, setPendingMessage] = useState<PendingMessage | null>(\n    null,\n  );\n  const sessionId = selectedSessionId || null;\n\n  const { config } = useGlobalConfig();\n  const maxContextSize = useMemo(() => {\n    if (!config) return undefined;\n    const model = config.models.find((m) => m.name === config.defaultModel);\n    return model?.maxContextSize;\n  }, [config]);\n\n  const handleStreamError = useCallback((error: Error) => {\n    toast.error(\"Connection Error\", {\n      description: error.message,\n    });\n  }, []);\n\n  // Handle first turn completion for auto-rename\n  // Backend reads messages from wire.jsonl automatically\n  const handleFirstTurnComplete = useCallback(async () => {\n    if (!(selectedSessionId && generateTitle)) {\n      return;\n    }\n\n    await generateTitle(selectedSessionId);\n  }, [selectedSessionId, generateTitle]);\n\n  const sessionStream = useSessionStream({\n    sessionId,\n    baseUrl: getApiBaseUrl(),\n    onError: handleStreamError,\n    onSessionStatus,\n    onFirstTurnComplete: handleFirstTurnComplete,\n  });\n\n  const {\n    messages,\n    status,\n    isAwaitingFirstResponse,\n    sendMessage,\n    respondToApproval,\n    respondToQuestion,\n    cancel: cancelStream,\n    contextUsage,\n    tokenUsage,\n    currentStep,\n    isConnected: isStreamConnected,\n    isReplayingHistory,\n    planMode,\n    sendSetPlanMode,\n    slashCommands,\n  } = sessionStream;\n\n  const clearNewFiles = useToolEventsStore((state) => state.clearNewFiles);\n  const enqueue = useQueueStore((s) => s.enqueue);\n  const queueLength = useQueueStore((s) => s.queue.length);\n  const dequeue = useQueueStore((s) => s.dequeue);\n  const clearQueue = useQueueStore((s) => s.clearQueue);\n\n  useEffect(() => {\n    if (status === \"streaming\") {\n      clearNewFiles();\n    }\n  }, [status, clearNewFiles]);\n\n  // Clear queue when session changes (must run before auto-send to prevent\n  // sending stale queued messages to the wrong session)\n  // biome-ignore lint/correctness/useExhaustiveDependencies: selectedSessionId triggers queue clear on session switch\n  useEffect(() => {\n    clearQueue();\n  }, [selectedSessionId, clearQueue]);\n\n  // Auto-send next queued message when status becomes ready\n  const prevStatusRef = useRef(status);\n  useEffect(() => {\n    const wasProcessing =\n      prevStatusRef.current === \"streaming\" ||\n      prevStatusRef.current === \"submitted\" ||\n      prevStatusRef.current === \"error\";\n    prevStatusRef.current = status;\n\n    if (status === \"ready\" && wasProcessing && queueLength > 0) {\n      const next = dequeue();\n      if (next) {\n        sendMessage(next.text);\n      }\n    }\n  }, [status, queueLength, dequeue, sendMessage]);\n\n  useEffect(() => {\n    onStreamStatusChange?.(status);\n  }, [status, onStreamStatusChange]);\n\n  useEffect(() => {\n    if (\n      !pendingMessage ||\n      pendingMessage.targetSessionId !== selectedSessionId ||\n      !isStreamConnected ||\n      (status !== \"ready\" && status !== \"streaming\")\n    ) {\n      return;\n    }\n\n    // Send only when the stream is connected to the intended session.\n    // Using state (not ref) ensures this effect re-runs even if connection\n    // happens before the pending message is set.\n    setPendingMessage(null);\n    sendMessage(pendingMessage.text);\n  }, [\n    isStreamConnected,\n    status,\n    selectedSessionId,\n    sendMessage,\n    pendingMessage,\n  ]);\n\n  useEffect(() => {\n    if (\n      !pendingMessage ||\n      pendingMessage.targetSessionId === selectedSessionId\n    ) {\n      return;\n    }\n\n    // Drop stale pending messages if the user switches away before it is sent.\n    setPendingMessage(null);\n  }, [pendingMessage, selectedSessionId]);\n\n  const uploadFilesToSession = useCallback(\n    async (targetSessionId: string, files: FileUIPart[]) => {\n      if (files.length === 0) {\n        return 0;\n      }\n\n      setIsUploadingFiles(true);\n      try {\n        const uploadResults = await Promise.all(\n          files.map(async (filePart) => {\n            if (!filePart.url) return false;\n\n            const response = await fetch(filePart.url);\n            const blob = await response.blob();\n            const file = new File([blob], filePart.filename ?? \"unnamed_file\", {\n              type: filePart.mediaType ?? blob.type,\n            });\n\n            const uploadResult = await uploadSessionFile(targetSessionId, file);\n            console.log(\n              \"[ChatWorkspaceContainer] File uploaded:\",\n              uploadResult,\n            );\n            return true;\n          }),\n        );\n\n        const uploadedCount = uploadResults.filter(Boolean).length;\n        if (uploadedCount > 0) {\n          toast.success(\"Files uploaded\", {\n            description:\n              uploadedCount === 1\n                ? \"1 file uploaded successfully.\"\n                : `${uploadedCount} files uploaded successfully.`,\n          });\n        }\n        return uploadedCount;\n      } catch (error) {\n        console.error(\n          \"[ChatWorkspaceContainer] Failed to upload files:\",\n          error,\n        );\n        toast.error(\"Failed to Upload Files\", {\n          description:\n            error instanceof Error ? error.message : \"File upload failed\",\n        });\n        return 0;\n      } finally {\n        setIsUploadingFiles(false);\n      }\n    },\n    [uploadSessionFile],\n  );\n\n  const handlePromptSubmit = useCallback(\n    async (message: PromptInputMessage) => {\n      const hasPayload =\n        message.text.trim().length > 0 || message.files.length > 0;\n      if (!hasPayload) {\n        toast.info(\"Empty Message\", {\n          description: \"Please enter a message or attach a file.\",\n        });\n        return;\n      }\n\n      if (isUploadingFiles) {\n        toast.info(\"Still uploading\", {\n          description: \"Please wait until file uploads finish.\",\n        });\n        return;\n      }\n\n      if (status === \"streaming\" || status === \"submitted\") {\n        // Queue text-only messages when AI is processing\n        if (message.files.length > 0) {\n          toast.info(\"Still processing\", {\n            description: \"File attachments cannot be queued. Please wait.\",\n          });\n          return;\n        }\n        const messageText = message.text.trim();\n        if (messageText) {\n          enqueue(messageText);\n          toast.info(\"Message queued\", {\n            description: \"It will be sent when the current response finishes.\",\n          });\n        }\n        return;\n      }\n\n      // Note: This check is defensive - the submit button is disabled when no session exists\n      if (!selectedSessionId) {\n        return;\n      }\n\n      const targetSessionId = selectedSessionId;\n\n      if (message.files.length > 0 && targetSessionId) {\n        await uploadFilesToSession(targetSessionId, message.files);\n      }\n\n      const messageText =\n        message.text.trim() ||\n        (message.files.length > 0 ? \"KIMI_FILE_UPLOAD_WITHOUT_MESSAGE\" : \"\");\n\n      await sendMessage(messageText);\n    },\n    [status, isUploadingFiles, selectedSessionId, uploadFilesToSession, sendMessage, enqueue],\n  );\n\n  const handlePlanModeChange = useCallback((enabled: boolean) => {\n    sendSetPlanMode(enabled);\n  }, [sendSetPlanMode]);\n\n  const handleForkSession = useCallback(\n    async (turnIndex: number) => {\n      if (!(selectedSessionId && onForkSession)) {\n        return;\n      }\n      try {\n        await onForkSession(selectedSessionId, turnIndex);\n        toast.success(\"Session forked successfully\");\n      } catch (error) {\n        toast.error(\"Fork failed\", {\n          description:\n            error instanceof Error ? error.message : \"Failed to fork session\",\n        });\n      }\n    },\n    [selectedSessionId, onForkSession],\n  );\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.defaultPrevented) {\n        return;\n      }\n\n      if (event.key.toLowerCase() !== \"o\") {\n        return;\n      }\n\n      const hasModifier = isMacOS() ? event.metaKey : event.ctrlKey;\n      if (!(hasModifier && event.shiftKey)) {\n        return;\n      }\n\n      event.preventDefault();\n      onOpenCreateDialog?.();\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [onOpenCreateDialog]);\n\n  return (\n    <ChatWorkspace\n      selectedSessionId={selectedSessionId}\n      messages={messages}\n      onSubmit={handlePromptSubmit}\n      status={status}\n      isUploadingFiles={isUploadingFiles}\n      onCreateSession={onOpenCreateDialog}\n      onCancel={cancelStream}\n      onApprovalResponse={respondToApproval}\n      onQuestionResponse={respondToQuestion}\n      sessionDescription={sessionDescription}\n      contextUsage={contextUsage}\n      maxContextSize={maxContextSize}\n      tokenUsage={tokenUsage}\n      currentStep={currentStep}\n      currentSession={currentSession}\n      isReplayingHistory={isReplayingHistory}\n      isAwaitingFirstResponse={isAwaitingFirstResponse}\n      onListSessionDirectory={onListSessionDirectory}\n      onGetSessionFileUrl={onGetSessionFileUrl}\n      onGetSessionFile={onGetSessionFile}\n      onOpenSidebar={onOpenSidebar}\n      onRenameSession={onRenameSession}\n      slashCommands={slashCommands}\n      planMode={planMode}\n      onPlanModeChange={handlePlanModeChange}\n      onForkSession={onForkSession ? handleForkSession : undefined}\n    />\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/chat.tsx",
    "content": "import {\n  memo,\n  type ReactElement,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type { ChatStatus } from \"ai\";\nimport type { PromptInputMessage } from \"@ai-elements\";\nimport type { ApprovalResponseDecision, TokenUsage } from \"@/hooks/wireTypes\";\nimport type { LiveMessage } from \"@/hooks/types\";\nimport type { SessionFileEntry } from \"@/hooks/useSessions\";\nimport type { SlashCommandDef } from \"@/hooks/useSessionStream\";\nimport type { Session } from \"@/lib/api/models\";\n\n// Re-export SlashCommandDef for convenience\nexport type { SlashCommandDef };\nimport { toast } from \"sonner\";\nimport { ChatWorkspaceHeader } from \"./components/chat-workspace-header\";\nimport { ChatConversation } from \"./components/chat-conversation\";\nimport { ChatPromptComposer } from \"./components/chat-prompt-composer\";\nimport { ApprovalDialog } from \"./components/approval-dialog\";\nimport { QuestionDialog, usePendingQuestion } from \"./components/question-dialog\";\nimport { useGitDiffStats } from \"@/hooks/useGitDiffStats\";\nimport {\n  deriveActivityStatus,\n  type ActivityDetail,\n} from \"./components/activity-status-indicator\";\n\n// Re-export LiveMessage type from hooks for backward compatibility\nexport type { LiveMessage } from \"@/hooks/types\";\n\ntype ChatWorkspaceProps = {\n  status: ChatStatus;\n  onSubmit: (message: PromptInputMessage) => Promise<void>;\n  messages: LiveMessage[];\n  /** Selected session ID (may be set before session metadata loads) */\n  selectedSessionId?: string;\n  onApprovalResponse?: (\n    requestId: string,\n    decision: ApprovalResponseDecision,\n    reason?: string,\n  ) => Promise<void>;\n  onQuestionResponse?: (\n    requestId: string,\n    answers: Record<string, string>,\n  ) => Promise<void>;\n  sessionDescription?: string;\n  /** Context usage (0-1) */\n  contextUsage?: number;\n  /** Current step token usage from backend */\n  tokenUsage?: TokenUsage | null;\n  /** Current step number */\n  currentStep?: number;\n  /** Current session configuration */\n  currentSession?: Session;\n  /** Whether the stream is still replaying history */\n  isReplayingHistory?: boolean;\n  /** List files inside the session workspace */\n  onListSessionDirectory?: (\n    sessionId: string,\n    path?: string,\n  ) => Promise<SessionFileEntry[]>;\n  /** Build a direct download URL for a workspace file */\n  onGetSessionFileUrl?: (sessionId: string, path: string) => string;\n  /** Fetch a workspace file as a Blob for preview */\n  onGetSessionFile?: (sessionId: string, path: string) => Promise<Blob>;\n  /** Cancel the current streaming turn */\n  onCancel?: () => void;\n  /** Whether files are uploading before sending */\n  isUploadingFiles?: boolean;\n  /** Whether waiting for the first response after a prompt is sent */\n  isAwaitingFirstResponse?: boolean;\n  /** Create a new session when none is selected */\n  onCreateSession?: () => void;\n  /** Open sessions sidebar (mobile) */\n  onOpenSidebar?: () => void;\n  /** Rename session */\n  onRenameSession?: (sessionId: string, newTitle: string) => Promise<boolean>;\n  /** Available slash commands */\n  slashCommands?: SlashCommandDef[];\n  /** Whether plan mode is active */\n  planMode?: boolean;\n  /** Callback to set plan mode */\n  onPlanModeChange?: (enabled: boolean) => void;\n  /** Maximum context size for the current model (tokens) */\n  maxContextSize?: number;\n  /** Fork session at a specific turn */\n  onForkSession?: (turnIndex: number) => void;\n};\n\ntype ToolApproval = NonNullable<LiveMessage[\"toolCall\"]>[\"approval\"];\n\nexport const ChatWorkspace = memo(function ChatWorkspaceComponent({\n  status,\n  onSubmit,\n  messages,\n  selectedSessionId,\n  onApprovalResponse,\n  onQuestionResponse,\n  sessionDescription,\n  contextUsage = 0,\n  tokenUsage = null,\n  currentStep = 0,\n  currentSession,\n  isReplayingHistory = false,\n  onListSessionDirectory,\n  onGetSessionFileUrl: _onGetSessionFileUrl,\n  onGetSessionFile: _onGetSessionFile,\n  onCancel,\n  isUploadingFiles = false,\n  isAwaitingFirstResponse = false,\n  onCreateSession,\n  onOpenSidebar,\n  onRenameSession,\n  maxContextSize,\n  slashCommands = [],\n  planMode = false,\n  onPlanModeChange,\n  onForkSession,\n}: ChatWorkspaceProps): ReactElement {\n  const [blocksExpanded, setBlocksExpanded] = useState(false);\n  const [isSearchOpen, setIsSearchOpen] = useState(false);\n  const [pendingApprovalMap, setPendingApprovalMap] = useState<\n    Record<string, boolean>\n  >({});\n  const [pendingQuestionMap, setPendingQuestionMap] = useState<\n    Record<string, boolean>\n  >({});\n\n  // Check if there's a pending question to replace the prompt composer\n  const hasPendingQuestion = usePendingQuestion(messages) !== null;\n\n  // Fetch git diff stats for the current session\n  const { stats: gitDiffStats, isLoading: isGitDiffLoading } = useGitDiffStats(\n    currentSession?.sessionId ?? null\n  );\n\n  // Derive activity status for the header indicator\n  // Use ref to cache the previous result and avoid unnecessary object reference changes\n  const prevActivityRef = useRef<ActivityDetail | null>(null);\n\n  const activityStatus = useMemo(() => {\n    const newStatus = deriveActivityStatus({\n      chatStatus: status,\n      isAwaitingFirstResponse,\n      isReplayingHistory,\n      isUploadingFiles,\n      messages,\n    });\n\n    // If status and description haven't changed, return cached reference\n    // to avoid unnecessary re-renders in downstream components\n    if (\n      prevActivityRef.current &&\n      prevActivityRef.current.status === newStatus.status &&\n      prevActivityRef.current.description === newStatus.description\n    ) {\n      return prevActivityRef.current;\n    }\n\n    prevActivityRef.current = newStatus;\n    return newStatus;\n  }, [status, isAwaitingFirstResponse, isReplayingHistory, isUploadingFiles, messages]);\n\n  const maxTokens = maxContextSize ?? 64000;\n  const usedTokens = Math.round(contextUsage * maxTokens);\n  const usagePercent = Math.round(contextUsage * 1000) / 10;\n\n  const canSendMessage = true;\n  const isStreaming = status === \"streaming\";\n  const isAwaitingIdle = status === \"submitted\";\n  const isUploading = isUploadingFiles;\n\n  const handleApprovalAction = useCallback(\n    async (approval: ToolApproval, decision: ApprovalResponseDecision) => {\n      if (!(approval?.id && onApprovalResponse)) {\n        return;\n      }\n\n      setPendingApprovalMap((prev) => ({\n        ...prev,\n        [approval.id]: true,\n      }));\n\n      try {\n        await onApprovalResponse(approval.id, decision);\n      } catch (error) {\n        console.error(\"[ChatWorkspace] Failed to respond to approval\", error);\n        toast.error(\"Approval action failed\", {\n          description: error instanceof Error ? error.message : String(error),\n        });\n      } finally {\n        setPendingApprovalMap((prev) => {\n          const next = { ...prev };\n          delete next[approval.id];\n          return next;\n        });\n      }\n    },\n    [onApprovalResponse],\n  );\n\n  // Wrapper for ApprovalDialog that routes through handleApprovalAction\n  // so pendingApprovalMap is properly managed (prevents duplicate requests)\n  const handleDialogApprovalResponse = useCallback(\n    async (requestId: string, decision: ApprovalResponseDecision) => {\n      for (const message of messages) {\n        if (\n          message.variant === \"tool\" &&\n          message.toolCall?.approval?.id === requestId\n        ) {\n          await handleApprovalAction(message.toolCall.approval, decision);\n          return;\n        }\n      }\n    },\n    [messages, handleApprovalAction],\n  );\n\n  const handleQuestionResponse = useCallback(\n    async (requestId: string, answers: Record<string, string>) => {\n      if (!onQuestionResponse) return;\n\n      setPendingQuestionMap((prev) => ({\n        ...prev,\n        [requestId]: true,\n      }));\n\n      try {\n        await onQuestionResponse(requestId, answers);\n      } catch (error) {\n        console.error(\"[ChatWorkspace] Failed to respond to question\", error);\n        toast.error(\"Question response failed\", {\n          description: error instanceof Error ? error.message : String(error),\n        });\n      } finally {\n        setPendingQuestionMap((prev) => {\n          const next = { ...prev };\n          delete next[requestId];\n          return next;\n        });\n      }\n    },\n    [onQuestionResponse],\n  );\n\n  return (\n    <div className=\"flex h-full min-h-0 w-full flex-col overflow-hidden lg:sticky lg:top-4 lg:min-h-[560px]\">\n      <div className=\"relative flex h-full flex-col\">\n        <ChatWorkspaceHeader\n          currentStep={currentStep}\n          sessionDescription={sessionDescription}\n          currentSession={currentSession}\n          selectedSessionId={selectedSessionId}\n          blocksExpanded={blocksExpanded}\n          onToggleBlocks={() => setBlocksExpanded((prev) => !prev)}\n          onOpenSearch={() => setIsSearchOpen(true)}\n          onOpenSidebar={onOpenSidebar}\n          onRenameSession={onRenameSession}\n        />\n\n        <div className=\"flex-1 overflow-hidden min-h-0\">\n          <ChatConversation\n            messages={messages}\n            status={status}\n            selectedSessionId={selectedSessionId}\n            currentSession={currentSession}\n            isReplayingHistory={isReplayingHistory}\n            pendingApprovalMap={pendingApprovalMap}\n            onApprovalAction={\n              onApprovalResponse ? handleApprovalAction : undefined\n            }\n            canRespondToApproval={Boolean(onApprovalResponse)}\n            blocksExpanded={blocksExpanded}\n            onCreateSession={onCreateSession}\n            isSearchOpen={isSearchOpen}\n            onSearchOpenChange={setIsSearchOpen}\n            onForkSession={onForkSession}\n          />\n        </div>\n\n        {/* Approval Dialog - shows above input when approval is needed */}\n        <ApprovalDialog\n          messages={messages}\n          onApprovalResponse={handleDialogApprovalResponse}\n          pendingApprovalMap={pendingApprovalMap}\n          canRespondToApproval={Boolean(onApprovalResponse)}\n        />\n\n        {/* Bottom area: Question Dialog replaces prompt composer when active */}\n        {currentSession && (\n          <div className=\"mt-auto flex-shrink-0\">\n            {hasPendingQuestion ? (\n              <QuestionDialog\n                messages={messages}\n                onQuestionResponse={handleQuestionResponse}\n                pendingQuestionMap={pendingQuestionMap}\n              />\n            ) : (\n              <div className=\"px-0 pb-0 pt-0 sm:px-3 sm:pb-3\">\n                <ChatPromptComposer\n                  status={status}\n                  onSubmit={onSubmit}\n                  canSendMessage={canSendMessage}\n                  currentSession={currentSession}\n                  isUploading={isUploading}\n                  isStreaming={isStreaming}\n                  isAwaitingIdle={isAwaitingIdle}\n                  isReplayingHistory={isReplayingHistory}\n                  onCancel={onCancel}\n                  onListSessionDirectory={onListSessionDirectory}\n                  gitDiffStats={gitDiffStats}\n                  isGitDiffLoading={isGitDiffLoading}\n                  slashCommands={slashCommands}\n                  planMode={planMode}\n                  onPlanModeChange={onPlanModeChange}\n                  activityStatus={activityStatus}\n                  usagePercent={usagePercent}\n                  usedTokens={usedTokens}\n                  maxTokens={maxTokens}\n                  tokenUsage={tokenUsage}\n                />\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n});\n"
  },
  {
    "path": "web/src/features/chat/components/activity-status-indicator.tsx",
    "content": "import { memo, type ReactElement } from \"react\";\nimport type { ChatStatus } from \"ai\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Loader } from \"@/components/ai-elements/loader\";\nimport type { LiveMessage } from \"@/hooks/types\";\nimport { cn } from \"@/lib/utils\";\n\n// --- Type Definitions ---\n\nexport type ActivityStatus = \"idle\" | \"connecting\" | \"processing\" | \"waiting_input\" | \"error\";\n\nexport type ActivityDetail = {\n  status: ActivityStatus;\n  description: string;\n};\n\n// --- Tool Name Mapping ---\n\nconst TOOL_DISPLAY_NAMES: Record<string, string> = {\n  Read: \"Reading files...\",\n  Write: \"Writing files...\",\n  Edit: \"Editing code...\",\n  Bash: \"Running command...\",\n  Glob: \"Searching files...\",\n  Grep: \"Searching content...\",\n  WebFetch: \"Fetching web content...\",\n  WebSearch: \"Searching the web...\",\n  Task: \"Running agent...\",\n  NotebookEdit: \"Editing notebook...\",\n};\n\n// --- Status Derivation ---\n\ntype DeriveActivityStatusParams = {\n  chatStatus: ChatStatus;\n  isAwaitingFirstResponse: boolean;\n  isReplayingHistory: boolean;\n  isUploadingFiles: boolean;\n  messages: LiveMessage[];\n};\n\n/**\n * Derives the current activity status from chat state and messages.\n */\nexport function deriveActivityStatus({\n  chatStatus,\n  isAwaitingFirstResponse,\n  isReplayingHistory,\n  isUploadingFiles,\n  messages,\n}: DeriveActivityStatusParams): ActivityDetail {\n  // Check for pending approval requests (search from end for efficiency)\n  if (findPendingApproval(messages)) {\n    return {\n      status: \"waiting_input\",\n      description: \"Waiting for approval...\",\n    };\n  }\n\n  // Handle uploading files state\n  if (isUploadingFiles) {\n    return {\n      status: \"processing\",\n      description: \"Uploading files...\",\n    };\n  }\n\n  // Handle error state\n  if (chatStatus === \"error\") {\n    return {\n      status: \"error\",\n      description: \"An error occurred\",\n    };\n  }\n\n  // Handle submitted state (waiting for first response)\n  if (chatStatus === \"submitted\" || isAwaitingFirstResponse) {\n    return {\n      status: \"connecting\",\n      description: \"Connecting...\",\n    };\n  }\n\n  // Handle streaming state\n  if (chatStatus === \"streaming\") {\n    // History replay - not AI thinking\n    if (isReplayingHistory) {\n      return {\n        status: \"processing\",\n        description: \"Loading history...\",\n      };\n    }\n\n    // Find the most recent in-progress tool call\n    const activeToolCall = findActiveToolCall(messages);\n\n    if (activeToolCall) {\n      const toolName = extractToolName(activeToolCall);\n      const displayText = TOOL_DISPLAY_NAMES[toolName] || `Running ${toolName}...`;\n      return {\n        status: \"processing\",\n        description: displayText,\n      };\n    }\n\n    // No active tool call - model is thinking\n    return {\n      status: \"processing\",\n      description: \"Thinking...\",\n    };\n  }\n\n  // Default idle state\n  return {\n    status: \"idle\",\n    description: \"Awaiting input\",\n  };\n}\n\n/**\n * Finds the most recent pending approval request.\n * Searches from end for efficiency since pending approvals are likely in recent messages.\n */\nfunction findPendingApproval(messages: LiveMessage[]): LiveMessage[\"toolCall\"] | null {\n  for (let i = messages.length - 1; i >= 0; i--) {\n    const msg = messages[i];\n    if (\n      msg.toolCall?.approval &&\n      !msg.toolCall.approval.resolved &&\n      msg.toolCall.state === \"approval-requested\"\n    ) {\n      return msg.toolCall;\n    }\n  }\n  return null;\n}\n\n/**\n * Finds the most recent tool call that is in progress.\n * Active states are: \"input-streaming\" (streaming input) or \"input-available\" (executing).\n */\nfunction findActiveToolCall(messages: LiveMessage[]): LiveMessage[\"toolCall\"] | null {\n  // Iterate from the end to find the most recent active tool\n  for (let i = messages.length - 1; i >= 0; i--) {\n    const msg = messages[i];\n    if (\n      msg.toolCall &&\n      (msg.toolCall.state === \"input-streaming\" ||\n        msg.toolCall.state === \"input-available\") &&\n      msg.role === \"assistant\"\n    ) {\n      return msg.toolCall;\n    }\n  }\n  return null;\n}\n\n/**\n * Extracts the tool name from a tool call.\n */\nfunction extractToolName(toolCall: NonNullable<LiveMessage[\"toolCall\"]>): string {\n  // The title often contains the tool name, e.g., \"Read: /path/to/file\"\n  const title = toolCall.title || \"\";\n  const colonIndex = title.indexOf(\":\");\n  if (colonIndex > 0) {\n    return title.substring(0, colonIndex).trim();\n  }\n  // Fallback to full title\n  return title || \"Tool\";\n}\n\n// --- Status Indicator Component ---\n\ntype ActivityStatusIndicatorProps = {\n  activity: ActivityDetail;\n  showDescription?: boolean;\n  className?: string;\n};\n\nconst STATUS_COLORS: Record<ActivityStatus, string> = {\n  idle: \"bg-muted-foreground/50\",\n  connecting: \"bg-blue-500\",\n  processing: \"bg-green-500\",\n  waiting_input: \"bg-yellow-500\",\n  error: \"bg-red-500\",\n};\n\nconst STATUS_PULSE_COLORS: Record<ActivityStatus, string> = {\n  idle: \"\",\n  connecting: \"bg-blue-500/50\",\n  processing: \"bg-green-500/50\",\n  waiting_input: \"bg-yellow-500/50\",\n  error: \"bg-red-500/50\",\n};\n\nexport const ActivityStatusIndicator = memo(function ActivityStatusIndicatorComponent({\n  activity,\n  showDescription = true,\n  className,\n}: ActivityStatusIndicatorProps): ReactElement {\n  const { status, description } = activity;\n  const isActive = status !== \"idle\";\n  const showSpinner = status === \"processing\";\n\n  return (\n    <output\n      aria-live=\"polite\"\n      aria-atomic=\"true\"\n      className={cn(\"flex items-center gap-1.5\", className)}\n    >\n      {/* Status indicator dot with optional pulse animation */}\n      <div className=\"relative flex items-center justify-center\">\n        {isActive && (\n          <motion.div\n            className={cn(\n              \"absolute size-2.5 rounded-full\",\n              STATUS_PULSE_COLORS[status]\n            )}\n            animate={{\n              scale: [1, 1.8, 1],\n              opacity: [0.6, 0, 0.6],\n            }}\n            transition={{\n              duration: 1.5,\n              repeat: Number.POSITIVE_INFINITY,\n              ease: \"easeInOut\",\n            }}\n          />\n        )}\n        <div\n          className={cn(\n            \"size-2 rounded-full transition-colors duration-200\",\n            STATUS_COLORS[status]\n          )}\n        />\n      </div>\n\n      {/* Spinner for processing state */}\n      {showSpinner && (\n        <Loader size={12} className=\"text-muted-foreground\" />\n      )}\n\n      {/* Description text */}\n      <AnimatePresence mode=\"wait\">\n        {showDescription && (\n          <motion.span\n            key={description}\n            initial={{ opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: 4 }}\n            transition={{ duration: 0.15 }}\n            className=\"text-xs text-muted-foreground select-none\"\n          >\n            {description}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </output>\n  );\n});\n\n// --- Toolbar-specific Activity Indicator ---\n\nexport const ToolbarActivityIndicator = memo(function ToolbarActivityIndicatorComponent({\n  activity,\n  className,\n}: {\n  activity: ActivityDetail;\n  className?: string;\n}): ReactElement {\n  const { status, description } = activity;\n  const isActive = status !== \"idle\" && status !== \"error\";\n  const isError = status === \"error\";\n\n  return (\n    <output\n      aria-live=\"polite\"\n      aria-atomic=\"true\"\n      className={cn(\n        \"flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs font-medium border select-none transition-colors\",\n        !(isActive || isError ) && \"bg-transparent text-muted-foreground border-transparent\",\n        isActive && \"bg-transparent text-muted-foreground border-border/60\",\n        isError && \"bg-transparent text-red-500 border-red-500/30\",\n        className,\n      )}\n    >\n      {/* Status dot with optional pulse */}\n      <div className=\"relative flex items-center justify-center\">\n        {(isActive || isError) && (\n          <motion.div\n            className={cn(\n              \"absolute size-2.5 rounded-full\",\n              STATUS_PULSE_COLORS[status],\n            )}\n            animate={{\n              scale: [1, 1.8, 1],\n              opacity: [0.6, 0, 0.6],\n            }}\n            transition={{\n              duration: 1.5,\n              repeat: Number.POSITIVE_INFINITY,\n              ease: \"easeInOut\",\n            }}\n          />\n        )}\n        <div\n          className={cn(\n            \"size-1.5 rounded-full transition-colors duration-200\",\n            STATUS_COLORS[status],\n          )}\n        />\n      </div>\n\n\n      {/* Description text with animated transitions */}\n      <AnimatePresence mode=\"wait\">\n        <motion.span\n          key={description}\n          initial={{ opacity: 0, y: -4 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 4 }}\n          transition={{ duration: 0.15 }}\n          className=\"whitespace-nowrap\"\n        >\n          {description}\n        </motion.span>\n      </AnimatePresence>\n    </output>\n  );\n});\n"
  },
  {
    "path": "web/src/features/chat/components/approval-dialog.tsx",
    "content": "import { useCallback, useEffect, useMemo } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\nimport type { ApprovalResponseDecision } from \"@/hooks/wireTypes\";\nimport type { LiveMessage } from \"@/hooks/types\";\n\ntype ApprovalDialogProps = {\n  messages: LiveMessage[];\n  onApprovalResponse?: (\n    requestId: string,\n    decision: ApprovalResponseDecision,\n    reason?: string,\n  ) => Promise<void>;\n  pendingApprovalMap: Record<string, boolean>;\n  canRespondToApproval: boolean;\n};\n\nexport function ApprovalDialog({\n  messages,\n  onApprovalResponse,\n  pendingApprovalMap,\n  canRespondToApproval,\n}: ApprovalDialogProps) {\n  // from messages, extract the pending approval request\n  const pendingApproval = useMemo(() => {\n    for (const message of messages) {\n      if (\n        message.variant === \"tool\" &&\n        message.toolCall?.approval &&\n        message.toolCall.state === \"approval-requested\" &&\n        !message.toolCall.approval.submitted\n      ) {\n        return {\n          message,\n          approval: message.toolCall.approval,\n          toolCall: message.toolCall,\n        };\n      }\n    }\n    return null;\n  }, [messages]);\n\n  const handleResponse = useCallback(\n    async (decision: ApprovalResponseDecision) => {\n      if (!(pendingApproval && onApprovalResponse)) return;\n\n      const { approval } = pendingApproval;\n      if (!approval.id) return;\n\n      try {\n        await onApprovalResponse(approval.id, decision);\n      } catch (error) {\n        console.error(\"[ApprovalDialog] Failed to respond\", error);\n      }\n    },\n    [pendingApproval, onApprovalResponse],\n  );\n\n  // Compute disable state before early return (hooks must run unconditionally)\n  const approvalId = pendingApproval?.approval?.id;\n  const approvalPending = approvalId\n    ? pendingApprovalMap[approvalId] === true\n    : false;\n  const disableActions =\n    !(canRespondToApproval && onApprovalResponse) || approvalPending;\n\n  // Keyboard shortcuts: 1=Approve, 2=Approve for session, 3=Decline\n  useEffect(() => {\n    if (!pendingApproval || disableActions) return;\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.defaultPrevented) return;\n      if (event.repeat) return;\n      if (event.isComposing) return;\n      if (event.metaKey || event.ctrlKey || event.altKey) return;\n\n      // Skip when any input element is focused\n      const el = document.activeElement;\n      if (el) {\n        const tag = el.tagName;\n        if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n        if ((el as HTMLElement).isContentEditable) return;\n      }\n\n      const keyMap: Record<string, ApprovalResponseDecision> = {\n        \"1\": \"approve\",\n        \"2\": \"approve_for_session\",\n        \"3\": \"reject\",\n      };\n      const decision = keyMap[event.key];\n      if (decision) {\n        event.preventDefault();\n        handleResponse(decision);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [pendingApproval, disableActions, handleResponse]);\n\n  // if no pending approval request, do not render anything\n  if (!pendingApproval) return null;\n\n  const { approval, toolCall } = pendingApproval;\n\n  const options = [\n    { key: \"approve\", label: \"Approve\", pendingLabel: \"Approving...\", index: 1 },\n    {\n      key: \"approve_for_session\",\n      label: \"Approve for session\",\n      pendingLabel: \"Approving...\",\n      index: 2,\n    },\n    { key: \"reject\", label: \"Decline\", pendingLabel: \"Declining...\", index: 3 },\n  ] as const;\n\n  return (\n    <div className=\"px-3 pb-2 w-full\">\n      <div\n        role=\"alert\"\n        className={cn(\n          \"relative w-full border border-border/60 shadow-xs\",\n          \"border-l border-l-blue-400/50\",\n          \"rounded-lg px-4 py-3\",\n          \"transition-all duration-200\",\n          \"max-h-[70vh]\",\n          \"overflow-hidden\",\n        )}\n      >\n        <div className=\"flex flex-col gap-2.5\">\n          {/* Header */}\n          <div className=\"flex items-center gap-2\">\n            <div className=\"size-2 rounded-full bg-blue-400 animate-pulse flex-shrink-0\" />\n            <div className=\"font-semibold text-sm text-foreground\">\n              Allow this {approval.action}?\n            </div>\n            {approval.sender && (\n              <span className=\"text-xs text-muted-foreground\">\n                · {approval.sender}\n              </span>\n            )}\n          </div>\n\n          {/* Description */}\n          {approval.description && (\n            <div className=\"rounded-md bg-muted/50 px-3 py-2 w-full max-h-44 overflow-auto\">\n              <pre className=\"font-mono text-xs whitespace-pre-wrap text-foreground/90\">\n                {approval.description}\n              </pre>\n            </div>\n          )}\n\n          {/* Display blocks (if any) */}\n          {toolCall.display && toolCall.display.length > 0 && (\n            <div className=\"rounded-md bg-muted/30 px-3 py-2 text-sm max-h-40 overflow-auto\">\n              {toolCall.display.map((item) => {\n                const displayKeyBase =\n                  typeof item.data === \"string\" ||\n                  typeof item.data === \"number\" ||\n                  typeof item.data === \"boolean\"\n                    ? `${item.type}:${item.data}`\n                    : item.data == null\n                      ? `${item.type}:null`\n                      : (() => {\n                          try {\n                            return `${item.type}:${JSON.stringify(item.data)}`;\n                          } catch {\n                            return `${item.type}:unserializable`;\n                          }\n                        })();\n                const displayKey = `${toolCall.toolCallId ?? toolCall.title}:${displayKeyBase}`;\n\n                return (\n                  <div key={displayKey} className=\"font-mono text-xs\">\n                    {JSON.stringify(item, null, 2)}\n                  </div>\n                );\n              })}\n            </div>\n          )}\n\n          {/* Action buttons */}\n          <div className=\"flex flex-wrap items-center gap-2\">\n            {options.map((option) => (\n              <Button\n                key={option.key}\n                size=\"sm\"\n                variant={option.key === \"reject\" ? \"ghost\" : \"outline\"}\n                disabled={disableActions}\n                onClick={() => handleResponse(option.key)}\n                className={cn(\n                  \"transition-all\",\n                  option.key === \"reject\" &&\n                    \"text-muted-foreground hover:text-destructive hover:bg-destructive/10\",\n                )}\n              >\n                {approvalPending\n                  ? option.pendingLabel\n                  : option.label}\n                {!approvalPending && (\n                  <Kbd className=\"ml-1.5\">{option.index}</Kbd>\n                )}\n              </Button>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/assistant-message.tsx",
    "content": "import { useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { ApprovalResponseDecision } from \"@/hooks/wireTypes\";\nimport type { LiveMessage } from \"@/hooks/types\";\nimport {\n  ChainOfThought,\n  ChainOfThoughtContent,\n  ChainOfThoughtHeader,\n  ChainOfThoughtSearchResult,\n  ChainOfThoughtSearchResults,\n  ChainOfThoughtStep as ChainOfThoughtStepItem,\n  Confirmation,\n  ConfirmationAccepted,\n  ConfirmationAction,\n  ConfirmationActions,\n  ConfirmationRejected,\n  ConfirmationRequest,\n  ConfirmationTitle,\n  CodeBlock,\n  MessageContent,\n  MessageResponse,\n  Reasoning,\n  ReasoningContent,\n  ReasoningTrigger,\n  SubagentActivity,\n  Tool,\n  ToolContent,\n  ToolDisplay,\n  ToolHeader,\n  ToolInput,\n  ToolMediaPreview,\n  ToolOutput,\n} from \"@ai-elements\";\nimport { BrainIcon, ChevronRightIcon } from \"lucide-react\";\n\nexport type ToolApproval = NonNullable<LiveMessage[\"toolCall\"]>[\"approval\"];\n\nexport type AssistantApprovalHandler = (\n  approval: ToolApproval,\n  decision: ApprovalResponseDecision,\n) => void | Promise<void>;\n\nconst assistantContentClass =\n  \"w-full max-w-full text-sm leading-relaxed overflow-visible\";\nconst assistantMetaTextClass = \"text-xs text-muted-foreground\";\n\ntype AssistantMessageProps = {\n  message: LiveMessage;\n  pendingApprovalMap: Record<string, boolean>;\n  onApprovalAction?: AssistantApprovalHandler;\n  canRespondToApproval: boolean;\n  blocksExpanded: boolean;\n};\n\nexport function AssistantMessage({\n  message,\n  pendingApprovalMap,\n  onApprovalAction,\n  canRespondToApproval,\n  blocksExpanded,\n}: AssistantMessageProps) {\n  const content = useMemo(() => {\n    switch (message.variant) {\n      case \"chain-of-thought\":\n        return renderChainOfThoughtMessage(message);\n      case \"tool\":\n        return renderToolMessage({\n          message,\n          pendingApprovalMap,\n          onApprovalAction,\n          canRespondToApproval,\n          blocksExpanded,\n        });\n      case \"code\":\n        return renderCodeMessage(message);\n      case \"thinking\":\n        return renderThinkingMessage(message, blocksExpanded);\n      default:\n        return renderAssistantText(message);\n    }\n  }, [\n    message,\n    pendingApprovalMap,\n    onApprovalAction,\n    canRespondToApproval,\n    blocksExpanded,\n  ]);\n\n  return content;\n}\n\nconst renderAssistantText = (message: LiveMessage) => {\n  return (\n    <MessageContent className={assistantContentClass}>\n      <div className=\"flex items-start gap-2\">\n        <div className=\"relative mt-1.5 shrink-0 size-2\">\n          <span\n            className={cn(\n              \"absolute inset-0 rounded-full transition-all\",\n              message.isStreaming\n                ? \"bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)] animate-[glow-pulse_1.5s_ease-in-out_infinite]\"\n                : \"bg-muted-foreground/40\",\n            )}\n          />\n        </div>\n        <div className=\"flex-1 min-w-0\">\n          <MessageResponse\n            className=\"wrap-break-word\"\n            mode={message.isStreaming ? \"streaming\" : \"static\"}\n            parseIncompleteMarkdown={Boolean(message.isStreaming)}\n          >\n            {message.content || \"Thinking through the response...\"}\n          </MessageResponse>\n        </div>\n      </div>\n    </MessageContent>\n  );\n};\n\nconst renderChainOfThoughtMessage = (message: LiveMessage) => {\n  const details = message.chainOfThought;\n  if (!details) {\n    return renderAssistantText(message);\n  }\n  const visibleSteps = details.steps.slice(0, details.revealedSteps);\n\n  return (\n    <MessageContent className={assistantContentClass}>\n      <ChainOfThought className=\"space-y-3\">\n        <ChainOfThoughtHeader>{details.title}</ChainOfThoughtHeader>\n        <ChainOfThoughtContent>\n          {visibleSteps.map((step, index) => {\n            const isLast = index === visibleSteps.length - 1;\n            const status: \"complete\" | \"active\" =\n              message.isStreaming && isLast ? \"active\" : \"complete\";\n            return (\n              <ChainOfThoughtStepItem\n                description={step.description}\n                key={`${message.id}-cot-${index}`}\n                label={step.label}\n                status={status}\n              />\n            );\n          })}\n          {details.relatedSources && details.relatedSources.length > 0 ? (\n            <ChainOfThoughtSearchResults className=\"pt-1\">\n              {details.relatedSources.map((source) => (\n                <ChainOfThoughtSearchResult key={`${message.id}-${source}`}>\n                  {source}\n                </ChainOfThoughtSearchResult>\n              ))}\n            </ChainOfThoughtSearchResults>\n          ) : null}\n        </ChainOfThoughtContent>\n      </ChainOfThought>\n      {message.isStreaming ? (\n        <div className={`mt-2 ${assistantMetaTextClass}`}>\n          Reasoning through the request…\n        </div>\n      ) : null}\n    </MessageContent>\n  );\n};\n\nconst renderToolMessage = ({\n  message,\n  pendingApprovalMap,\n  onApprovalAction,\n  canRespondToApproval,\n  blocksExpanded,\n}: {\n  message: LiveMessage;\n  pendingApprovalMap: Record<string, boolean>;\n  onApprovalAction?: AssistantApprovalHandler;\n  canRespondToApproval: boolean;\n  blocksExpanded: boolean;\n}) => {\n  const toolCall = message.toolCall;\n  if (!toolCall) {\n    return renderAssistantText(message);\n  }\n\n  // Think tool: render as lightweight reasoning-style block\n  if (toolCall.title === \"Think\") {\n    return renderThinkToolMessage(message, blocksExpanded);\n  }\n\n  const shouldShowOutput = Boolean(\n    toolCall.output ?? toolCall.errorText ?? toolCall.display,\n  );\n  const approval = toolCall.approval;\n  const approvalId = approval?.id;\n  const approvalResponse =\n    typeof approval?.response === \"string\" ? approval.response : undefined;\n  const isApprovalRequested = toolCall.state === \"approval-requested\";\n  const isApprovalDenied = toolCall.state === \"output-denied\";\n  const approvalPending =\n    approvalId !== undefined ? pendingApprovalMap[approvalId] === true : false;\n  const disableApprovalActions = !(\n    canRespondToApproval &&\n    onApprovalAction &&\n    !approvalPending &&\n    !approval?.submitted &&\n    isApprovalRequested\n  );\n\n  return (\n    <>\n      <Tool\n        key={`${message.id}-${blocksExpanded}`}\n        defaultOpen={blocksExpanded}\n      >\n        <ToolHeader\n          state={toolCall.state}\n          title={toolCall.title}\n          type={toolCall.type}\n          input={toolCall.input}\n        />\n        <ToolContent>\n          {toolCall.input ? <ToolInput input={toolCall.input} /> : null}\n          <ToolDisplay display={toolCall.display} isError={toolCall.isError} />\n          {toolCall.mediaParts ? <ToolMediaPreview mediaParts={toolCall.mediaParts} /> : null}\n          {toolCall.subagentSteps && toolCall.subagentSteps.length > 0 ? (\n            <SubagentActivity\n              steps={toolCall.subagentSteps}\n              isRunning={toolCall.subagentRunning}\n              defaultOpen={blocksExpanded}\n            />\n          ) : null}\n          {shouldShowOutput ? (\n            <ToolOutput\n              errorText={toolCall.errorText}\n              output={toolCall.output}\n              message={toolCall.message}\n            />\n          ) : null}\n          {approval ? (\n            <Confirmation\n              approval={approval}\n              state={toolCall.state}\n              className=\"rounded-md bg-muted/30 px-3 py-2.5 text-sm\"\n            >\n              <ConfirmationTitle>\n                Manual approval required by {approval.sender}\n              </ConfirmationTitle>\n              <ConfirmationRequest>\n                <div className=\"text-sm text-muted-foreground\">\n                  <p>\n                    <span className=\"font-medium text-foreground\">Action:</span>{\" \"}\n                    {approval.action}\n                  </p>\n                  {approval.description ? (\n                    <p className=\"mt-2 text-foreground\">\n                      {approval.description}\n                    </p>\n                  ) : null}\n                </div>\n                <ConfirmationActions className=\"mt-2 gap-2\">\n                  <ConfirmationAction\n                    disabled={disableApprovalActions}\n                    onClick={() =>\n                      approval && onApprovalAction?.(approval, \"reject\")\n                    }\n                    variant=\"outline\"\n                  >\n                    {approvalPending ? \"Declining…\" : \"Decline\"}\n                  </ConfirmationAction>\n                  <ConfirmationAction\n                    disabled={disableApprovalActions}\n                    onClick={() =>\n                      approval && onApprovalAction?.(approval, \"approve\")\n                    }\n                  >\n                    {approvalPending ? \"Confirming…\" : \"Approve\"}\n                  </ConfirmationAction>\n                  <ConfirmationAction\n                    disabled={disableApprovalActions}\n                    onClick={() =>\n                      approval &&\n                      onApprovalAction?.(approval, \"approve_for_session\")\n                    }\n                    variant=\"secondary\"\n                    className=\"hover:bg-primary/30\"\n                  >\n                    {approvalPending\n                      ? \"Approving session…\"\n                      : \"Approve for session\"}\n                  </ConfirmationAction>\n                </ConfirmationActions>\n              </ConfirmationRequest>\n              <ConfirmationAccepted>\n                <div className=\"rounded-md bg-success/10 px-3 py-2 text-xs text-success\">\n                  {approvalResponse === \"approve_for_session\"\n                    ? \"Session approved. Future matching requests auto-approve.\"\n                    : \"Approval confirmed. Continuing execution…\"}\n                </div>\n              </ConfirmationAccepted>\n              <ConfirmationRejected>\n                <div className=\"rounded-md bg-warning/10 px-3 py-2 text-xs text-warning\">\n                  Request denied\n                  {approval.reason ? `: ${approval.reason}` : \".\"}\n                </div>\n              </ConfirmationRejected>\n            </Confirmation>\n          ) : null}\n        </ToolContent>\n      </Tool>\n      {isApprovalRequested ? (\n        <div className={assistantMetaTextClass}>Waiting for your approval…</div>\n      ) : isApprovalDenied ? (\n        <div className={assistantMetaTextClass}>Tool execution cancelled.</div>\n      ) : null}\n    </>\n  );\n};\n\nconst ThinkToolBlock = ({\n  message,\n  defaultOpen,\n}: { message: LiveMessage; defaultOpen: boolean }) => {\n  const toolCall = message.toolCall;\n  const thought =\n    toolCall?.input && typeof toolCall.input === \"object\"\n      ? (toolCall.input as Record<string, unknown>).thought\n      : undefined;\n  const thoughtText = typeof thought === \"string\" ? thought : \"\";\n  const [isOpen, setIsOpen] = useState(defaultOpen);\n  const isComplete =\n    toolCall?.state === \"output-available\" ||\n    toolCall?.state === \"output-error\" ||\n    toolCall?.state === \"output-denied\";\n\n  return (\n    <MessageContent className={assistantContentClass}>\n      <div className=\"not-prose\">\n        <button\n          type=\"button\"\n          className=\"flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer\"\n          onClick={() => setIsOpen(!isOpen)}\n        >\n          <BrainIcon className=\"size-3.5 text-muted-foreground/70 shrink-0\" />\n          <span className=\"italic\">\n            {isComplete\n              ? \"Thought through the problem\"\n              : \"Thinking through the problem…\"}\n          </span>\n          <ChevronRightIcon\n            className={cn(\n              \"size-3 text-muted-foreground/50 transition-transform duration-200\",\n              isOpen && \"rotate-90\",\n            )}\n          />\n        </button>\n        {isOpen && thoughtText && (\n          <div className=\"mt-1.5 pl-4 border-l-2 border-border text-sm text-muted-foreground italic whitespace-pre-wrap\">\n            {thoughtText.length > 500\n              ? `${thoughtText.slice(0, 500)}…`\n              : thoughtText}\n          </div>\n        )}\n      </div>\n    </MessageContent>\n  );\n};\n\nconst renderThinkToolMessage = (\n  message: LiveMessage,\n  blocksExpanded: boolean,\n) => {\n  return (\n    <ThinkToolBlock\n      key={`${message.id}-think-${blocksExpanded}`}\n      message={message}\n      defaultOpen={blocksExpanded}\n    />\n  );\n};\n\nconst renderCodeMessage = (message: LiveMessage) => {\n  const snippet = message.codeSnippet;\n  if (!snippet) {\n    return renderAssistantText(message);\n  }\n\n  return (\n    <MessageContent className={assistantContentClass}>\n      <MessageResponse\n        className=\"wrap-break-word font-medium\"\n        mode={message.isStreaming ? \"streaming\" : \"static\"}\n        parseIncompleteMarkdown={Boolean(message.isStreaming)}\n      >\n        {message.content ?? snippet.title ?? \"Generated code\"}\n      </MessageResponse>\n      {snippet.code ? (\n        <div className=\"mt-3\">\n          <CodeBlock\n            code={snippet.code}\n            language={snippet.language}\n            showLineNumbers\n          />\n        </div>\n      ) : (\n        <div className={`mt-3 ${assistantMetaTextClass}`}>\n          Assembling snippet…\n        </div>\n      )}\n    </MessageContent>\n  );\n};\n\nconst renderThinkingMessage = (\n  message: LiveMessage,\n  blocksExpanded: boolean,\n) => {\n  const thinkingContent = message.thinking;\n  if (!thinkingContent) {\n    return renderAssistantText(message);\n  }\n\n  return (\n    <MessageContent className={assistantContentClass}>\n      <Reasoning\n        key={`${message.id}-${blocksExpanded}`}\n        isStreaming={message.isStreaming}\n        duration={message.thinkingDuration}\n        defaultOpen={blocksExpanded}\n        disableAutoClose\n      >\n        <ReasoningTrigger />\n        <ReasoningContent>{thinkingContent}</ReasoningContent>\n      </Reasoning>\n    </MessageContent>\n  );\n};\n"
  },
  {
    "path": "web/src/features/chat/components/attachment-button.tsx",
    "content": "import { PromptInputButton, usePromptInputAttachments } from \"@ai-elements\";\nimport { PaperclipIcon } from \"lucide-react\";\n\nexport function AttachmentButton() {\n  const attachments = usePromptInputAttachments();\n\n  return (\n    <PromptInputButton onClick={() => attachments.openFileDialog()}>\n      <PaperclipIcon className=\"size-4\" />\n    </PromptInputButton>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/chat-conversation.tsx",
    "content": "import type { ChatStatus } from \"ai\";\nimport type { LiveMessage } from \"@/hooks/types\";\nimport { ConversationEmptyState } from \"@ai-elements\";\nimport { Button } from \"@/components/ui/button\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport type { Session } from \"@/lib/api/models\";\nimport type { AssistantApprovalHandler } from \"./assistant-message\";\nimport {\n  ArrowDownIcon,\n  Loader2Icon,\n  PlusIcon,\n  SparklesIcon,\n} from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { hasPlatformModifier, isMacOS } from \"@/hooks/utils\";\nimport {\n  VirtualizedMessageList,\n  type VirtualizedMessageListHandle,\n} from \"./virtualized-message-list\";\nimport { MessageSearchDialog } from \"../message-search-dialog\";\n\ntype ChatConversationProps = {\n  messages: LiveMessage[];\n  status: ChatStatus;\n  selectedSessionId?: string;\n  currentSession?: Session;\n  isReplayingHistory: boolean;\n  pendingApprovalMap: Record<string, boolean>;\n  onApprovalAction?: AssistantApprovalHandler;\n  canRespondToApproval: boolean;\n  blocksExpanded: boolean;\n  onCreateSession?: () => void;\n  isSearchOpen: boolean;\n  onSearchOpenChange: (open: boolean) => void;\n  onForkSession?: (turnIndex: number) => void;\n};\n\nexport function ChatConversation({\n  messages,\n  status,\n  selectedSessionId,\n  isReplayingHistory,\n  pendingApprovalMap,\n  onApprovalAction,\n  canRespondToApproval,\n  blocksExpanded,\n  onCreateSession,\n  isSearchOpen,\n  onSearchOpenChange,\n  onForkSession,\n}: ChatConversationProps) {\n  const listRef = useRef<VirtualizedMessageListHandle>(null);\n  const [isAtBottom, setIsAtBottom] = useState(true);\n  const [highlightedIndex, setHighlightedIndex] = useState(-1);\n\n  // Handle Cmd+F / Ctrl+F\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (hasPlatformModifier(e) && e.key === \"f\") {\n        e.preventDefault();\n        onSearchOpenChange(true);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [onSearchOpenChange]);\n\n  const handleJumpToMessage = useCallback((messageIndex: number) => {\n    setHighlightedIndex(messageIndex);\n    listRef.current?.scrollToIndex(messageIndex);\n    // Clear highlight after a delay\n    setTimeout(() => setHighlightedIndex(-1), 2000);\n  }, []);\n\n  const handleScrollToBottom = useCallback(() => {\n    listRef.current?.scrollToBottom();\n  }, []);\n\n  const isLoadingResponse =\n    messages.length === 0 &&\n    (status === \"streaming\" || status === \"submitted\");\n  const isStartingEnvironment =\n    isLoadingResponse && status === \"submitted\" && !isReplayingHistory;\n\n  const hasSelectedSession = Boolean(selectedSessionId);\n  const emptyNoSessionState =\n    messages.length === 0 && !hasSelectedSession;\n  const emptySessionState =\n    messages.length === 0 &&\n    hasSelectedSession &&\n    !isLoadingResponse;\n\n  const hasMessages = messages.length > 0;\n  const shouldShowScrollButton = hasMessages && !isAtBottom;\n  const shouldShowEmptyState =\n    isLoadingResponse || emptyNoSessionState || emptySessionState;\n\n  const conversationKey = hasSelectedSession\n    ? `session:${selectedSessionId}`\n    : \"empty\";\n  const newSessionShortcutModifier = isMacOS() ? \"Cmd\" : \"Ctrl\";\n\n  return (\n    <div\n      className=\"relative flex h-full flex-col overflow-x-hidden px-2\"\n      role=\"log\"\n    >\n      {shouldShowEmptyState ? (\n        isLoadingResponse ? (\n          <ConversationEmptyState\n            description=\"\"\n            icon={<Loader2Icon className=\"size-6 animate-spin text-primary\" />}\n            title={isStartingEnvironment ? \"Starting environment...\" : \"Connecting to session...\"}\n          />\n        ) : emptyNoSessionState ? (\n          <ConversationEmptyState>\n            <div className=\"flex size-16 items-center justify-center rounded-2xl bg-secondary\">\n              <SparklesIcon className=\"size-8 text-muted-foreground\" />\n            </div>\n            <div className=\"text-center\">\n              <p className=\"text-lg font-medium text-foreground\">\n                Create a session to begin\n              </p>\n              <p className=\"mt-1 text-sm text-muted-foreground\">\n                Click the + button in the sidebar to start a new session\n              </p>\n            </div>\n            {onCreateSession ? (\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <Button\n                    className=\"mt-1\"\n                    type=\"button\"\n                    onClick={(e) => {\n                      if (hasPlatformModifier(e)) {\n                        const url = new URL(window.location.origin + window.location.pathname);\n                        url.searchParams.set(\"action\", \"create\");\n                        window.open(url.toString(), \"_blank\");\n                      } else {\n                        onCreateSession();\n                      }\n                    }}\n                  >\n                    <PlusIcon className=\"size-4\" />\n                    <span>Create new session</span>\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent className=\"flex flex-col items-center gap-1\" side=\"top\">\n                  <div className=\"flex items-center gap-2\">\n                    <span>Create new session</span>\n                    <KbdGroup>\n                      <Kbd>Shift</Kbd>\n                      <span className=\"text-muted-foreground\">+</span>\n                      <Kbd>{newSessionShortcutModifier}</Kbd>\n                      <span className=\"text-muted-foreground\">+</span>\n                      <Kbd>O</Kbd>\n                    </KbdGroup>\n                  </div>\n                  <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                    <span>{newSessionShortcutModifier}+Click to open in new tab</span>\n                  </div>\n                </TooltipContent>\n              </Tooltip>\n            ) : null}\n          </ConversationEmptyState>\n        ) : emptySessionState ? (\n          <div className=\"flex h-full items-center justify-center\">\n            <p className=\"text-sm text-muted-foreground\">\n              Start a conversation...\n            </p>\n          </div>\n        ) : null\n      ) : (\n        <div className=\"flex-1\">\n          <VirtualizedMessageList\n            ref={listRef}\n            messages={messages}\n            conversationKey={conversationKey}\n            pendingApprovalMap={pendingApprovalMap}\n            onApprovalAction={onApprovalAction}\n            canRespondToApproval={canRespondToApproval}\n            blocksExpanded={blocksExpanded}\n            highlightedMessageIndex={highlightedIndex}\n            onAtBottomChange={setIsAtBottom}\n            onForkSession={onForkSession}\n          />\n        </div>\n      )}\n\n      {shouldShowScrollButton ? (\n        <Button\n          className=\"absolute bottom-[calc(1rem+var(--safe-bottom))] left-[50%] -translate-x-1/2 rounded-full\"\n          onClick={handleScrollToBottom}\n          size=\"icon\"\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <ArrowDownIcon className=\"size-4\" />\n        </Button>\n      ) : null}\n\n      <MessageSearchDialog\n        messages={messages}\n        open={isSearchOpen}\n        onOpenChange={onSearchOpenChange}\n        onJumpToMessage={handleJumpToMessage}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/chat-prompt-composer.tsx",
    "content": "import {\n  PromptInput,\n  PromptInputAttachment,\n  PromptInputAttachments,\n  PromptInputBody,\n  PromptInputButton,\n  PromptInputFooter,\n  PromptInputSubmit,\n  PromptInputTextarea,\n  PromptInputTools,\n  usePromptInputAttachments,\n  usePromptInputController,\n} from \"@ai-elements\";\nimport type { ChatStatus } from \"ai\";\nimport type { PromptInputMessage } from \"@ai-elements\";\nimport type { GitDiffStats, Session } from \"@/lib/api/models\";\nimport type { TokenUsage } from \"@/hooks/wireTypes\";\nimport type { ActivityDetail } from \"./activity-status-indicator\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { cn } from \"@/lib/utils\";\nimport { MEDIA_CONFIG } from \"@/config/media\";\n\nimport { FileMentionMenu } from \"../file-mention-menu\";\nimport { useFileMentions } from \"../useFileMentions\";\nimport { SlashCommandMenu } from \"../slash-command-menu\";\nimport { useSlashCommands, type SlashCommandDef } from \"../useSlashCommands\";\nimport { PromptToolbar } from \"./prompt-toolbar\";\nimport { ArrowUpIcon, Loader2Icon, SquareIcon, Maximize2Icon, Minimize2Icon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { GlobalConfigControls } from \"@/features/chat/global-config-controls\";\nimport {\n  type ChangeEvent,\n  type KeyboardEvent,\n  type ReactElement,\n  type SyntheticEvent,\n  memo,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport type { SessionFileEntry } from \"@/hooks/useSessions\";\n\ntype ChatPromptComposerProps = {\n  status: ChatStatus;\n  onSubmit: (message: PromptInputMessage) => Promise<void>;\n  canSendMessage: boolean;\n  currentSession?: Session;\n  isUploading: boolean;\n  isStreaming: boolean;\n  isAwaitingIdle: boolean;\n  isReplayingHistory: boolean;\n  onCancel?: () => void;\n  onListSessionDirectory?: (\n    sessionId: string,\n    path?: string,\n  ) => Promise<SessionFileEntry[]>;\n  gitDiffStats?: GitDiffStats | null;\n  isGitDiffLoading?: boolean;\n  slashCommands?: SlashCommandDef[];\n  planMode?: boolean;\n  onPlanModeChange?: (enabled: boolean) => void;\n  activityStatus?: ActivityDetail;\n  usagePercent?: number;\n  usedTokens?: number;\n  maxTokens?: number;\n  tokenUsage?: TokenUsage | null;\n};\n\nexport const ChatPromptComposer = memo(function ChatPromptComposerComponent({\n  status,\n  onSubmit,\n  canSendMessage,\n  currentSession,\n  isUploading,\n  isStreaming,\n  isAwaitingIdle,\n  isReplayingHistory,\n  onCancel,\n  onListSessionDirectory,\n  gitDiffStats,\n  isGitDiffLoading,\n  slashCommands = [],\n  planMode = false,\n  onPlanModeChange,\n  activityStatus,\n  usagePercent,\n  usedTokens,\n  maxTokens,\n  tokenUsage,\n}: ChatPromptComposerProps): ReactElement {\n  const promptController = usePromptInputController();\n  const attachmentContext = usePromptInputAttachments();\n  const textareaRef = useRef<HTMLTextAreaElement | null>(null);\n  const [isExpanded, setIsExpanded] = useState(false);\n\n  const {\n    isOpen: isMentionOpen,\n    query: mentionQuery,\n    sections: mentionSections,\n    flatOptions: mentionOptions,\n    activeIndex: mentionActiveIndex,\n    setActiveIndex: setMentionActiveIndex,\n    handleTextChange: handleMentionTextChange,\n    handleCaretChange: handleMentionCaretChange,\n    handleKeyDown: handleMentionKeyDown,\n    selectOption: selectMentionOption,\n    closeMenu: closeMentionMenu,\n    workspaceStatus: mentionWorkspaceStatus,\n    workspaceError: mentionWorkspaceError,\n    retryWorkspace: retryMentionWorkspace,\n    workspaceFileCount: mentionWorkspaceFileCount,\n  } = useFileMentions({\n    text: promptController.textInput.value,\n    setText: promptController.textInput.setInput,\n    textareaRef,\n    attachments: attachmentContext.files,\n    sessionId: currentSession?.sessionId,\n    listDirectory: onListSessionDirectory,\n  });\n\n  const {\n    isOpen: isSlashOpen,\n    query: slashQuery,\n    options: slashOptions,\n    activeIndex: slashActiveIndex,\n    setActiveIndex: setSlashActiveIndex,\n    handleTextChange: handleSlashTextChange,\n    handleCaretChange: handleSlashCaretChange,\n    handleKeyDown: handleSlashKeyDown,\n    selectOption: selectSlashOption,\n    closeMenu: closeSlashMenu,\n  } = useSlashCommands({\n    text: promptController.textInput.value,\n    setText: promptController.textInput.setInput,\n    textareaRef,\n    commands: slashCommands,\n  });\n\n  const handleTextareaChange = useCallback(\n    (event: ChangeEvent<HTMLTextAreaElement>) => {\n      const value = event.currentTarget.value;\n      const caret = event.currentTarget.selectionStart;\n      handleMentionTextChange(value, caret);\n      handleSlashTextChange(value, caret);\n    },\n    [handleMentionTextChange, handleSlashTextChange],\n  );\n\n  const handleTextareaSelection = useCallback(\n    (event: SyntheticEvent<HTMLTextAreaElement>) => {\n      const caret = event.currentTarget.selectionStart;\n      handleMentionCaretChange(caret);\n      handleSlashCaretChange(caret);\n    },\n    [handleMentionCaretChange, handleSlashCaretChange],\n  );\n\n  const handleTextareaBlur = useCallback(() => {\n    closeMentionMenu();\n    closeSlashMenu();\n  }, [closeMentionMenu, closeSlashMenu]);\n\n  const handleTextareaKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>) => {\n      // Priority: slash menu first, then mention menu\n      if (isSlashOpen) {\n        handleSlashKeyDown(event);\n        return;\n      }\n      if (isMentionOpen) {\n        handleMentionKeyDown(event);\n        return;\n      }\n    },\n    [isSlashOpen, isMentionOpen, handleSlashKeyDown, handleMentionKeyDown],\n  );\n\n  const handleFileError = useCallback(\n    (err: { code: string; message: string }) => {\n      toast.error(\"File Error\", { description: err.message });\n    },\n    [],\n  );\n\n  const handleToggleExpand = useCallback(() => {\n    setIsExpanded((prev) => !prev);\n  }, []);\n\n  return (\n    <div className=\"w-full\">\n      <PromptToolbar\n        gitDiffStats={gitDiffStats}\n        isGitDiffLoading={isGitDiffLoading}\n        workDir={currentSession?.workDir}\n        planMode={planMode}\n        activityStatus={activityStatus}\n        usagePercent={usagePercent}\n        usedTokens={usedTokens}\n        maxTokens={maxTokens}\n        tokenUsage={tokenUsage}\n      />\n\n      <PromptInput\n        accept=\"*\"\n        className={cn(\n          \"w-full [&_[data-slot=input-group]]:border [&_[data-slot=input-group]]:border-border\",\n          planMode && \"[&_[data-slot=input-group]]:border-dashed [&_[data-slot=input-group]]:!border-blue-200 dark:[&_[data-slot=input-group]]:!border-blue-600\"\n        )}\n        multiple\n        maxFiles={MEDIA_CONFIG.maxCount}\n        onSubmit={onSubmit}\n        onError={handleFileError}\n      >\n        <PromptInputBody className=\"w-full relative\">\n          {/* Expand/Collapse button - positioned relative to entire input body */}\n          <button\n            type=\"button\"\n            onClick={handleToggleExpand}\n            disabled={!(canSendMessage && currentSession)}\n            className=\"absolute top-2 right-2 z-10 p-1 cursor-pointer rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors disabled:opacity-50 disabled:pointer-events-none\"\n            aria-label={isExpanded ? \"Collapse input\" : \"Expand input\"}\n          >\n            {isExpanded ? (\n              <Minimize2Icon className=\"size-4\" />\n            ) : (\n              <Maximize2Icon className=\"size-4\" />\n            )}\n          </button>\n          <PromptInputAttachments>\n            {(file) => <PromptInputAttachment data={file} />}\n          </PromptInputAttachments>\n          {isUploading ? (\n            <Badge\n              className=\"mb-2 bg-secondary/70 text-muted-foreground\"\n              variant=\"secondary\"\n            >\n              <Loader2Icon className=\"size-4 animate-spin text-primary\" />\n              <span>Uploading files…</span>\n            </Badge>\n          ) : null}\n          <div className=\"relative w-full flex items-start\">\n            <div className=\"flex-1 relative\">\n              <PromptInputTextarea\n                ref={textareaRef}\n                className={cn(\n                  \"transition-all duration-200 pr-8\",\n                  isExpanded\n                    ? \"min-h-[220px] max-h-[60vh] sm:min-h-[300px]\"\n                    : \"min-h-10 max-h-36 sm:min-h-16 sm:max-h-48\",\n                )}\n                placeholder={\n                  !currentSession\n                    ? \"Create a session to start...\"\n                    : isAwaitingIdle\n                      ? isReplayingHistory\n                        ? \"Connecting...\"\n                        : \"Starting environment...\"\n                      : isStreaming\n                        ? \"Add a follow-up message...\"\n                        : \"Ask anything, / for commands, @ to mention files\"\n                }\n                aria-busy={isUploading}\n                disabled={!canSendMessage || isUploading || !currentSession || isAwaitingIdle}\n                onChange={handleTextareaChange}\n                onSelect={handleTextareaSelection}\n                onKeyUp={handleTextareaSelection}\n                onClick={handleTextareaSelection}\n                onBlur={handleTextareaBlur}\n                onKeyDown={handleTextareaKeyDown}\n              />\n              {/* Slash command menu - mutually exclusive with file mention menu */}\n              <SlashCommandMenu\n                open={isSlashOpen && canSendMessage && !isMentionOpen}\n                query={slashQuery}\n                options={slashOptions}\n                activeIndex={slashActiveIndex}\n                onSelect={selectSlashOption}\n                onHover={setSlashActiveIndex}\n              />\n              {/* File mention menu - only show when slash menu is not open */}\n              <FileMentionMenu\n                open={isMentionOpen && canSendMessage && !isSlashOpen}\n                query={mentionQuery}\n                sections={mentionSections}\n                flatOptions={mentionOptions}\n                activeIndex={mentionActiveIndex}\n                onSelect={selectMentionOption}\n                onHover={setMentionActiveIndex}\n                workspaceStatus={mentionWorkspaceStatus}\n                workspaceError={mentionWorkspaceError}\n                onRetryWorkspace={retryMentionWorkspace}\n                isWorkspaceAvailable={Boolean(\n                  currentSession && onListSessionDirectory,\n                )}\n                workspaceFileCount={mentionWorkspaceFileCount}\n              />\n            </div>\n          </div>\n        </PromptInputBody>\n        <PromptInputFooter className=\"w-full gap-2 py-1 border-none bg-transparent shadow-none\">\n          <PromptInputTools className=\"flex-1 min-w-0 flex-wrap\">\n            <GlobalConfigControls planMode={planMode} onPlanModeChange={onPlanModeChange} />\n          </PromptInputTools>\n          {isStreaming ? (\n            <div className=\"flex items-center gap-1.5 shrink-0\">\n              <PromptInputButton\n                aria-label=\"Stop generation\"\n                disabled={!onCancel}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  onCancel?.();\n                }}\n                size=\"icon-sm\"\n                variant=\"default\"\n                className=\"shrink-0\"\n              >\n                <SquareIcon className=\"size-4\" />\n              </PromptInputButton>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <PromptInputSubmit\n                    aria-label=\"Queue message\"\n                    size=\"icon-sm\"\n                    variant=\"outline\"\n                    className=\"shrink-0\"\n                    disabled={!(canSendMessage && currentSession)}\n                  >\n                    <ArrowUpIcon className=\"size-4\" />\n                  </PromptInputSubmit>\n                </TooltipTrigger>\n                <TooltipContent>Queue message</TooltipContent>\n              </Tooltip>\n            </div>\n          ) : (\n            <PromptInputSubmit\n              status={isUploading ? \"submitted\" : status}\n              disabled={\n                !canSendMessage ||\n                isAwaitingIdle ||\n                isUploading ||\n                !currentSession\n              }\n              className=\"shrink-0\"\n            />\n          )}\n        </PromptInputFooter>\n      </PromptInput>\n    </div>\n  );\n});\n"
  },
  {
    "path": "web/src/features/chat/components/chat-workspace-header.tsx",
    "content": "import { useState, useCallback } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\nimport type { Session } from \"@/lib/api/models\";\nimport { shortenTitle } from \"@/lib/utils\";\nimport {\n  ChevronsDownUpIcon,\n  ChevronsUpDownIcon,\n  PanelLeftOpen,\n  SearchIcon,\n} from \"lucide-react\";\nimport { SessionInfoPopover } from \"./session-info-popover\";\nimport { OpenInMenu } from \"./open-in-menu\";\nimport { isMacOS } from \"@/hooks/utils\";\n\ntype ChatWorkspaceHeaderProps = {\n  currentStep: number;\n  sessionDescription?: string;\n  currentSession?: Session;\n  selectedSessionId?: string;\n  blocksExpanded: boolean;\n  onToggleBlocks: () => void;\n  onOpenSearch: () => void;\n  onOpenSidebar?: () => void;\n  onRenameSession?: (sessionId: string, newTitle: string) => Promise<boolean>;\n};\n\nexport function ChatWorkspaceHeader({\n  currentStep: _,\n  sessionDescription,\n  currentSession,\n  selectedSessionId,\n  blocksExpanded,\n  onToggleBlocks,\n  onOpenSearch,\n  onOpenSidebar,\n  onRenameSession,\n}: ChatWorkspaceHeaderProps) {\n  const searchShortcutModifier = isMacOS() ? \"Cmd\" : \"Ctrl\";\n\n  // Editing state\n  const [isEditing, setIsEditing] = useState(false);\n  const [editingTitle, setEditingTitle] = useState(\"\");\n\n  const handleDoubleClick = useCallback(() => {\n    if (!((onRenameSession && selectedSessionId ) && sessionDescription)) return;\n    setIsEditing(true);\n    setEditingTitle(sessionDescription);\n  }, [onRenameSession, selectedSessionId, sessionDescription]);\n\n  const handleCancelEdit = useCallback(() => {\n    setIsEditing(false);\n    setEditingTitle(\"\");\n  }, []);\n\n  const handleSaveEdit = useCallback(async () => {\n    if (!(selectedSessionId && onRenameSession)) {\n      handleCancelEdit();\n      return;\n    }\n\n    const trimmedTitle = editingTitle.trim();\n    if (!trimmedTitle) {\n      handleCancelEdit();\n      return;\n    }\n\n    const success = await onRenameSession(selectedSessionId, trimmedTitle);\n    if (success) {\n      handleCancelEdit();\n    }\n  }, [selectedSessionId, editingTitle, onRenameSession, handleCancelEdit]);\n\n  return (\n    <div className=\"flex min-w-0 flex-col gap-2 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3 lg:pl-8\">\n      <div className=\"flex min-w-0 items-center gap-2\">\n        {onOpenSidebar ? (\n          <button\n            type=\"button\"\n            aria-label=\"Open sessions sidebar\"\n            className=\"inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground lg:hidden\"\n            onClick={onOpenSidebar}\n          >\n            <PanelLeftOpen className=\"size-4\" />\n          </button>\n        ) : null}\n        <div className=\"min-w-0 flex-1\">\n          {isEditing ? (\n            <Input\n              autoFocus\n              value={editingTitle}\n              onChange={(e) => setEditingTitle(e.target.value)}\n              onBlur={handleSaveEdit}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  e.preventDefault();\n                  handleSaveEdit();\n                }\n                if (e.key === \"Escape\") {\n                  e.preventDefault();\n                  handleCancelEdit();\n                }\n              }}\n              className=\"h-7 text-xs font-bold\"\n            />\n          ) : sessionDescription ? (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <button\n                  type=\"button\"\n                  className=\"truncate text-xs font-bold cursor-pointer hover:text-primary text-left bg-transparent border-none p-0\"\n                  onDoubleClick={handleDoubleClick}\n                >\n                  {shortenTitle(sessionDescription, 60)}\n                </button>\n              </TooltipTrigger>\n              <TooltipContent side=\"bottom\" className=\"max-w-md\">\n                <div>{sessionDescription}</div>\n                {onRenameSession && (\n                  <div className=\"text-muted-foreground text-[10px] mt-1\">\n                    Double-click to rename\n                  </div>\n                )}\n              </TooltipContent>\n            </Tooltip>\n          ) : null}\n        </div>\n      </div>\n      <div className=\"flex items-center justify-end gap-2\">\n        {selectedSessionId && (\n          <>\n            {currentSession?.workDir ? (\n              <div className=\"hidden lg:block\">\n                <OpenInMenu workDir={currentSession.workDir} />\n              </div>\n            ) : null}\n\n            <SessionInfoPopover\n              sessionId={selectedSessionId}\n              session={currentSession}\n            />\n\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <button\n                  type=\"button\"\n                  aria-label=\"Search messages\"\n                  className=\"inline-flex items-center cursor-pointer justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground\"\n                  onClick={onOpenSearch}\n                >\n                  <SearchIcon className=\"size-4\" />\n                </button>\n              </TooltipTrigger>\n              <TooltipContent className=\"flex items-center gap-2\" side=\"bottom\">\n                <span>Search messages</span>\n                <KbdGroup>\n                  <Kbd>{searchShortcutModifier}</Kbd>\n                  <span className=\"text-muted-foreground\">+</span>\n                  <Kbd>F</Kbd>\n                </KbdGroup>\n              </TooltipContent>\n            </Tooltip>\n\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <button\n                  type=\"button\"\n                  aria-label={\n                    blocksExpanded ? \"Fold all blocks\" : \"Unfold all blocks\"\n                  }\n                  className=\"inline-flex items-center cursor-pointer justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground\"\n                  onClick={onToggleBlocks}\n                >\n                  {blocksExpanded ? (\n                    <ChevronsDownUpIcon className=\"size-4\" />\n                  ) : (\n                    <ChevronsUpDownIcon className=\"size-4\" />\n                  )}\n                </button>\n              </TooltipTrigger>\n              <TooltipContent side=\"bottom\">\n                {blocksExpanded ? \"Fold all blocks\" : \"Unfold all blocks\"}\n              </TooltipContent>\n            </Tooltip>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/open-in-menu.tsx",
    "content": "import { useCallback, useMemo, type ReactNode } from \"react\";\nimport {\n  ChevronDownIcon,\n  CopyIcon,\n  FolderOpenIcon,\n  CodeIcon,\n  SquareTerminalIcon,\n  TerminalIcon,\n  AppWindowIcon,\n  ChevronUpIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Button } from \"@/components/ui/button\";\nimport { ButtonGroup, ButtonGroupText } from \"@/components/ui/button-group\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { isMacOS } from \"@/hooks/utils\";\nimport { getAuthHeader } from \"@/lib/auth\";\nimport { cn } from \"@/lib/utils\";\n\ntype OpenInMenuProps = {\n  workDir?: string | null;\n  className?: string;\n};\n\ntype OpenTarget = {\n  id: string;\n  label: string;\n  icon: ReactNode;\n  backendApp: \"finder\" | \"cursor\" | \"vscode\" | \"iterm\" | \"terminal\" | \"antigravity\";\n  macOnly?: boolean;\n  shortcut?: string;\n};\n\nconst TRAILING_SLASH_REGEX = /\\/+$/;\n\nfunction normalizePath(path: string): string {\n  const trimmed = path.trim().replace(/\\\\/g, \"/\");\n  if (trimmed === \"\") {\n    return \"/\";\n  }\n  const cleaned = trimmed.replace(TRAILING_SLASH_REGEX, \"\");\n  return cleaned === \"\" ? \"/\" : cleaned;\n}\n\nfunction compactPath(path: string, maxLength = 22): string {\n  const normalized = normalizePath(path);\n  if (normalized.length <= maxLength) {\n    return normalized;\n  }\n  const parts = normalized.split(\"/\").filter(Boolean);\n  if (parts.length === 0) {\n    return normalized.slice(0, maxLength - 1) + \"…\";\n  }\n  const tail = parts.slice(-2).join(\"/\");\n  if (tail.length + 2 <= maxLength) {\n    return `…/${tail}`;\n  }\n  return `…/${tail.slice(-maxLength + 2)}`;\n}\n\nasync function openViaBackend(app: OpenTarget[\"backendApp\"], path: string) {\n  const response = await fetch(\"/api/open-in\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\", ...getAuthHeader() },\n    body: JSON.stringify({ app, path }),\n  });\n\n  if (response.ok) {\n    return;\n  }\n\n  let detail = \"Failed to open application.\";\n  try {\n    const data = await response.json();\n    if (data?.detail) {\n      detail = String(data.detail);\n    }\n  } catch (error) {\n    console.error(\"Failed to parse open-in error:\", error);\n  }\n  throw new Error(detail);\n}\n\nexport function OpenInMenu({ workDir, className }: OpenInMenuProps) {\n  const isMac = isMacOS();\n  const hasWorkDir = Boolean(workDir && workDir.trim().length > 0);\n  const displayPath = workDir ? compactPath(workDir) : \"No directory\";\n\n  const openTargets = useMemo<OpenTarget[]>(\n    () => [\n      {\n        id: \"finder\",\n        label: \"Finder\",\n        icon: <FolderOpenIcon className=\"size-4\" />,\n        backendApp: \"finder\",\n        macOnly: true,\n      },\n      {\n        id: \"cursor\",\n        label: \"Cursor\",\n        icon: <AppWindowIcon className=\"size-4\" />,\n        backendApp: \"cursor\",\n      },\n      {\n        id: \"vscode\",\n        label: \"VS Code\",\n        icon: <CodeIcon className=\"size-4\" />,\n        backendApp: \"vscode\",\n      },\n      {\n        id: \"antigravity\",\n        label: \"Antigravity\",\n        icon: <ChevronUpIcon className=\"size-4\" />,\n        backendApp: \"antigravity\",\n      },\n      {\n        id: \"iterm\",\n        label: \"iTerm\",\n        icon: <TerminalIcon className=\"size-4\" />,\n        backendApp: \"iterm\",\n        macOnly: true,\n      },\n      {\n        id: \"terminal\",\n        label: \"Terminal\",\n        icon: <SquareTerminalIcon className=\"size-4\" />,\n        backendApp: \"terminal\",\n        macOnly: true,\n      },\n    ],\n    [],\n  );\n\n  const menuTargets = useMemo(\n    () => openTargets.filter((target) => !target.macOnly || isMac),\n    [openTargets, isMac],\n  );\n\n  const handleCopyPath = useCallback(async () => {\n    if (!workDir) {\n      return;\n    }\n    try {\n      await navigator.clipboard.writeText(workDir);\n      toast.success(\"Path copied\", { description: workDir });\n    } catch (error) {\n      console.error(\"Failed to copy path:\", error);\n      toast.error(\"Failed to copy path\");\n    }\n  }, [workDir]);\n\n  const handleOpenTarget = useCallback(\n    async (target: OpenTarget) => {\n      if (!workDir) {\n        toast.message(\"No working directory\", {\n          description: \"Create a session with a working directory first.\",\n        });\n        return;\n      }\n      try {\n        await openViaBackend(target.backendApp, workDir);\n      } catch (error) {\n        console.error(\"Failed to open external URL:\", error);\n        toast.error(\"Failed to open application\", {\n          description:\n            error instanceof Error ? error.message : \"Unexpected error\",\n        });\n      }\n    },\n    [workDir],\n  );\n\n  if (!isMac) {\n    return null;\n  }\n\n  return (\n    <ButtonGroup\n      className={cn(\"h-8 items-center\", className)}\n      aria-label=\"Open working directory\"\n    >\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <ButtonGroupText\n            className={cn(\n              \"h-8 max-w-[220px] px-3 text-xs font-semibold\",\n              \"bg-secondary/40 text-foreground\",\n              !hasWorkDir && \"text-muted-foreground\",\n            )}\n          >\n            <TerminalIcon className=\"size-3.5\" />\n            <span className=\"truncate\">{displayPath}</span>\n          </ButtonGroupText>\n        </TooltipTrigger>\n        {workDir ? (\n          <TooltipContent side=\"bottom\" className=\"max-w-md break-all\">\n            {workDir}\n          </TooltipContent>\n        ) : null}\n      </Tooltip>\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            disabled={!hasWorkDir}\n            className=\"h-8 rounded-l-none px-2 text-xs\"\n            aria-label=\"Open working directory in app\"\n          >\n            Open\n            <ChevronDownIcon className=\"size-3\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\" className=\"w-56\">\n          {menuTargets.map((target) => (\n            <DropdownMenuItem\n              key={target.id}\n              onSelect={() => handleOpenTarget(target)}\n            >\n              {target.icon}\n              <span>{target.label}</span>\n            </DropdownMenuItem>\n          ))}\n          <DropdownMenuSeparator />\n          <DropdownMenuItem onSelect={handleCopyPath}>\n            <CopyIcon className=\"size-4\" />\n            <span>Copy path</span>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </ButtonGroup>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/prompt-toolbar/index.tsx",
    "content": "import {\n  type ReactElement,\n  memo,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { GitDiffStats } from \"@/lib/api/models\";\nimport type { TokenUsage } from \"@/hooks/wireTypes\";\nimport { useQueueStore } from \"../../queue-store\";\nimport { useToolEventsStore } from \"@/features/tool/store\";\nimport { ToolbarActivityIndicator, type ActivityDetail } from \"../activity-status-indicator\";\nimport { ToolbarQueuePanel, ToolbarQueueTab } from \"./toolbar-queue\";\nimport { ToolbarChangesPanel, ToolbarChangesTab } from \"./toolbar-changes\";\nimport { ToolbarTodoPanel, ToolbarTodoTab } from \"./toolbar-todo\";\nimport { ToolbarContextIndicator } from \"./toolbar-context\";\n\n// ─── Types ───────────────────────────────────────────────────\n\ntype TabId = \"queue\" | \"changes\" | \"todo\";\n\ntype PromptToolbarProps = {\n  gitDiffStats?: GitDiffStats | null;\n  isGitDiffLoading?: boolean;\n  workDir?: string | null;\n  planMode?: boolean;\n  activityStatus?: ActivityDetail;\n  usagePercent?: number;\n  usedTokens?: number;\n  maxTokens?: number;\n  tokenUsage?: TokenUsage | null;\n};\n\n// ─── Main toolbar ────────────────────────────────────────────\n\nexport const PromptToolbar = memo(function PromptToolbarComponent({\n  gitDiffStats,\n  isGitDiffLoading,\n  workDir,\n  planMode = false,\n  activityStatus,\n  usagePercent,\n  usedTokens,\n  maxTokens,\n  tokenUsage,\n}: PromptToolbarProps): ReactElement | null {\n  const queue = useQueueStore((s) => s.queue);\n  const todoItems = useToolEventsStore((s) => s.todoItems);\n  const [activeTab, setActiveTab] = useState<TabId | null>(null);\n  const prevQueueLenRef = useRef(0);\n\n  const stats = gitDiffStats;\n  const hasChanges = Boolean(stats?.isGitRepo && stats.hasChanges && stats.files && !stats.error);\n  const hasQueue = queue.length > 0;\n  const hasTodo = todoItems.length > 0;\n  const hasContext = usagePercent !== undefined && usedTokens !== undefined && maxTokens !== undefined;\n  const hasTabs = hasQueue || hasChanges || hasTodo;\n\n  // Auto-open queue tab when first item is added\n  useEffect(() => {\n    if (prevQueueLenRef.current === 0 && queue.length > 0) {\n      setActiveTab(\"queue\");\n    }\n    prevQueueLenRef.current = queue.length;\n  }, [queue.length]);\n\n  // Auto-close tab when its data becomes empty\n  useEffect(() => {\n    if (activeTab === \"queue\" && !hasQueue) setActiveTab(null);\n    if (activeTab === \"changes\" && !hasChanges) setActiveTab(null);\n    if (activeTab === \"todo\" && !hasTodo) setActiveTab(null);\n  }, [activeTab, hasQueue, hasChanges, hasTodo]);\n\n  const toggleTab = useCallback((tab: TabId) => {\n    setActiveTab((prev) => (prev === tab ? null : tab));\n  }, []);\n\n  if (!(hasTabs || activityStatus || hasContext || planMode)) return null;\n\n  return (\n    <div className={cn(\"w-full px-1 sm:px-2 flex flex-col gap-1 mb-2\", isGitDiffLoading && \"opacity-70\")}>\n      {/* ── Expanded panel ── */}\n      {activeTab && (\n        <div className={cn(\n          \"rounded-md border border-border bg-background\",\n          activeTab !== \"changes\" && \"max-h-32 overflow-y-auto py-1 px-0.5\",\n        )}>\n          {activeTab === \"queue\" && <ToolbarQueuePanel queue={queue} />}\n          {activeTab === \"changes\" && stats && (\n            <ToolbarChangesPanel stats={stats} workDir={workDir} />\n          )}\n          {activeTab === \"todo\" && (\n            <ToolbarTodoPanel items={todoItems} />\n          )}\n        </div>\n      )}\n\n      {/* ── Tab bar ── */}\n      <div className=\"flex items-center gap-1.5 px-1\">\n{activityStatus && (\n          <ToolbarActivityIndicator activity={activityStatus} />\n        )}\n\n        {hasQueue && (\n          <ToolbarQueueTab\n            count={queue.length}\n            isActive={activeTab === \"queue\"}\n            onToggle={() => toggleTab(\"queue\")}\n          />\n        )}\n\n        {hasChanges && stats?.files && (\n          <ToolbarChangesTab\n            stats={stats}\n            isActive={activeTab === \"changes\"}\n            onToggle={() => toggleTab(\"changes\")}\n          />\n        )}\n\n        {hasTodo && (\n          <ToolbarTodoTab\n            items={todoItems}\n            isActive={activeTab === \"todo\"}\n            onToggle={() => toggleTab(\"todo\")}\n          />\n        )}\n\n        {hasContext && (\n          <ToolbarContextIndicator\n            usagePercent={usagePercent!}\n            usedTokens={usedTokens!}\n            maxTokens={maxTokens!}\n            tokenUsage={tokenUsage ?? null}\n            className=\"ml-auto\"\n          />\n        )}\n      </div>\n    </div>\n  );\n});\n"
  },
  {
    "path": "web/src/features/chat/components/prompt-toolbar/open-in-button.tsx",
    "content": "import { type ReactElement, useCallback, useMemo } from \"react\";\nimport {\n  CopyIcon,\n  ExternalLinkIcon,\n  FolderOpenIcon,\n  CodeIcon,\n  AppWindowIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { getAuthHeader } from \"@/lib/auth\";\nimport { isMacOS } from \"@/hooks/utils\";\n\ntype OpenTarget = {\n  id: string;\n  label: string;\n  icon: ReactElement;\n  backendApp: \"finder\" | \"cursor\" | \"vscode\";\n};\n\nconst OPEN_TARGETS: OpenTarget[] = [\n  { id: \"finder\", label: \"Finder\", icon: <FolderOpenIcon className=\"size-3.5\" />, backendApp: \"finder\" },\n  { id: \"cursor\", label: \"Cursor\", icon: <AppWindowIcon className=\"size-3.5\" />, backendApp: \"cursor\" },\n  { id: \"vscode\", label: \"VS Code\", icon: <CodeIcon className=\"size-3.5\" />, backendApp: \"vscode\" },\n];\n\nasync function openViaBackend(app: OpenTarget[\"backendApp\"], path: string) {\n  const response = await fetch(\"/api/open-in\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\", ...getAuthHeader() },\n    body: JSON.stringify({ app, path }),\n  });\n  if (!response.ok) {\n    let detail = \"Failed to open application.\";\n    try {\n      const data = await response.json();\n      if (data?.detail) detail = String(data.detail);\n    } catch { /* ignore */ }\n    throw new Error(detail);\n  }\n}\n\nexport function OpenInButton({ path, className }: { path: string; className?: string }) {\n  const targets = useMemo(() => (isMacOS() ? OPEN_TARGETS : OPEN_TARGETS.filter((t) => t.id !== \"finder\")), []);\n\n  const handleOpen = useCallback(async (target: OpenTarget, e: Event) => {\n    e.stopPropagation();\n    try { await openViaBackend(target.backendApp, path); }\n    catch (error) { toast.error(\"Failed to open\", { description: error instanceof Error ? error.message : \"Unexpected error\" }); }\n  }, [path]);\n\n  const handleCopyPath = useCallback(async (e: Event) => {\n    e.stopPropagation();\n    try { await navigator.clipboard.writeText(path); toast.success(\"Path copied\", { description: path }); }\n    catch { toast.error(\"Failed to copy path\"); }\n  }, [path]);\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          onClick={(e) => e.stopPropagation()}\n          className={cn(\n            \"inline-flex items-center gap-1 rounded px-1.5 py-0.5\",\n            \"text-[10px] font-medium text-muted-foreground\",\n            \"bg-background/80 hover:bg-background hover:text-foreground\",\n            \"border border-border/50 shadow-sm transition-all duration-150 cursor-pointer\",\n            className,\n          )}\n        >\n          <ExternalLinkIcon className=\"size-2.5\" />\n          <span>Open</span>\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"min-w-[120px]\" onClick={(e) => e.stopPropagation()}>\n        {targets.map((target) => (\n          <DropdownMenuItem key={target.id} onSelect={(e) => handleOpen(target, e)} className=\"text-xs\">\n            {target.icon}\n            <span>{target.label}</span>\n          </DropdownMenuItem>\n        ))}\n        <DropdownMenuSeparator />\n        <DropdownMenuItem onSelect={handleCopyPath} className=\"text-xs\">\n          <CopyIcon className=\"size-3.5\" />\n          <span>Copy path</span>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/prompt-toolbar/toolbar-changes.tsx",
    "content": "import { type ReactElement, memo, useCallback } from \"react\";\nimport {\n  ChevronDownIcon,\n  FileIcon,\n  FolderOpenIcon,\n  GitBranchIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { GitDiffStats } from \"@/lib/api/models\";\nimport { OpenInButton } from \"./open-in-button\";\n\nconst TRAILING_SLASHES_REGEX = /\\/+$/;\n\n// ─── Exported components ─────────────────────────────────────\n\ntype ToolbarChangesPanelProps = {\n  stats: GitDiffStats;\n  workDir?: string | null;\n};\n\nexport const ToolbarChangesPanel = memo(function ToolbarChangesPanelComponent({\n  stats,\n  workDir,\n}: ToolbarChangesPanelProps): ReactElement {\n  const getFilePath = useCallback(\n    (relativePath: string) => {\n      if (!workDir) return relativePath;\n      return `${workDir.replace(TRAILING_SLASHES_REGEX, \"\")}/${relativePath}`;\n    },\n    [workDir],\n  );\n\n  return (\n    <div className=\"flex flex-col max-h-32\">\n      <div className=\"overflow-y-auto py-1 px-0.5 flex-1 min-h-0\">\n        {stats.files?.map((file) => (\n          <div\n            key={file.path}\n            className=\"group/file flex items-center gap-2 px-3 py-1 text-xs hover:bg-muted/50 transition-colors\"\n          >\n            <FileIcon className=\"size-3 flex-shrink-0 text-muted-foreground\" />\n            <span className=\"flex items-center gap-1 flex-shrink-0 text-[11px]\">\n              {file.additions > 0 && (\n                <span className=\"text-emerald-600 dark:text-emerald-400\">+{file.additions}</span>\n              )}\n              {file.deletions > 0 && <span className=\"text-destructive\">-{file.deletions}</span>}\n            </span>\n            <span className=\"truncate text-muted-foreground\" title={file.path}>\n              {file.path}\n            </span>\n            {workDir && (\n              <div className=\"flex-shrink-0 hidden lg:block opacity-0 group-hover/file:opacity-100 transition-opacity duration-150\">\n                <OpenInButton path={getFilePath(file.path)} />\n              </div>\n            )}\n          </div>\n        ))}\n      </div>\n      {workDir && (\n        <div className=\"group/folder flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-t bg-muted/40 flex-shrink-0\">\n          <FolderOpenIcon className=\"size-3 flex-shrink-0 text-muted-foreground/70\" />\n          <span className=\"truncate text-muted-foreground/70\">{workDir}</span>\n          <div className=\"flex-shrink-0 hidden lg:block opacity-0 group-hover/folder:opacity-100 transition-opacity duration-150\">\n            <OpenInButton path={workDir} />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n});\n\ntype ToolbarChangesTabProps = {\n  stats: GitDiffStats;\n  isActive: boolean;\n  onToggle: () => void;\n};\n\nexport const ToolbarChangesTab = memo(function ToolbarChangesTabComponent({\n  stats,\n  isActive,\n  onToggle,\n}: ToolbarChangesTabProps): ReactElement {\n  const fileCount = stats.files?.length ?? 0;\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onToggle}\n      className={cn(\n        \"flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer border\",\n        isActive\n          ? \"bg-secondary text-foreground border-border shadow-sm\"\n          : \"bg-transparent text-muted-foreground border-border/60 hover:text-foreground hover:border-border\",\n      )}\n    >\n      <GitBranchIcon className=\"size-3\" />\n      <span className=\"flex items-center gap-1\">\n        <span className=\"text-emerald-600 dark:text-emerald-400\">\n          +{stats.totalAdditions}\n        </span>\n        <span className=\"text-destructive\">\n          -{stats.totalDeletions}\n        </span>\n      </span>\n      <span>\n        {fileCount} file{fileCount !== 1 ? \"s\" : \"\"}\n      </span>\n      <ChevronDownIcon\n        className={cn(\n          \"size-3 transition-transform duration-200\",\n          isActive && \"rotate-180\",\n        )}\n      />\n    </button>\n  );\n});\n"
  },
  {
    "path": "web/src/features/chat/components/prompt-toolbar/toolbar-context.tsx",
    "content": "import { type ReactElement, memo } from \"react\";\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { ContextProgressIcon } from \"@ai-elements\";\nimport { cn } from \"@/lib/utils\";\nimport type { TokenUsage } from \"@/hooks/wireTypes\";\n\ntype ToolbarContextIndicatorProps = {\n  usagePercent: number;\n  usedTokens: number;\n  maxTokens: number;\n  tokenUsage: TokenUsage | null;\n  className?: string;\n};\n\nexport const ToolbarContextIndicator = memo(\n  function ToolbarContextIndicatorComponent({\n    usagePercent,\n    usedTokens,\n    maxTokens,\n    tokenUsage,\n    className,\n  }: ToolbarContextIndicatorProps): ReactElement {\n    const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0;\n\n    const used = new Intl.NumberFormat(\"en-US\", {\n      notation: \"compact\",\n    }).format(usedTokens);\n    const total = new Intl.NumberFormat(\"en-US\", {\n      notation: \"compact\",\n    }).format(maxTokens);\n\n    return (\n      <HoverCard openDelay={200} closeDelay={150}>\n        <HoverCardTrigger asChild>\n          <button\n            type=\"button\"\n            className={cn(\n              \"flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs font-medium\",\n              \"transition-colors cursor-default border\",\n              \"bg-transparent text-muted-foreground border-border/60\",\n              \"hover:text-foreground hover:border-border\",\n              className,\n            )}\n          >\n            <ContextProgressIcon usedPercent={usedPercent} size={14} />\n            <span>{usagePercent.toFixed(1)}% context</span>\n          </button>\n        </HoverCardTrigger>\n        <HoverCardContent\n          align=\"end\"\n          side=\"top\"\n          sideOffset={8}\n          className=\"w-64 p-0\"\n        >\n          <div className=\"w-full space-y-2 p-3\">\n            <div className=\"flex items-center justify-between gap-3 text-xs\">\n              <p>{usagePercent.toFixed(1)}%</p>\n              <p className=\"font-mono text-muted-foreground\">\n                {used} / {total}\n              </p>\n            </div>\n            <Progress className=\"bg-muted\" value={usagePercent} />\n          </div>\n\n          {tokenUsage && (\n            <div className=\"border-t p-3 space-y-2.5 text-xs\">\n              <div className=\"space-y-1\">\n                <div className=\"text-[11px] font-medium text-muted-foreground\">\n                  Input Tokens\n                </div>\n                <RawUsageRow\n                  label=\"Regular\"\n                  value={tokenUsage.input_other}\n                  description=\"Tokens processed without cache\"\n                />\n                <RawUsageRow\n                  label=\"Cache Read\"\n                  value={tokenUsage.input_cache_read}\n                  description=\"Tokens loaded from cache\"\n                />\n                <RawUsageRow\n                  label=\"Cache Write\"\n                  value={tokenUsage.input_cache_creation}\n                  description=\"Tokens written to cache\"\n                />\n                <div className=\"flex items-center justify-between text-xs font-medium border-t mt-1 pt-1\">\n                  <span>Total Input</span>\n                  <span>\n                    {new Intl.NumberFormat(\"en-US\", { notation: \"compact\" }).format(\n                      tokenUsage.input_other +\n                      tokenUsage.input_cache_read +\n                      tokenUsage.input_cache_creation\n                    )}\n                  </span>\n                </div>\n              </div>\n\n              <div className=\"space-y-1 border-t pt-2.5\">\n                <div className=\"text-[11px] font-medium text-muted-foreground\">\n                  Output Tokens\n                </div>\n                <RawUsageRow\n                  label=\"Generated\"\n                  value={tokenUsage.output}\n                  description=\"Tokens generated in response\"\n                />\n              </div>\n            </div>\n          )}\n        </HoverCardContent>\n      </HoverCard>\n    );\n  },\n);\n\nconst RawUsageRow = ({\n  label,\n  value,\n  description,\n}: {\n  label: string;\n  value: number;\n  description?: string;\n}) => {\n  const content = (\n    <div className=\"flex items-center justify-between text-xs\">\n      <span className=\"text-muted-foreground\">{label}</span>\n      <span>\n        {new Intl.NumberFormat(\"en-US\", { notation: \"compact\" }).format(value)}\n      </span>\n    </div>\n  );\n\n  if (description) {\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <div className=\"cursor-help\">{content}</div>\n        </TooltipTrigger>\n        <TooltipContent side=\"left\">\n          <p className=\"text-xs\">{description}</p>\n        </TooltipContent>\n      </Tooltip>\n    );\n  }\n\n  return content;\n};\n"
  },
  {
    "path": "web/src/features/chat/components/prompt-toolbar/toolbar-queue.tsx",
    "content": "import {\n  type KeyboardEvent,\n  type ReactElement,\n  memo,\n  useCallback,\n  useState,\n} from \"react\";\nimport {\n  ArrowUpIcon,\n  CheckIcon,\n  ChevronDownIcon,\n  ListOrderedIcon,\n  PencilIcon,\n  Trash2Icon,\n  XIcon,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { useQueueStore, type QueuedItem } from \"../../queue-store\";\n\n// ─── Sub-components ──────────────────────────────────────────\n\nfunction QueueItemRow({ item, isFirst, onEdit }: { item: QueuedItem; isFirst: boolean; onEdit: (id: string) => void }): ReactElement {\n  const removeFromQueue = useQueueStore((s) => s.removeFromQueue);\n  const moveQueueItemUp = useQueueStore((s) => s.moveQueueItemUp);\n\n  return (\n    <div className=\"group flex items-center gap-1.5 px-3 py-1.5  hover:bg-muted/50 transition-colors\">\n      <p className=\"min-w-0 text-xs text-foreground truncate leading-relaxed\">\n        {item.text}\n      </p>\n      <div className=\"flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity\">\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button variant=\"ghost\" size=\"icon-sm\" className=\"size-5\" onClick={() => onEdit(item.id)}>\n              <PencilIcon className=\"size-3\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>Edit</TooltipContent>\n        </Tooltip>\n        {!isFirst && (\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button variant=\"ghost\" size=\"icon-sm\" className=\"size-5\" onClick={() => moveQueueItemUp(item.id)}>\n                <ArrowUpIcon className=\"size-3\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>Move up</TooltipContent>\n          </Tooltip>\n        )}\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button variant=\"ghost\" size=\"icon-sm\" className=\"size-5 text-muted-foreground hover:text-destructive\" onClick={() => removeFromQueue(item.id)}>\n              <Trash2Icon className=\"size-3\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>Remove</TooltipContent>\n        </Tooltip>\n      </div>\n    </div>\n  );\n}\n\nfunction EditingItemRow({ item, onDone }: { item: QueuedItem; onDone: () => void }): ReactElement {\n  const [text, setText] = useState(item.text);\n  const editQueueItem = useQueueStore((s) => s.editQueueItem);\n\n  const handleSave = useCallback(() => {\n    if (text.trim()) editQueueItem(item.id, text.trim());\n    onDone();\n  }, [text, item.id, editQueueItem, onDone]);\n\n  const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Enter\") { e.preventDefault(); handleSave(); }\n    if (e.key === \"Escape\") { e.preventDefault(); onDone(); }\n  }, [handleSave, onDone]);\n\n  return (\n    <div className=\"flex items-center gap-1.5 px-3 py-1.5 bg-muted/30\">\n      <input\n        autoFocus\n        aria-label=\"Edit queued message\"\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        onKeyDown={handleKeyDown}\n        className=\"flex-1 min-w-0 text-xs bg-transparent border-b border-border outline-none py-0.5\"\n      />\n      <Button variant=\"ghost\" size=\"icon-sm\" className=\"size-5\" onClick={handleSave}>\n        <CheckIcon className=\"size-3\" />\n      </Button>\n      <Button variant=\"ghost\" size=\"icon-sm\" className=\"size-5\" onClick={onDone}>\n        <XIcon className=\"size-3\" />\n      </Button>\n    </div>\n  );\n}\n\n// ─── Exported components ─────────────────────────────────────\n\ntype ToolbarQueuePanelProps = {\n  queue: QueuedItem[];\n};\n\nexport const ToolbarQueuePanel = memo(function ToolbarQueuePanelComponent({\n  queue,\n}: ToolbarQueuePanelProps): ReactElement {\n  const [editingId, setEditingId] = useState<string | null>(null);\n  const handleEditDone = useCallback(() => setEditingId(null), []);\n\n  return (\n    <>\n      {queue.map((item, idx) =>\n        editingId === item.id ? (\n          <EditingItemRow key={item.id} item={item} onDone={handleEditDone} />\n        ) : (\n          <QueueItemRow key={item.id} item={item} isFirst={idx === 0} onEdit={setEditingId} />\n        ),\n      )}\n    </>\n  );\n});\n\ntype ToolbarQueueTabProps = {\n  count: number;\n  isActive: boolean;\n  onToggle: () => void;\n};\n\nexport const ToolbarQueueTab = memo(function ToolbarQueueTabComponent({\n  count,\n  isActive,\n  onToggle,\n}: ToolbarQueueTabProps): ReactElement {\n  return (\n    <button\n      type=\"button\"\n      onClick={onToggle}\n      className={cn(\n        \"flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer border\",\n        isActive\n          ? \"bg-secondary text-foreground border-border shadow-sm\"\n          : \"bg-transparent text-muted-foreground border-border/60 hover:text-foreground hover:border-border\",\n      )}\n    >\n      <ListOrderedIcon className=\"size-3\" />\n      <span>{count} Queued</span>\n      <ChevronDownIcon\n        className={cn(\n          \"size-3 transition-transform duration-200\",\n          isActive && \"rotate-180\",\n        )}\n      />\n    </button>\n  );\n});\n"
  },
  {
    "path": "web/src/features/chat/components/prompt-toolbar/toolbar-todo.tsx",
    "content": "import { type ReactElement, memo } from \"react\";\nimport {\n  CheckCircle2Icon,\n  CheckSquare2Icon,\n  ChevronDownIcon,\n  CircleDotIcon,\n  CircleIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { TodoItem } from \"@/features/tool/store\";\n\n// ─── Panel ───────────────────────────────────────────────────\n\ntype ToolbarTodoPanelProps = {\n  items: TodoItem[];\n};\n\nexport const ToolbarTodoPanel = memo(function ToolbarTodoPanelComponent({\n  items,\n}: ToolbarTodoPanelProps): ReactElement {\n  return (\n    <>\n      {items.map((item, index) => (\n        <div\n          key={`${index}-${item.title}`}\n          className=\"flex items-center gap-2 px-3 py-1 text-xs\"\n        >\n          {item.status === \"done\" && (\n            <CheckCircle2Icon className=\"size-3 flex-shrink-0 text-emerald-500\" />\n          )}\n          {item.status === \"in_progress\" && (\n            <CircleDotIcon className=\"size-3 flex-shrink-0 text-blue-500\" />\n          )}\n          {item.status === \"pending\" && (\n            <CircleIcon className=\"size-3 flex-shrink-0 text-muted-foreground\" />\n          )}\n          <span\n            className={cn(\n              \"truncate\",\n              item.status === \"done\"\n                ? \"line-through text-muted-foreground\"\n                : item.status === \"in_progress\"\n                  ? \"text-foreground font-medium\"\n                  : \"text-muted-foreground\",\n            )}\n          >\n            {item.title}\n          </span>\n        </div>\n      ))}\n    </>\n  );\n});\n\n// ─── Tab ─────────────────────────────────────────────────────\n\ntype ToolbarTodoTabProps = {\n  items: TodoItem[];\n  isActive: boolean;\n  onToggle: () => void;\n};\n\nexport const ToolbarTodoTab = memo(function ToolbarTodoTabComponent({\n  items,\n  isActive,\n  onToggle,\n}: ToolbarTodoTabProps): ReactElement {\n  const doneCount = items.filter((i) => i.status === \"done\").length;\n  const totalCount = items.length;\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onToggle}\n      className={cn(\n        \"flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer border\",\n        isActive\n          ? \"bg-secondary text-foreground border-border shadow-sm\"\n          : \"bg-transparent text-muted-foreground border-border/60 hover:text-foreground hover:border-border\",\n      )}\n    >\n      <CheckSquare2Icon className=\"size-3\" />\n      <span>\n        {doneCount}/{totalCount} Tasks\n      </span>\n      <ChevronDownIcon\n        className={cn(\n          \"size-3 transition-transform duration-200\",\n          isActive && \"rotate-180\",\n        )}\n      />\n    </button>\n  );\n});\n"
  },
  {
    "path": "web/src/features/chat/components/question-dialog.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { ChevronRightIcon } from \"lucide-react\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\nimport { MessageResponse } from \"@/components/ai-elements/message\";\nimport type { LiveMessage } from \"@/hooks/types\";\nimport type { QuestionItem } from \"@/hooks/wireTypes\";\n\ntype QuestionDialogProps = {\n  messages: LiveMessage[];\n  onQuestionResponse?: (\n    requestId: string,\n    answers: Record<string, string>,\n  ) => Promise<void>;\n  pendingQuestionMap: Record<string, boolean>;\n};\n\n/**\n * Detect the pending question from messages.\n * Exported so chat.tsx can check if a question is active without duplicating logic.\n */\nexport function usePendingQuestion(messages: LiveMessage[]) {\n  return useMemo(() => {\n    for (const message of messages) {\n      if (\n        message.variant === \"tool\" &&\n        message.toolCall?.question &&\n        message.toolCall.state === \"question-requested\" &&\n        !message.toolCall.question.submitted\n      ) {\n        return {\n          message,\n          question: message.toolCall.question,\n          toolCall: message.toolCall,\n        };\n      }\n    }\n    return null;\n  }, [messages]);\n}\n\n/**\n * QuestionDialog replaces the prompt composer when a question is pending.\n */\nexport function QuestionDialog({\n  messages,\n  onQuestionResponse,\n  pendingQuestionMap,\n}: QuestionDialogProps) {\n  const pendingQuestion = usePendingQuestion(messages);\n\n  const questions = pendingQuestion?.question.questions ?? [];\n  const totalQuestions = questions.length;\n\n  const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);\n  const [selectedIndex, setSelectedIndex] = useState<number>(0);\n  const [multiSelected, setMultiSelected] = useState<Set<number>>(new Set());\n  const [otherText, setOtherText] = useState(\"\");\n  const [answers, setAnswers] = useState<Record<string, string>>({});\n  const otherInputRef = useRef<HTMLInputElement>(null);\n  const savedSelectionsRef = useRef<\n    Map<number, { selectedIndex: number; multiSelected: Set<number>; otherText: string }>\n  >(new Map());\n\n  // Reset state when the pending question changes\n  const questionId = pendingQuestion?.question.id;\n  const prevQuestionIdRef = useRef<string | undefined>(undefined);\n  useEffect(() => {\n    if (questionId !== prevQuestionIdRef.current) {\n      prevQuestionIdRef.current = questionId;\n      setCurrentQuestionIndex(0);\n      setSelectedIndex(0);\n      setMultiSelected(new Set());\n      setOtherText(\"\");\n      setAnswers({});\n      savedSelectionsRef.current.clear();\n    }\n  }, [questionId]);\n\n  const currentQuestion: QuestionItem | undefined =\n    questions[currentQuestionIndex];\n  const options = currentQuestion?.options ?? [];\n  const isMultiSelect = currentQuestion?.multi_select ?? false;\n  const otherIndex = options.length;\n\n  // Auto-focus/blur Other input as selectedIndex moves in/out\n  useEffect(() => {\n    if (selectedIndex === otherIndex) {\n      // In multi-select, only auto-focus if Other is checked\n      if (!isMultiSelect || multiSelected.has(otherIndex)) {\n        otherInputRef.current?.focus();\n      }\n    } else if (document.activeElement === otherInputRef.current) {\n      otherInputRef.current?.blur();\n    }\n  }, [selectedIndex, otherIndex, isMultiSelect, multiSelected]);\n\n  const questionPending = questionId\n    ? pendingQuestionMap[questionId] === true\n    : false;\n  const disableActions = !onQuestionResponse || questionPending;\n\n  const focusOtherInputAfterToggle = useCallback((wasSelectedBeforeToggle: boolean) => {\n    if (wasSelectedBeforeToggle) {\n      // Unchecking Other: blur to prevent onFocus re-adding\n      if (document.activeElement === otherInputRef.current) {\n        otherInputRef.current?.blur();\n      }\n    } else {\n      // Checking Other: focus input for typing\n      setTimeout(() => otherInputRef.current?.focus(), 0);\n    }\n  }, []);\n\n  const getCurrentAnswer = useCallback((): string | null => {\n    if (isMultiSelect) {\n      const labels: string[] = [];\n      for (const idx of Array.from(multiSelected).sort((a, b) => a - b)) {\n        if (idx === otherIndex) {\n          if (otherText.trim()) labels.push(otherText.trim());\n        } else if (options[idx]) {\n          labels.push(options[idx].label);\n        }\n      }\n      return labels.length > 0 ? labels.join(\", \") : null;\n    }\n    if (selectedIndex === otherIndex) {\n      return otherText.trim() || null;\n    }\n    return options[selectedIndex]?.label ?? null;\n  }, [isMultiSelect, multiSelected, selectedIndex, otherIndex, otherText, options]);\n\n  /** Restore selection state for a given question index. */\n  const restoreForQuestion = useCallback(\n    (index: number) => {\n      const saved = savedSelectionsRef.current.get(index);\n      if (saved) {\n        setSelectedIndex(saved.selectedIndex);\n        setMultiSelected(saved.multiSelected);\n        setOtherText(saved.otherText);\n        return;\n      }\n      // Fall back to answers dict — find which option was submitted\n      const targetQuestion = questions[index];\n      if (targetQuestion) {\n        const prevAnswer = answers[targetQuestion.question];\n        if (prevAnswer) {\n          if (targetQuestion.multi_select) {\n            const answerLabels = prevAnswer.split(\", \").map((s) => s.trim());\n            const knownLabels = new Set(targetQuestion.options.map((o) => o.label));\n            const selected = new Set<number>();\n            targetQuestion.options.forEach((o, i) => {\n              if (answerLabels.includes(o.label)) {\n                selected.add(i);\n              }\n            });\n            // Unmatched labels = Other text\n            const otherParts = answerLabels.filter((l) => !knownLabels.has(l));\n            if (otherParts.length > 0) {\n              selected.add(targetQuestion.options.length);\n              setOtherText(otherParts.join(\", \"));\n            } else {\n              setOtherText(\"\");\n            }\n            setMultiSelected(selected);\n            setSelectedIndex(selected.size > 0 ? Math.min(...selected) : 0);\n          } else {\n            const foundIdx = targetQuestion.options.findIndex(\n              (o) => o.label === prevAnswer,\n            );\n            if (foundIdx >= 0) {\n              setSelectedIndex(foundIdx);\n              setOtherText(\"\");\n            } else {\n              // Answer was Other text\n              setSelectedIndex(targetQuestion.options.length);\n              setOtherText(prevAnswer);\n            }\n            setMultiSelected(new Set());\n          }\n          return;\n        }\n      }\n      // Brand new question — default to first option\n      setSelectedIndex(0);\n      setMultiSelected(new Set());\n      setOtherText(\"\");\n    },\n    [questions, answers],\n  );\n\n  /** Core advance logic: save state, record answer, advance or submit all. */\n  const advanceWithAnswer = useCallback(\n    async (answer: string) => {\n      if (disableActions || !currentQuestion || !pendingQuestion) return;\n\n      if (document.activeElement instanceof HTMLElement) {\n        document.activeElement.blur();\n      }\n\n      const newAnswers = {\n        ...answers,\n        [currentQuestion.question]: answer,\n      };\n      savedSelectionsRef.current.delete(currentQuestionIndex);\n      // Keep local state in sync immediately, even if the final async submit fails.\n      setAnswers(newAnswers);\n\n      const allAnswered = questions.every((q) => q.question in newAnswers);\n      if (allAnswered) {\n        try {\n          await onQuestionResponse!(pendingQuestion.question.id, newAnswers);\n        } catch (error) {\n          console.error(\"[QuestionDialog] Failed to respond\", error);\n        }\n      } else {\n        for (let offset = 1; offset <= totalQuestions; offset++) {\n          const idx = (currentQuestionIndex + offset) % totalQuestions;\n          if (!(questions[idx].question in newAnswers)) {\n            setCurrentQuestionIndex(idx);\n            restoreForQuestion(idx);\n            break;\n          }\n        }\n      }\n    },\n    [\n      disableActions,\n      currentQuestion,\n      pendingQuestion,\n      answers,\n      questions,\n      currentQuestionIndex,\n      totalQuestions,\n      onQuestionResponse,\n      restoreForQuestion,\n    ],\n  );\n\n  const handleSubmitCurrent = useCallback(async () => {\n    const answer = getCurrentAnswer();\n    if (!answer) return;\n    await advanceWithAnswer(answer);\n  }, [getCurrentAnswer, advanceWithAnswer]);\n\n  const handleDismiss = useCallback(async () => {\n    if (disableActions || !pendingQuestion) return;\n    try {\n      await onQuestionResponse!(pendingQuestion.question.id, {});\n    } catch (error) {\n      console.error(\"[QuestionDialog] Failed to dismiss\", error);\n    }\n  }, [disableActions, pendingQuestion, onQuestionResponse]);\n\n  const handleTabClick = useCallback(\n    (index: number) => {\n      if (disableActions || index === currentQuestionIndex) return;\n      // Save current cursor state\n      savedSelectionsRef.current.set(currentQuestionIndex, {\n        selectedIndex,\n        multiSelected,\n        otherText,\n      });\n      setCurrentQuestionIndex(index);\n      restoreForQuestion(index);\n    },\n    [disableActions, currentQuestionIndex, selectedIndex, multiSelected, otherText, restoreForQuestion],\n  );\n\n  const handleOptionClick = useCallback(\n    (idx: number) => {\n      if (disableActions) return;\n\n      if (isMultiSelect) {\n        const wasSelectedBeforeToggle = multiSelected.has(idx);\n        setSelectedIndex(idx);\n        setMultiSelected((prev) => {\n          const next = new Set(prev);\n          if (next.has(idx)) {\n            next.delete(idx);\n          } else {\n            next.add(idx);\n          }\n          return next;\n        });\n        if (idx === otherIndex) {\n          focusOtherInputAfterToggle(wasSelectedBeforeToggle);\n        }\n      } else if (idx === otherIndex) {\n        // Other option: focus input for typing\n        setSelectedIndex(idx);\n        setTimeout(() => otherInputRef.current?.focus(), 0);\n      } else {\n        // Single-select click on regular option: auto-confirm\n        setSelectedIndex(idx);\n        const clickedAnswer = options[idx]?.label;\n        if (clickedAnswer) {\n          advanceWithAnswer(clickedAnswer);\n        }\n      }\n    },\n    [disableActions, isMultiSelect, otherIndex, options, advanceWithAnswer, focusOtherInputAfterToggle, multiSelected],\n  );\n\n  // Keyboard navigation\n  useEffect(() => {\n    if (!pendingQuestion || disableActions) return;\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.defaultPrevented || event.repeat || event.isComposing) return;\n      if (event.metaKey || event.ctrlKey || event.altKey) return;\n\n      const el = document.activeElement;\n      if (el) {\n        const tag = el.tagName;\n        if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\") return;\n        if ((el as HTMLElement).isContentEditable) return;\n      }\n\n      const totalOptions = options.length + 1;\n\n      const num = Number.parseInt(event.key, 10);\n      if (num >= 1 && num <= totalOptions) {\n        event.preventDefault();\n        handleOptionClick(num - 1);\n        return;\n      }\n\n      if (event.key === \"ArrowLeft\" && currentQuestionIndex > 0) {\n        event.preventDefault();\n        handleTabClick(currentQuestionIndex - 1);\n      } else if (event.key === \"ArrowRight\" && currentQuestionIndex < totalQuestions - 1) {\n        event.preventDefault();\n        handleTabClick(currentQuestionIndex + 1);\n      } else if (event.key === \"ArrowDown\" || event.key === \"j\") {\n        event.preventDefault();\n        setSelectedIndex((prev) => Math.min(prev + 1, totalOptions - 1));\n      } else if (event.key === \"ArrowUp\" || event.key === \"k\") {\n        event.preventDefault();\n        setSelectedIndex((prev) => Math.max(prev - 1, 0));\n      } else if (event.key === \" \") {\n        event.preventDefault();\n        if (isMultiSelect) {\n          const wasSelectedBeforeToggle = multiSelected.has(selectedIndex);\n          // Toggle checkbox at focused position\n          setMultiSelected((prev) => {\n            const next = new Set(prev);\n            if (next.has(selectedIndex)) {\n              next.delete(selectedIndex);\n            } else {\n              next.add(selectedIndex);\n            }\n            return next;\n          });\n          if (selectedIndex === otherIndex) {\n            focusOtherInputAfterToggle(wasSelectedBeforeToggle);\n          }\n        } else {\n          // Single-select: confirm like Enter\n          handleSubmitCurrent();\n        }\n      } else if (event.key === \"Enter\") {\n        event.preventDefault();\n        handleSubmitCurrent();\n      } else if (event.key === \"Escape\") {\n        event.preventDefault();\n        handleDismiss();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [pendingQuestion, disableActions, options.length, isMultiSelect, selectedIndex, otherIndex, handleSubmitCurrent, handleOptionClick, handleDismiss, handleTabClick, currentQuestionIndex, totalQuestions, multiSelected, focusOtherInputAfterToggle]);\n\n  if (!(pendingQuestion && currentQuestion)) return null;\n\n  const hasAnswer = getCurrentAnswer() !== null;\n  // Would submitting the current answer complete all questions?\n  const allQuestionsAnswered =\n    hasAnswer &&\n    questions.every(\n      (q) => q.question in answers || q.question === currentQuestion.question,\n    );\n\n  return (\n    <div className=\"w-full px-0 pb-0 pt-0 sm:px-3 sm:pb-3\">\n      <div\n        className={cn(\n          \"relative w-full\",\n          \"border border-border/60 rounded-xl\",\n          \"bg-background\",\n          \"shadow-sm\",\n          \"overflow-hidden\",\n        )}\n      >\n        {/* Tab bar for multi-question */}\n        {totalQuestions > 1 && (\n          <div className=\"flex items-center gap-1.5 px-4 pt-3 pb-1\">\n            {questions.map((q, i) => {\n              const label = q.header || `Q${i + 1}`;\n              const isAnswered = q.question in answers;\n              const isActive = i === currentQuestionIndex;\n              return (\n                <button\n                  key={`tab-${q.question}`}\n                  type=\"button\"\n                  disabled={disableActions}\n                  onClick={() => handleTabClick(i)}\n                  className={cn(\n                    \"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer\",\n                    isActive && \"bg-primary text-primary-foreground\",\n                    isAnswered && !isActive && \"bg-secondary text-secondary-foreground\",\n                    !(isActive || isAnswered) && \"border border-border/60 text-muted-foreground hover:bg-muted/50\",\n                    disableActions && \"opacity-50 cursor-not-allowed\",\n                  )}\n                >\n                  <span className=\"text-[10px]\">\n                    {isActive ? \"\\u25cf\" : isAnswered ? \"\\u2713\" : \"\\u25cb\"}\n                  </span>\n                  {label}\n                </button>\n              );\n            })}\n          </div>\n        )}\n\n        {/* Question text */}\n        <div className=\"flex items-center gap-2.5 px-4 pt-2 pb-1 mb-1\">\n          <span className=\"font-semibold text-sm text-foreground\">\n            {currentQuestion.question}\n          </span>\n          {totalQuestions === 1 && currentQuestion.header && (\n            <span className=\"inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground\">\n              {currentQuestion.header}\n            </span>\n          )}\n        </div>\n\n        {/* Plan body preview */}\n        {currentQuestion.body && (\n          <Collapsible defaultOpen className=\"mx-4 mb-2\">\n            <CollapsibleTrigger className=\"group flex items-center gap-2 w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1\">\n              <ChevronRightIcon className=\"size-3.5 transition-transform group-data-[state=open]:rotate-90\" />\n              <span>Plan Preview</span>\n            </CollapsibleTrigger>\n            <CollapsibleContent>\n              <div className=\"border-l-2 border-blue-400/40 pl-3 mt-1 max-h-[360px] overflow-y-auto\">\n                <MessageResponse>{currentQuestion.body}</MessageResponse>\n              </div>\n            </CollapsibleContent>\n          </Collapsible>\n        )}\n\n        {/* Options */}\n        <div className=\"flex flex-col px-4 py-2 gap-0.5\">\n          {options.map((option, idx) => {\n            const isFocused = selectedIndex === idx;\n            const isChecked = isMultiSelect && multiSelected.has(idx);\n            const displayNumber = idx + 1;\n\n            return (\n              <button\n                key={`${currentQuestion.question}-${option.label}`}\n                type=\"button\"\n                disabled={disableActions}\n                onClick={() => handleOptionClick(idx)}\n                className={cn(\n                  \"flex items-start gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors cursor-pointer\",\n                  \"hover:bg-muted/50\",\n                  isChecked && \"bg-primary/[0.08]\",\n                  isFocused && !isChecked && \"bg-muted/70\",\n                  isFocused && isChecked && \"ring-1 ring-inset ring-primary/20\",\n                  disableActions && \"opacity-50 cursor-not-allowed\",\n                )}\n              >\n                {isMultiSelect ? (\n                  <Checkbox\n                    checked={isChecked}\n                    className=\"mt-0.5 pointer-events-none\"\n                    tabIndex={-1}\n                  />\n                ) : (\n                  <span className=\"flex-shrink-0 w-5 text-right text-muted-foreground font-mono text-sm\">\n                    {displayNumber}.\n                  </span>\n                )}\n                <div className=\"flex flex-col min-w-0\">\n                  <span className=\"font-medium text-foreground\">\n                    {option.label}\n                  </span>\n                  {option.description && (\n                    <span className=\"text-xs text-muted-foreground\">\n                      {option.description}\n                    </span>\n                  )}\n                </div>\n              </button>\n            );\n          })}\n\n          {/* \"Other\" — inline input */}\n          <div\n            className={cn(\n              \"flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors\",\n              isMultiSelect && multiSelected.has(otherIndex) && \"bg-primary/[0.08]\",\n              selectedIndex === otherIndex && !(isMultiSelect && multiSelected.has(otherIndex)) && \"bg-muted/70\",\n              selectedIndex === otherIndex && isMultiSelect && multiSelected.has(otherIndex) && \"ring-1 ring-inset ring-primary/20\",\n            )}\n          >\n            {isMultiSelect ? (\n              <Checkbox\n                checked={multiSelected.has(otherIndex)}\n                onCheckedChange={() => handleOptionClick(otherIndex)}\n                className=\"mt-0.5 pointer-events-auto\"\n                tabIndex={-1}\n              />\n            ) : (\n              <span className=\"flex-shrink-0 w-5 text-right text-muted-foreground font-mono text-sm\">\n                {options.length + 1}.\n              </span>\n            )}\n            <div className=\"flex flex-col min-w-0 flex-1\">\n              {currentQuestion.other_label && (\n                <span className=\"font-medium text-foreground text-sm\">\n                  {currentQuestion.other_label}\n                </span>\n              )}\n              {currentQuestion.other_description && (\n                <span className=\"text-xs text-muted-foreground\">\n                  {currentQuestion.other_description}\n                </span>\n              )}\n              <input\n                ref={otherInputRef}\n                value={otherText}\n                onChange={(e) => {\n                  setOtherText(e.target.value);\n                  if (!isMultiSelect) {\n                    setSelectedIndex(otherIndex);\n                  } else if (!multiSelected.has(otherIndex)) {\n                    setMultiSelected((prev) => new Set(prev).add(otherIndex));\n                  }\n                }}\n                onFocus={() => {\n                  setSelectedIndex(otherIndex);\n                  if (isMultiSelect && !multiSelected.has(otherIndex)) {\n                    setMultiSelected((prev) => new Set(prev).add(otherIndex));\n                  }\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\" && !e.nativeEvent.isComposing) {\n                    e.preventDefault();\n                    handleSubmitCurrent();\n                  } else if (e.key === \"Escape\") {\n                    e.preventDefault();\n                    (e.target as HTMLInputElement).blur();\n                  } else if (e.key === \"ArrowUp\") {\n                    e.preventDefault();\n                    setSelectedIndex(Math.max(otherIndex - 1, 0));\n                  } else if (e.key === \"ArrowDown\") {\n                    // Already at last position — no-op but prevent default\n                    e.preventDefault();\n                  }\n                }}\n                placeholder={currentQuestion.other_label || \"Type your answer...\"}\n                className={cn(\n                  \"flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50\",\n                  \"border-0 outline-none ring-0 focus:ring-0 focus:outline-none\",\n                  \"py-0 h-auto\",\n                  disableActions && \"opacity-50 cursor-not-allowed\",\n                )}\n                disabled={disableActions}\n              />\n            </div>\n          </div>\n        </div>\n\n        {/* Footer */}\n        <div className=\"flex items-center gap-4 px-4 py-2.5 mt-1\">\n          {/* Keyboard hints */}\n          <div className=\"flex items-center gap-3 mr-auto text-muted-foreground/40 text-[11px]\">\n            <span className=\"inline-flex items-center gap-1\">\n              <Kbd className=\"text-[10px] opacity-60\">↑↓</Kbd>\n              select\n            </span>\n            {totalQuestions > 1 && (\n              <span className=\"inline-flex items-center gap-1\">\n                <Kbd className=\"text-[10px] opacity-60\">←→</Kbd>\n                switch\n              </span>\n            )}\n            {isMultiSelect ? (\n              <>\n                <span className=\"inline-flex items-center gap-1\">\n                  <Kbd className=\"text-[10px] opacity-60\">space</Kbd>\n                  toggle\n                </span>\n                <span className=\"inline-flex items-center gap-1\">\n                  <Kbd className=\"text-[10px] opacity-60\">↵</Kbd>\n                  confirm\n                </span>\n              </>\n            ) : (\n              <span className=\"inline-flex items-center gap-1\">\n                <Kbd className=\"text-[10px] opacity-60\">space/↵</Kbd>\n                confirm\n              </span>\n            )}\n          </div>\n\n          <button\n            type=\"button\"\n            disabled={disableActions}\n            onClick={handleDismiss}\n            className=\"inline-flex items-center gap-1.5 text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors disabled:opacity-50 cursor-pointer\"\n          >\n            Dismiss\n            <Kbd className=\"text-[11px] opacity-70\">esc</Kbd>\n          </button>\n          <button\n            type=\"button\"\n            disabled={disableActions || !hasAnswer}\n            onClick={handleSubmitCurrent}\n            className={cn(\n              \"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors\",\n              \"disabled:opacity-30 disabled:pointer-events-none cursor-pointer\",\n            )}\n          >\n            {questionPending\n              ? \"Submitting...\"\n              : allQuestionsAnswered\n                ? \"Submit\"\n                : \"Next\"}\n            {!questionPending && <Kbd className=\"text-[11px]\">↵</Kbd>}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/session-info-popover.tsx",
    "content": "import {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport type { Session } from \"@/lib/api/models\";\nimport { CheckIcon, CopyIcon, InfoIcon } from \"lucide-react\";\nimport { useState, useCallback } from \"react\";\n\ntype SessionInfoItemProps = {\n  label: string;\n  value: string;\n};\n\nfunction SessionInfoItem({ label, value }: SessionInfoItemProps) {\n  const [copied, setCopied] = useState(false);\n\n  const handleCopy = useCallback(async () => {\n    try {\n      await navigator.clipboard.writeText(value);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } catch (error) {\n      console.error(\"Failed to copy:\", error);\n    }\n  }, [value]);\n\n  return (\n    <div className=\"space-y-1\">\n      <p className=\"text-xs text-muted-foreground\">{label}</p>\n      <div className=\"flex items-center gap-2\">\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <code className=\"flex-1 truncate rounded bg-muted px-2 py-1 font-mono text-xs\">\n              {value}\n            </code>\n          </TooltipTrigger>\n          <TooltipContent side=\"top\" className=\"max-w-md break-all\">\n            {value}\n          </TooltipContent>\n        </Tooltip>\n        <button\n          type=\"button\"\n          onClick={handleCopy}\n          className=\"shrink-0 rounded p-1 cursor-pointer text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n          aria-label={`Copy ${label}`}\n        >\n          {copied ? (\n            <CheckIcon className=\"size-3.5 text-green-500\" />\n          ) : (\n            <CopyIcon className=\"size-3.5\" />\n          )}\n        </button>\n      </div>\n    </div>\n  );\n}\n\ntype SessionInfoSectionProps = {\n  sessionId: string;\n  session?: Session;\n};\n\nexport function SessionInfoSection({\n  sessionId,\n  session,\n}: SessionInfoSectionProps) {\n  return (\n    <div className=\"space-y-3\">\n      <p className=\"font-medium text-sm\">Session Info</p>\n      <SessionInfoItem label=\"Session ID\" value={sessionId} />\n      {session?.workDir && (\n        <SessionInfoItem label=\"Working Directory\" value={session.workDir} />\n      )}\n      {session?.sessionDir && (\n        <SessionInfoItem label=\"Session Directory\" value={session.sessionDir} />\n      )}\n    </div>\n  );\n}\n\ntype SessionInfoPopoverProps = {\n  sessionId: string;\n  session?: Session;\n};\n\nexport function SessionInfoPopover({\n  sessionId,\n  session,\n}: SessionInfoPopoverProps) {\n  return (\n    <DropdownMenu>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <DropdownMenuTrigger asChild>\n            <button\n              type=\"button\"\n              aria-label=\"Session info\"\n              className=\"inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground\"\n            >\n              <InfoIcon className=\"size-4\" />\n            </button>\n          </DropdownMenuTrigger>\n        </TooltipTrigger>\n        <TooltipContent side=\"bottom\">Session info</TooltipContent>\n      </Tooltip>\n      <DropdownMenuContent align=\"end\" className=\"w-100 p-3\">\n        <div className=\"space-y-3\">\n          <p className=\"font-medium text-sm\">Session Info</p>\n          <SessionInfoItem label=\"Session ID\" value={sessionId} />\n          {session?.workDir && (\n            <SessionInfoItem label=\"Working Directory\" value={session.workDir} />\n          )}\n          {session?.sessionDir && (\n            <SessionInfoItem label=\"Session Directory\" value={session.sessionDir} />\n          )}\n        </div>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/components/virtualized-message-list.tsx",
    "content": "import type { LiveMessage } from \"@/hooks/types\";\nimport {\n  Message,\n  MessageActions,\n  MessageAttachment,\n  MessageAttachments,\n  MessageContent,\n  MessageCopyButton,\n  MessageForkButton,\n  UserMessageContent,\n} from \"@ai-elements\";\nimport {\n  AssistantMessage,\n  type AssistantApprovalHandler,\n} from \"./assistant-message\";\n\nimport type React from \"react\";\nimport {\n  forwardRef,\n  useCallback,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  type ComponentPropsWithoutRef,\n} from \"react\";\nimport { Virtuoso, type VirtuosoHandle } from \"react-virtuoso\";\nimport { cn } from \"@/lib/utils\";\n\nexport type VirtualizedMessageListProps = {\n  messages: LiveMessage[];\n  conversationKey: string;\n  pendingApprovalMap: Record<string, boolean>;\n  onApprovalAction?: AssistantApprovalHandler;\n  canRespondToApproval: boolean;\n  blocksExpanded: boolean;\n  /** Index of message to highlight (for search) */\n  highlightedMessageIndex?: number;\n  /** Callback when scroll position changes */\n  onAtBottomChange?: (atBottom: boolean) => void;\n  /** Callback to fork session from before a specific turn */\n  onForkSession?: (turnIndex: number) => void;\n};\n\nexport type VirtualizedMessageListHandle = {\n  scrollToIndex: (index: number, behavior?: \"auto\" | \"smooth\") => void;\n  scrollToBottom: () => void;\n};\n\ntype ConversationListItem = {\n  message: LiveMessage;\n  index: number;\n};\n\nfunction VirtuosoScrollerComponent(\n  props: ComponentPropsWithoutRef<\"div\">,\n  ref: React.Ref<HTMLDivElement>,\n) {\n  const { className, ...rest } = props;\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"flex-1 overflow-y-auto overflow-x-hidden pr-1 sm:pr-2\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nconst VirtuosoScroller = forwardRef(VirtuosoScrollerComponent);\n\nfunction VirtuosoListComponent(\n  props: ComponentPropsWithoutRef<\"div\">,\n  ref: React.Ref<HTMLDivElement>,\n) {\n  const { className, ...rest } = props;\n  return (\n    <div\n      ref={ref}\n      className={cn(\"flex flex-col px-3 py-4 sm:px-6 lg:px-8\", className)}\n      {...rest}\n    />\n  );\n}\n\nconst VirtuosoList = forwardRef(VirtuosoListComponent);\n\nVirtuosoScroller.displayName = \"VirtuosoScroller\";\nVirtuosoList.displayName = \"VirtuosoList\";\n\nfunction getMessageSpacingClass(\n  message: LiveMessage,\n  index: number,\n  allMessages: LiveMessage[],\n): string | undefined {\n  // Terminal-style message spacing - more compact\n  // 1. User messages get breathing room (`mt-3`) from previous content\n  // 2. Assistant messages flow naturally with minimal spacing\n  // 3. Tool calls have subtle spacing to group related operations\n  const previousMessage = index > 0 ? allMessages[index - 1] : undefined;\n  const nextMessage =\n    index < allMessages.length - 1 ? allMessages[index + 1] : undefined;\n\n  const classes: string[] = [];\n\n  const isUser = message.role === \"user\";\n  const isAssistant = message.role === \"assistant\";\n  const isToolMessage = isAssistant && message.variant === \"tool\";\n  const isThinkingMessage = isAssistant && message.variant === \"thinking\";\n  const previousIsUser = previousMessage?.role === \"user\";\n  const previousIsAssistant = previousMessage?.role === \"assistant\";\n  const previousIsTool =\n    previousIsAssistant && previousMessage?.variant === \"tool\";\n\n  if (index > 0) {\n    if (isUser) {\n      // User messages get more space from previous content\n      classes.push(\"mt-4\");\n    } else if (isAssistant) {\n      if (isToolMessage) {\n        // Tool calls: slightly more breathing room between consecutive calls\n        classes.push(previousIsUser ? \"mt-2\" : \"mt-1.5\");\n      } else if (isThinkingMessage) {\n        // Thinking blocks have minimal spacing\n        classes.push(previousIsUser ? \"mt-2\" : \"mt-1\");\n      } else if (previousIsTool) {\n        // Text after tool gets slight spacing\n        classes.push(\"mt-2\");\n      } else if (previousIsAssistant) {\n        // Consecutive assistant messages flow together\n        classes.push(\"mt-1\");\n      } else {\n        // After user message\n        classes.push(\"mt-2\");\n      }\n    }\n  }\n\n  // Add bottom margin for the last message to avoid clashing with UI below\n  if (!nextMessage) {\n    classes.push(\"mb-30\");\n  }\n\n  return classes.length > 0 ? classes.join(\" \") : undefined;\n}\n\nfunction VirtualizedMessageListComponent(\n  {\n    messages,\n    conversationKey,\n    pendingApprovalMap,\n    onApprovalAction,\n    canRespondToApproval,\n    blocksExpanded,\n    highlightedMessageIndex = -1,\n    onAtBottomChange,\n    onForkSession,\n  }: VirtualizedMessageListProps,\n  ref: React.Ref<VirtualizedMessageListHandle>,\n) {\n  const virtuosoRef = useRef<VirtuosoHandle | null>(null);\n  const scrollerRef = useRef<HTMLElement | null>(null);\n\n  // Filtered messages list (excluding message-id) aligned with listItems indices\n  const filteredMessages = useMemo(\n    () => messages.filter((m) => m.variant !== \"message-id\"),\n    [messages],\n  );\n\n  const listItems = useMemo<ConversationListItem[]>(\n    () =>\n      filteredMessages.map((message, index) => ({ message, index })),\n    [filteredMessages],\n  );\n\n  const handleAtBottomChange = useCallback(\n    (atBottom: boolean) => {\n      onAtBottomChange?.(atBottom);\n    },\n    [onAtBottomChange],\n  );\n\n  const handleScrollerRef = useCallback(\n    (ref: HTMLElement | Window | null) => {\n      scrollerRef.current = ref instanceof HTMLElement ? ref : null;\n    },\n    [],\n  );\n\n  // Use a generous threshold to tolerate height estimation mismatches\n  // when blocks are expanded (actual heights >> defaultItemHeight).\n  // This is decoupled from atBottomStateChange which uses Virtuoso's\n  // default tight threshold for the scroll-to-bottom button.\n  const handleFollowOutput = useCallback(\n    (isAtBottom: boolean) => {\n      if (isAtBottom) return \"auto\" as const;\n      const scroller = scrollerRef.current;\n      if (scroller) {\n        const gap =\n          scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;\n        if (gap <= 1500) return \"auto\" as const;\n      }\n      return false;\n    },\n    [],\n  );\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      scrollToIndex: (\n        index: number,\n        behavior: \"auto\" | \"smooth\" = \"smooth\",\n      ) => {\n        virtuosoRef.current?.scrollToIndex({\n          index,\n          align: \"center\",\n          behavior,\n        });\n      },\n      scrollToBottom: () => {\n        if (listItems.length > 0) {\n          virtuosoRef.current?.scrollToIndex({\n            index: listItems.length - 1,\n            align: \"end\",\n            behavior: \"auto\",\n          });\n        }\n      },\n    }),\n    [listItems.length],\n  );\n\n  return (\n    <Virtuoso\n      key={conversationKey}\n      ref={virtuosoRef}\n      data={listItems}\n      className=\"h-full\"\n      scrollerRef={handleScrollerRef}\n      followOutput={handleFollowOutput}\n      defaultItemHeight={160}\n      increaseViewportBy={{ top: 400, bottom: 400 }}\n      overscan={200}\n      minOverscanItemCount={4}\n      atBottomStateChange={handleAtBottomChange}\n      initialTopMostItemIndex={{\n        index: Math.max(0, listItems.length - 1),\n        align: \"end\",\n      }}\n      components={{\n        Scroller: VirtuosoScroller,\n        List: VirtuosoList,\n      }}\n      computeItemKey={(_index: number, item: ConversationListItem) =>\n        item.message.id\n      }\n      itemContent={(_index, item) => {\n        const message = item.message;\n\n        if (message.variant === \"status\") {\n          return (\n            <Message\n              className={messages.length > 0 ? \"mt-2\" : undefined}\n              from=\"assistant\"\n            >\n              <MessageContent className=\"text-xs text-muted-foreground\">\n                {message.content}\n              </MessageContent>\n            </Message>\n          );\n        }\n\n        const spacingClass = getMessageSpacingClass(\n          message,\n          item.index,\n          filteredMessages,\n        );\n\n        const isHighlighted = item.index === highlightedMessageIndex;\n\n        return (\n          <Message\n            className={cn(\n              spacingClass,\n              isHighlighted && \"rounded-lg ring-2 ring-primary/50\",\n            )}\n            from={message.role}\n          >\n            {message.role === \"user\" ? (\n              message.content ? (\n                <UserMessageContent>{message.content}</UserMessageContent>\n              ) : null\n            ) : (\n              <>\n                <AssistantMessage\n                  message={message}\n                  pendingApprovalMap={pendingApprovalMap}\n                  onApprovalAction={onApprovalAction}\n                  canRespondToApproval={canRespondToApproval}\n                  blocksExpanded={blocksExpanded}\n                />\n                {!message.isStreaming &&\n                  (!message.variant || message.variant === \"text\") &&\n                  (message.content || (onForkSession && message.turnIndex !== undefined)) && (\n                  <MessageActions className=\"\n                  hover-reveal\n                   opacity-0 group-hover:opacity-100 transition-opacity mt-1\">\n                    {message.content && <MessageCopyButton content={message.content} />}\n                    {onForkSession && message.turnIndex !== undefined && (\n                      <MessageForkButton onFork={() => onForkSession(message.turnIndex!)} />\n                    )}\n                  </MessageActions>\n                )}\n              </>\n            )}\n            {message.attachments && message.attachments.length > 0 ? (\n              <MessageAttachments>\n                {message.attachments.map((attachment, attIdx) => {\n                  const key =\n                    \"kind\" in attachment\n                      ? attachment.filename\n                      : (attachment.filename ??\n                        attachment.url ??\n                        `${message.id}-${attIdx}`);\n                  return (\n                    <MessageAttachment\n                      className=\"size-28 sm:size-32 lg:size-40\"\n                      data={attachment}\n                      key={key}\n                    />\n                  );\n                })}\n              </MessageAttachments>\n            ) : null}\n          </Message>\n        );\n      }}\n    />\n  );\n}\n\nexport const VirtualizedMessageList = forwardRef(\n  VirtualizedMessageListComponent,\n);\nVirtualizedMessageList.displayName = \"VirtualizedMessageList\";\n"
  },
  {
    "path": "web/src/features/chat/file-mention-menu.tsx",
    "content": "import { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  AlertCircleIcon,\n  FileTextIcon,\n  PaperclipIcon,\n  RefreshCwIcon,\n} from \"lucide-react\";\nimport type { MentionOption, MentionSections } from \"./useFileMentions\";\n\nconst formatFileSize = (size?: number): string | null => {\n  if (size === null || size === undefined) {\n    return null;\n  }\n  if (size === 0) {\n    return \"0 B\";\n  }\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  let value = size;\n  let idx = 0;\n  while (value >= 1024 && idx < units.length - 1) {\n    value /= 1024;\n    idx += 1;\n  }\n  const precision = value >= 10 ? 0 : 1;\n  return `${value.toFixed(precision)} ${units[idx]}`;\n};\n\nconst MAX_WORKSPACE_FILES = 500;\n\ntype FileMentionMenuProps = {\n  open: boolean;\n  query: string;\n  sections: MentionSections;\n  flatOptions: MentionOption[];\n  activeIndex: number;\n  onSelect: (option: MentionOption) => void;\n  onHover: (index: number) => void;\n  workspaceStatus: \"idle\" | \"loading\" | \"ready\" | \"error\";\n  workspaceError: string | null;\n  onRetryWorkspace: () => void;\n  isWorkspaceAvailable: boolean;\n  workspaceFileCount?: number;\n};\n\nconst SectionLabel: Record<MentionOption[\"type\"], string> = {\n  attachment: \"Pending uploads\",\n  workspace: \"Workspace files\",\n};\n\nconst TypeBadge: Record<MentionOption[\"type\"], string> = {\n  attachment: \"Upload\",\n  workspace: \"Workspace\",\n};\n\nconst TypeIcon = {\n  attachment: PaperclipIcon,\n  workspace: FileTextIcon,\n};\n\nconst renderSection = ({\n  label,\n  options,\n  activeIndex,\n  activeItemRef,\n  onHover,\n  onSelect,\n}: {\n  label: string;\n  options: MentionOption[];\n  activeIndex: number;\n  activeItemRef: React.RefObject<HTMLButtonElement | null>;\n  onHover: (index: number) => void;\n  onSelect: (option: MentionOption) => void;\n}) => {\n  if (!options.length) {\n    return null;\n  }\n\n  return (\n    <div className=\"py-0.5 px-1\">\n      <div className=\"px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground\">\n        {label}\n      </div>\n      <div>\n        {options.map((option) => {\n          const Icon = TypeIcon[option.type];\n          const isActive = option.order === activeIndex;\n          const sizeLabel = formatFileSize(option.meta?.size);\n          return (\n            <button\n              key={option.id}\n              ref={isActive ? activeItemRef : undefined}\n              type=\"button\"\n              className={cn(\n                \"flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors\",\n                isActive\n                  ? \"bg-primary/10 text-foreground ring-1 ring-primary/30\"\n                  : \"hover:bg-muted\",\n              )}\n              onMouseDown={(event) => {\n                event.preventDefault();\n                onSelect(option);\n              }}\n              onMouseEnter={() => onHover(option.order)}\n            >\n              <Icon className=\"size-3.5 shrink-0 text-muted-foreground\" />\n              <span className=\"min-w-0 flex-1 truncate\">\n                <span className=\"font-medium\">{option.label}</span>\n                {option.description && option.description !== option.label ? (\n                  <span className=\"ml-2 text-xs text-muted-foreground\">\n                    {option.description}\n                  </span>\n                ) : null}\n              </span>\n              <div className=\"flex shrink-0 items-center gap-1.5 text-[10px] text-muted-foreground\">\n                {sizeLabel ? <span>{sizeLabel}</span> : null}\n                <span className=\"rounded border border-border/60 px-1 py-px font-medium uppercase\">\n                  {TypeBadge[option.type]}\n                </span>\n              </div>\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nexport const FileMentionMenu = ({\n  open,\n  query,\n  sections,\n  flatOptions,\n  activeIndex,\n  onSelect,\n  onHover,\n  workspaceStatus,\n  workspaceError,\n  onRetryWorkspace,\n  isWorkspaceAvailable,\n  workspaceFileCount = 0,\n}: FileMentionMenuProps) => {\n  const activeItemRef = useRef<HTMLButtonElement>(null);\n\n  // Scroll active item into view when activeIndex changes\n  // biome-ignore lint/correctness/useExhaustiveDependencies: keep activeIndex to scroll on selection\n  useEffect(() => {\n    if (open && activeItemRef.current) {\n      activeItemRef.current.scrollIntoView({\n        block: \"nearest\",\n        behavior: \"smooth\",\n      });\n    }\n  }, [open, activeIndex]);\n\n  if (!open) {\n    return null;\n  }\n\n  const hasSections =\n    sections.attachments.length > 0 || sections.workspace.length > 0;\n  const showStatus = workspaceStatus !== \"idle\" || !isWorkspaceAvailable;\n\n  return (\n    <div className=\"absolute left-0 right-0 bottom-[calc(100%+0.75rem)] z-30\">\n      <div className=\"rounded-xl border border-border/80 bg-popover/95 p-2 shadow-xl backdrop-blur supports-backdrop-filter:bg-popover/80\">\n        <div className=\"max-h-96 overflow-y-auto [-webkit-overflow-scrolling:touch]\">\n          {hasSections ? (\n            <>\n              {renderSection({\n                label: SectionLabel.attachment,\n                options: sections.attachments,\n                activeIndex,\n                activeItemRef,\n                onHover,\n                onSelect,\n              })}\n              {renderSection({\n                label: SectionLabel.workspace,\n                options: sections.workspace,\n                activeIndex,\n                activeItemRef,\n                onHover,\n                onSelect,\n              })}\n            </>\n          ) : (\n            <div className=\"px-3 py-2 text-sm text-muted-foreground\">\n              {query\n                ? `No files match “@${query}”.`\n                : \"No files available to mention yet.\"}\n            </div>\n          )}\n        </div>\n        {showStatus ? (\n          <div className=\"mt-2 rounded-md border border-border/80 bg-muted/40 px-3 py-2 text-xs text-muted-foreground\">\n            {workspaceStatus === \"loading\" ? (\n              <div className=\"flex items-center gap-2\">\n                <RefreshCwIcon className=\"size-3.5 animate-spin text-primary\" />\n                Indexing workspace files…\n              </div>\n            ) : workspaceStatus === \"error\" ? (\n              <div className=\"flex flex-wrap items-center gap-2\">\n                <span className=\"inline-flex items-center gap-1 text-destructive\">\n                  <AlertCircleIcon className=\"size-3.5\" />\n                  {workspaceError ?? \"Workspace files unavailable.\"}\n                </span>\n                <button\n                  type=\"button\"\n                  className=\"text-xs font-semibold text-primary underline underline-offset-2\"\n                  onClick={onRetryWorkspace}\n                >\n                  Retry\n                </button>\n              </div>\n            ) : isWorkspaceAvailable ? (\n              <div className=\"flex items-center justify-between text-xs\">\n                <span>\n                  {flatOptions.length\n                    ? `${flatOptions.length} file${\n                        flatOptions.length === 1 ? \"\" : \"s\"\n                      } ready to mention.`\n                    : \"Workspace files indexed.\"}\n                  {workspaceFileCount >= MAX_WORKSPACE_FILES\n                    ? \" Type a path to search deeper.\"\n                    : \"\"}\n                </span>\n              </div>\n            ) : (\n              <span>\n                Select an active session to enable workspace file mentions.\n              </span>\n            )}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "web/src/features/chat/global-config-controls.tsx",
    "content": "import { useCallback, useMemo, useState, type ReactElement } from \"react\";\nimport { toast } from \"sonner\";\nimport { Check, Cpu, Paperclip, RefreshCcw } from \"lucide-react\";\nimport { usePromptInputAttachments } from \"@ai-elements\";\nimport type { ConfigModel } from \"@/lib/api/models\";\nimport { ModelCapability } from \"@/lib/api/models\";\nimport { useGlobalConfig } from \"@/hooks/useGlobalConfig\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { Loader } from \"@/components/ai-elements/loader\";\nimport {\n  ModelSelector,\n  ModelSelectorContent,\n  ModelSelectorEmpty,\n  ModelSelectorGroup,\n  ModelSelectorInput,\n  ModelSelectorItem,\n  ModelSelectorList,\n  ModelSelectorName,\n  ModelSelectorTrigger,\n} from \"@/components/ai-elements/model-selector\";\nimport { cn } from \"@/lib/utils\";\n\ntype ThinkingState = \"enabled\" | \"disabled\" | \"forced\";\n\nfunction getThinkingState(model: ConfigModel | null): ThinkingState {\n  const capabilities = model?.capabilities;\n  if (!capabilities) {\n    return \"disabled\";\n  }\n  if (capabilities.has(ModelCapability.AlwaysThinking)) {\n    return \"forced\";\n  }\n  if (capabilities.has(ModelCapability.Thinking)) {\n    return \"enabled\";\n  }\n  return \"disabled\";\n}\n\nexport type GlobalConfigControlsProps = {\n  className?: string;\n  planMode?: boolean;\n  onPlanModeChange?: (enabled: boolean) => void;\n};\n\nexport function GlobalConfigControls({\n  className,\n  planMode = false,\n  onPlanModeChange,\n}: GlobalConfigControlsProps): ReactElement {\n  const { config, isLoading, isUpdating, error, refresh, update } =\n    useGlobalConfig();\n\n  const [isSelectorOpen, setIsSelectorOpen] = useState(false);\n  const [lastBusySkip, setLastBusySkip] = useState<string[] | null>(null);\n\n  const currentModel = useMemo(() => {\n    if (!config) {\n      return null;\n    }\n    return config.models.find((m) => m.name === config.defaultModel) ?? null;\n  }, [config]);\n\n  const thinkingState = useMemo(\n    () => getThinkingState(currentModel),\n    [currentModel],\n  );\n\n  const thinkingChecked = config?.defaultThinking ?? false;\n  const thinkingDisabled =\n    isLoading || isUpdating || thinkingState !== \"enabled\";\n\n  const handleSelectModel = useCallback(\n    async (modelKey: string) => {\n      setIsSelectorOpen(false);\n      if (!config || modelKey === config.defaultModel) {\n        return;\n      }\n\n      try {\n        const resp = await update({ defaultModel: modelKey });\n        const restarted = resp.restartedSessionIds ?? [];\n        const skippedBusy = resp.skippedBusySessionIds ?? [];\n\n        if (restarted.length > 0) {\n          toast.success(\"Global model updated\", {\n            description: `Restarted ${restarted.length} running session(s).`,\n          });\n        } else {\n          toast.success(\"Global model updated\");\n        }\n\n        if (skippedBusy.length > 0) {\n          setLastBusySkip(skippedBusy);\n          toast.message(\"Some sessions were skipped (busy)\", {\n            description: `Skipped ${skippedBusy.length} busy session(s). You can retry when they are idle, or force restart.`,\n          });\n        } else {\n          setLastBusySkip(null);\n        }\n      } catch (err) {\n        const message =\n          err instanceof Error ? err.message : \"Failed to update global model\";\n        toast.error(\"Failed to update global model\", { description: message });\n      }\n    },\n    [config, update],\n  );\n\n  const handleThinkingToggle = useCallback(\n    async (checked: boolean) => {\n      if (!config) {\n        return;\n      }\n      try {\n        const resp = await update({ defaultThinking: checked });\n        const skippedBusy = resp.skippedBusySessionIds ?? [];\n\n        if (skippedBusy.length > 0) {\n          setLastBusySkip(skippedBusy);\n          toast.message(\"Some sessions were skipped (busy)\", {\n            description: `Skipped ${skippedBusy.length} busy session(s). You can retry when they are idle, or force restart.`,\n          });\n        } else {\n          setLastBusySkip(null);\n        }\n      } catch (err) {\n        const message =\n          err instanceof Error\n            ? err.message\n            : \"Failed to update global thinking\";\n        toast.error(\"Failed to update global thinking\", {\n          description: message,\n        });\n      }\n    },\n    [config, update],\n  );\n\n  const handleForceRestartBusy = useCallback(async () => {\n    if (!lastBusySkip || lastBusySkip.length === 0) {\n      return;\n    }\n    try {\n      const resp = await update({ forceRestartBusySessions: true });\n      const restarted = resp.restartedSessionIds ?? [];\n      const skippedBusy = resp.skippedBusySessionIds ?? [];\n\n      if (skippedBusy.length === 0) {\n        setLastBusySkip(null);\n      } else {\n        setLastBusySkip(skippedBusy);\n      }\n\n      toast.success(\"Restarted running sessions\", {\n        description:\n          restarted.length > 0\n            ? `Restarted ${restarted.length} session(s).`\n            : \"No running sessions to restart.\",\n      });\n    } catch (err) {\n      const message =\n        err instanceof Error ? err.message : \"Failed to restart busy sessions\";\n      toast.error(\"Failed to restart busy sessions\", { description: message });\n    }\n  }, [lastBusySkip, update]);\n\n  const thinkingTooltip = useMemo(() => {\n    if (thinkingState === \"forced\") {\n      return \"Thinking is forced by the selected model.\";\n    }\n    if (thinkingState === \"disabled\") {\n      return \"Thinking is not supported by the selected model.\";\n    }\n    return null;\n  }, [thinkingState]);\n\n  const thinkingToggle = (\n    <div className=\"flex h-9 items-center gap-2 rounded-md px-2\">\n      <span className=\"text-xs text-muted-foreground\">Thinking</span>\n      <Switch\n        aria-label=\"Toggle global thinking\"\n        checked={\n          thinkingState === \"forced\"\n            ? true\n            : thinkingState === \"disabled\"\n              ? false\n              : thinkingChecked\n        }\n        disabled={thinkingDisabled}\n        onCheckedChange={handleThinkingToggle}\n      />\n    </div>\n  );\n\n  const attachments = usePromptInputAttachments();\n\n  return (\n    <div className={cn(\"flex items-center gap-1\", className)}>\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        className=\"size-9 border-0\"\n        aria-label=\"Attach files\"\n        onClick={() => attachments.openFileDialog()}\n      >\n        <Paperclip className=\"size-4\" />\n      </Button>\n\n      <div className=\"mx-0 h-4 w-px bg-border/70\" />\n\n      <ModelSelector open={isSelectorOpen} onOpenChange={setIsSelectorOpen}>\n        <ModelSelectorTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-9 max-w-[160px] justify-start gap-2 border-0\"\n            aria-label=\"Change global model\"\n            disabled={isLoading || isUpdating || !config}\n          >\n            <Cpu className=\"size-4 shrink-0\" />\n            <span className=\"truncate\">\n              {config ? config.defaultModel : \"Model\"}\n            </span>\n            {(isLoading || isUpdating) && (\n              <Loader className=\"ml-auto shrink-0\" size={14} />\n            )}\n          </Button>\n        </ModelSelectorTrigger>\n        <ModelSelectorContent title=\"Select global model\">\n          <ModelSelectorInput placeholder=\"Search models...\" />\n          <ModelSelectorList>\n            <ModelSelectorEmpty>No models found.</ModelSelectorEmpty>\n            <ModelSelectorGroup heading=\"Models\">\n              {(config?.models ?? []).map((m) => {\n                const isSelected = m.name === config?.defaultModel;\n                const label = `${m.name} (${m.provider})`;\n                return (\n                  <ModelSelectorItem\n                    key={m.name}\n                    value={`${m.name} ${m.model} ${m.provider}`}\n                    onSelect={(_value) => handleSelectModel(m.name)}\n                    className=\"flex items-center gap-2\"\n                  >\n                    {isSelected ? (\n                      <Check className=\"size-4 text-foreground\" />\n                    ) : (\n                      <span className=\"size-4\" />\n                    )}\n                    <ModelSelectorName title={label}>\n                      {m.name}\n                    </ModelSelectorName>\n                    <span className=\"shrink-0 text-xs text-muted-foreground\">\n                      {m.provider}\n                    </span>\n                  </ModelSelectorItem>\n                );\n              })}\n            </ModelSelectorGroup>\n          </ModelSelectorList>\n        </ModelSelectorContent>\n      </ModelSelector>\n\n      <div className=\"mx-0 h-4 w-px bg-border/70\" />\n      \n      {thinkingTooltip ? (\n        <Tooltip>\n          <TooltipTrigger asChild>{thinkingToggle}</TooltipTrigger>\n          <TooltipContent sideOffset={8}>{thinkingTooltip}</TooltipContent>\n        </Tooltip>\n      ) : (\n        thinkingToggle\n      )}\n\n      {onPlanModeChange && (\n        <>\n          <div className=\"mx-0 h-4 w-px bg-border/70\" />\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <div className=\"flex h-9 items-center gap-2 rounded-md px-2\">\n                <span className=\"text-xs text-muted-foreground\">\n                  Plan\n                </span>\n                <Switch\n                  aria-label=\"Toggle plan mode\"\n                  checked={planMode}\n                  onCheckedChange={onPlanModeChange}\n                />\n              </div>\n            </TooltipTrigger>\n            <TooltipContent sideOffset={8}>\n              {planMode\n                ? \"Plan mode is active. The model will only read and plan, not modify files.\"\n                : \"Enable plan mode for read-only research and planning.\"}\n            </TooltipContent>\n          </Tooltip>\n        </>\n      )}\n\n      {(lastBusySkip && lastBusySkip.length > 0) || error ? (\n        <div className=\"mx-1.5 h-4 w-px bg-border/70\" />\n      ) : null}\n\n      {lastBusySkip && lastBusySkip.length > 0 ? (\n        <Button\n          variant=\"outline\"\n          size=\"icon\"\n          className=\"size-9\"\n          aria-label=\"Force restart busy sessions\"\n          title=\"Force restart busy sessions\"\n          onClick={handleForceRestartBusy}\n          disabled={isUpdating}\n        >\n          <RefreshCcw className=\"size-4\" />\n        </Button>\n      ) : null}\n\n      {error ? (\n        <Button\n          variant=\"outline\"\n          size=\"icon\"\n          className=\"size-9\"\n          aria-label=\"Reload global config\"\n          title=\"Reload global config\"\n          onClick={() => {\n            refresh();\n          }}\n        >\n          <RefreshCcw className=\"size-4\" />\n        </Button>\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/message-search-dialog.tsx",
    "content": "import type { LiveMessage } from \"@/hooks/types\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  ArrowRightIcon,\n  SearchIcon,\n  UserIcon,\n  BotIcon,\n  WrenchIcon,\n} from \"lucide-react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { searchMessages, type SearchMatch } from \"./message-search-utils\";\n\ntype MessageSearchDialogProps = {\n  messages: LiveMessage[];\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onJumpToMessage: (messageIndex: number) => void;\n};\n\n/**\n * Highlight text with the matching query\n */\nfunction HighlightedText({\n  text,\n  query,\n  className,\n}: {\n  text: string;\n  query: string;\n  className?: string;\n}) {\n  if (!query.trim()) {\n    return <span className={className}>{text}</span>;\n  }\n\n  const lowerText = text.toLowerCase();\n  const lowerQuery = query.toLowerCase();\n  const parts: { text: string; isMatch: boolean }[] = [];\n  let lastIndex = 0;\n\n  let pos = lowerText.indexOf(lowerQuery);\n  while (pos !== -1) {\n    if (pos > lastIndex) {\n      parts.push({ text: text.slice(lastIndex, pos), isMatch: false });\n    }\n    parts.push({ text: text.slice(pos, pos + query.length), isMatch: true });\n    lastIndex = pos + query.length;\n    pos = lowerText.indexOf(lowerQuery, lastIndex);\n  }\n\n  if (lastIndex < text.length) {\n    parts.push({ text: text.slice(lastIndex), isMatch: false });\n  }\n\n  return (\n    <span className={className}>\n      {parts.map((part, i) =>\n        part.isMatch ? (\n          <mark\n            key={`match-${i}-${part.text}`}\n            className=\"rounded-sm bg-yellow-300 px-0.5 text-yellow-900 dark:bg-yellow-500/40 dark:text-yellow-100\"\n          >\n            {part.text}\n          </mark>\n        ) : (\n          <span key={`text-${i}-${part.text.slice(0, 20)}`}>{part.text}</span>\n        ),\n      )}\n    </span>\n  );\n}\n\nfunction getMessageIcon(message: LiveMessage) {\n  if (message.role === \"user\") {\n    return <UserIcon className=\"size-3.5\" />;\n  }\n  if (message.variant === \"tool\") {\n    return <WrenchIcon className=\"size-3.5\" />;\n  }\n  return <BotIcon className=\"size-3.5\" />;\n}\n\nfunction getMessageLabel(message: LiveMessage): string {\n  if (message.role === \"user\") {\n    return \"User\";\n  }\n  if (message.variant === \"tool\" && message.toolCall?.title) {\n    return message.toolCall.title;\n  }\n  if (message.variant === \"thinking\") {\n    return \"Thinking\";\n  }\n  return \"Assistant\";\n}\n\nexport function MessageSearchDialog({\n  messages,\n  open,\n  onOpenChange,\n  onJumpToMessage,\n}: MessageSearchDialogProps) {\n  const [query, setQuery] = useState(\"\");\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const resultsRef = useRef<HTMLDivElement>(null);\n\n  const matches = useMemo(\n    () => searchMessages(messages, query, 80),\n    [messages, query],\n  );\n\n  // Reset selection when matches change\n  // biome-ignore lint/correctness/useExhaustiveDependencies: We intentionally want to reset when length changes\n  useEffect(() => {\n    setSelectedIndex(0);\n  }, [matches.length]);\n\n  // Focus input when dialog opens\n  useEffect(() => {\n    if (open) {\n      setTimeout(() => inputRef.current?.focus(), 0);\n    } else {\n      setQuery(\"\");\n      setSelectedIndex(0);\n    }\n  }, [open]);\n\n  // Scroll selected item into view\n  useEffect(() => {\n    if (resultsRef.current && matches.length > 0) {\n      const selectedEl = resultsRef.current.querySelector(\n        `[data-index=\"${selectedIndex}\"]`,\n      );\n      selectedEl?.scrollIntoView({ block: \"nearest\" });\n    }\n  }, [selectedIndex, matches]);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        setSelectedIndex((i) => Math.min(i + 1, matches.length - 1));\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        setSelectedIndex((i) => Math.max(i - 1, 0));\n      } else if (e.key === \"Enter\" && matches.length > 0) {\n        e.preventDefault();\n        onJumpToMessage(matches[selectedIndex].messageIndex);\n        onOpenChange(false);\n      }\n    },\n    [matches, selectedIndex, onJumpToMessage, onOpenChange],\n  );\n\n  const selectedMatch = matches[selectedIndex];\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"flex h-[80dvh] max-w-[min(100vw-1.5rem,72rem)] flex-col gap-0 p-0 sm:h-[70vh] sm:max-w-6xl\">\n        <DialogHeader className=\"border-b px-4 py-3\">\n          <DialogTitle className=\"sr-only\">Search Messages</DialogTitle>\n          <div className=\"flex items-center gap-2\">\n            <SearchIcon className=\"size-4 text-muted-foreground\" />\n            <Input\n              ref={inputRef}\n              className=\"h-8 flex-1 border-none bg-transparent shadow-none focus-visible:ring-0\"\n              placeholder=\"Search in conversation...\"\n              value={query}\n              onChange={(e) => setQuery(e.target.value)}\n              onKeyDown={handleKeyDown}\n            />\n            <span className=\"text-xs text-muted-foreground\">\n              {query\n                ? matches.length > 0\n                  ? `${matches.length} result${matches.length !== 1 ? \"s\" : \"\"}`\n                  : \"No results\"\n                : \"\"}\n            </span>\n          </div>\n        </DialogHeader>\n\n        <div className=\"flex min-h-0 flex-1 flex-col sm:flex-row\">\n          {/* Results list */}\n          <div className=\"w-full border-b sm:w-1/3 sm:border-b-0 sm:border-r\">\n            <ScrollArea className=\"h-[35vh] sm:h-full\">\n              <div ref={resultsRef} className=\"p-2\">\n                {matches.length === 0 && query ? (\n                  <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n                    No messages found\n                  </p>\n                ) : (\n                  matches.map((match, index) => (\n                    <button\n                      type=\"button\"\n                      key={match.message.id || `${match.messageIndex}-${index}`}\n                      data-index={index}\n                      className={cn(\n                        \"w-full rounded-md px-2 py-2 text-left transition-colors\",\n                        index === selectedIndex\n                          ? \"bg-primary/10\"\n                          : \"hover:bg-muted/50\",\n                      )}\n                      onClick={() => setSelectedIndex(index)}\n                      onDoubleClick={() => {\n                        onJumpToMessage(match.messageIndex);\n                        onOpenChange(false);\n                      }}\n                    >\n                      <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground\">\n                        {getMessageIcon(match.message)}\n                        <span className=\"truncate\">\n                          {getMessageLabel(match.message)}\n                        </span>\n                      </div>\n                      <p className=\"mt-1 line-clamp-2 text-sm\">\n                        <HighlightedText text={match.snippet} query={query} />\n                      </p>\n                    </button>\n                  ))\n                )}\n              </div>\n            </ScrollArea>\n          </div>\n\n          {/* Preview panel */}\n          <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n            {selectedMatch ? (\n              <>\n                <div className=\"flex items-center justify-between border-b px-4 py-2\">\n                  <div className=\"flex items-center gap-2 text-sm\">\n                    {getMessageIcon(selectedMatch.message)}\n                    <span className=\"font-medium\">\n                      {getMessageLabel(selectedMatch.message)}\n                    </span>\n                  </div>\n                  <Button\n                    size=\"sm\"\n                    variant=\"ghost\"\n                    className=\"h-7 gap-1.5 text-xs\"\n                    onClick={() => {\n                      onJumpToMessage(selectedMatch.messageIndex);\n                      onOpenChange(false);\n                    }}\n                  >\n                    Jump to message\n                    <ArrowRightIcon className=\"size-3\" />\n                  </Button>\n                </div>\n                <ScrollArea className=\"flex-1 overflow-x-hidden\">\n                  <div className=\"p-4\">\n                    <PreviewContent match={selectedMatch} query={query} />\n                  </div>\n                </ScrollArea>\n              </>\n            ) : (\n              <div className=\"flex flex-1 items-center justify-center text-sm text-muted-foreground\">\n                {query ? \"Select a result to preview\" : \"Type to search\"}\n              </div>\n            )}\n          </div>\n        </div>\n\n        {/* Keyboard hints */}\n        <div className=\"flex items-center gap-4 border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground\">\n          <span>\n            <kbd className=\"rounded bg-muted px-1.5 py-0.5 font-mono\">\n              &uarr;&darr;\n            </kbd>{\" \"}\n            Navigate\n          </span>\n          <span>\n            <kbd className=\"rounded bg-muted px-1.5 py-0.5 font-mono\">\n              Enter\n            </kbd>{\" \"}\n            Jump to message\n          </span>\n          <span>\n            <kbd className=\"rounded bg-muted px-1.5 py-0.5 font-mono\">Esc</kbd>{\" \"}\n            Close\n          </span>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction PreviewContent({\n  match,\n  query,\n}: {\n  match: SearchMatch;\n  query: string;\n}) {\n  const { message } = match;\n\n  const sections: { label: string; content: string }[] = [];\n\n  if (message.content) {\n    sections.push({ label: \"Content\", content: message.content });\n  }\n  if (message.thinking) {\n    sections.push({ label: \"Thinking\", content: message.thinking });\n  }\n  if (message.toolCall) {\n    if (message.toolCall.title) {\n      sections.push({ label: \"Tool\", content: message.toolCall.title });\n    }\n    if (message.toolCall.input) {\n      const inputStr =\n        typeof message.toolCall.input === \"string\"\n          ? message.toolCall.input\n          : JSON.stringify(message.toolCall.input, null, 2);\n      sections.push({ label: \"Input\", content: inputStr });\n    }\n    if (message.toolCall.output) {\n      sections.push({ label: \"Output\", content: message.toolCall.output });\n    }\n    if (message.toolCall.message) {\n      sections.push({ label: \"Message\", content: message.toolCall.message });\n    }\n  }\n  if (message.codeSnippet?.code) {\n    sections.push({ label: \"Code\", content: message.codeSnippet.code });\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {sections.map((section) => (\n        <div key={section.label}>\n          <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n            {section.label}\n          </h4>\n          <div className=\"whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 font-mono text-sm\">\n            <HighlightedText text={section.content} query={query} />\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "web/src/features/chat/message-search-utils.ts",
    "content": "import type { LiveMessage } from \"@/hooks/types\";\n\n/**\n * Extract searchable text content from a LiveMessage\n */\nexport function getSearchableText(message: LiveMessage): string {\n  const parts: string[] = [];\n\n  if (message.content) {\n    parts.push(message.content);\n  }\n  if (message.thinking) {\n    parts.push(message.thinking);\n  }\n  if (message.toolCall) {\n    if (message.toolCall.title) {\n      parts.push(message.toolCall.title);\n    }\n    if (typeof message.toolCall.input === \"string\") {\n      parts.push(message.toolCall.input);\n    } else if (message.toolCall.input) {\n      parts.push(JSON.stringify(message.toolCall.input));\n    }\n    if (message.toolCall.output) {\n      parts.push(message.toolCall.output);\n    }\n    if (message.toolCall.message) {\n      parts.push(message.toolCall.message);\n    }\n  }\n  if (message.codeSnippet?.code) {\n    parts.push(message.codeSnippet.code);\n  }\n\n  return parts.join(\" \");\n}\n\n/**\n * Search result with message index and match context\n */\nexport type SearchMatch = {\n  /** Index of the message in the messages array */\n  messageIndex: number;\n  /** The message that matched */\n  message: LiveMessage;\n  /** Text snippet around the match for preview */\n  snippet: string;\n  /** Start position of the match in the snippet */\n  matchStart: number;\n  /** Length of the matched text */\n  matchLength: number;\n};\n\n/**\n * Search messages for a query and return matches with context\n */\nexport function searchMessages(\n  messages: LiveMessage[],\n  query: string,\n  contextChars = 50,\n): SearchMatch[] {\n  if (!query.trim()) {\n    return [];\n  }\n\n  const lowerQuery = query.toLowerCase();\n  const matches: SearchMatch[] = [];\n\n  messages.forEach((message, index) => {\n    const text = getSearchableText(message);\n    const lowerText = text.toLowerCase();\n    const matchPos = lowerText.indexOf(lowerQuery);\n\n    if (matchPos !== -1) {\n      // Extract snippet with context\n      const start = Math.max(0, matchPos - contextChars);\n      const end = Math.min(text.length, matchPos + query.length + contextChars);\n      let snippet = text.slice(start, end);\n\n      // Add ellipsis if truncated\n      if (start > 0) snippet = \"...\" + snippet;\n      if (end < text.length) snippet = snippet + \"...\";\n\n      matches.push({\n        messageIndex: index,\n        message,\n        snippet,\n        matchStart: start > 0 ? matchPos - start + 3 : matchPos, // +3 for \"...\"\n        matchLength: query.length,\n      });\n    }\n  });\n\n  return matches;\n}\n"
  },
  {
    "path": "web/src/features/chat/queue-store.ts",
    "content": "import { create } from \"zustand\";\n\nexport interface QueuedItem {\n  id: string;\n  text: string;\n}\n\ntype QueueStore = {\n  queue: QueuedItem[];\n  enqueue: (text: string) => void;\n  removeFromQueue: (id: string) => void;\n  editQueueItem: (id: string, text: string) => void;\n  moveQueueItemUp: (id: string) => void;\n  dequeue: () => QueuedItem | undefined;\n  clearQueue: () => void;\n};\n\nexport const useQueueStore = create<QueueStore>((set, get) => ({\n  queue: [],\n  enqueue: (text) =>\n    set((s) => ({\n      queue: [...s.queue, { id: crypto.randomUUID(), text }],\n    })),\n  removeFromQueue: (id) =>\n    set((s) => ({ queue: s.queue.filter((q) => q.id !== id) })),\n  editQueueItem: (id, text) =>\n    set((s) => ({\n      queue: s.queue.map((q) => (q.id === id ? { ...q, text } : q)),\n    })),\n  moveQueueItemUp: (id) =>\n    set((s) => {\n      const idx = s.queue.findIndex((q) => q.id === id);\n      if (idx <= 0) return s;\n      const next = [...s.queue];\n      [next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];\n      return { queue: next };\n    }),\n  dequeue: () => {\n    const { queue } = get();\n    if (queue.length === 0) return undefined;\n    const [first, ...rest] = queue;\n    set({ queue: rest });\n    return first;\n  },\n  clearQueue: () => set({ queue: [] }),\n}));\n"
  },
  {
    "path": "web/src/features/chat/slash-command-menu.tsx",
    "content": "import { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { TerminalSquareIcon } from \"lucide-react\";\nimport type { SlashCommandOption } from \"./useSlashCommands\";\n\ntype SlashCommandMenuProps = {\n  open: boolean;\n  query: string;\n  options: SlashCommandOption[];\n  activeIndex: number;\n  onSelect: (option: SlashCommandOption) => void;\n  onHover: (index: number) => void;\n};\n\nexport const SlashCommandMenu = ({\n  open,\n  query,\n  options,\n  activeIndex,\n  onSelect,\n  onHover,\n}: SlashCommandMenuProps) => {\n  const activeItemRef = useRef<HTMLButtonElement | null>(null);\n\n  // Scroll active item into view when activeIndex changes\n  // biome-ignore lint/correctness/useExhaustiveDependencies: keep activeIndex to scroll on selection\n  useEffect(() => {\n    if (open && activeItemRef.current) {\n      activeItemRef.current.scrollIntoView({\n        block: \"nearest\",\n        behavior: \"smooth\",\n      });\n    }\n  }, [open, activeIndex]);\n\n  if (!open) {\n    return null;\n  }\n\n  return (\n    <div className=\"absolute left-0 right-0 bottom-[calc(100%+0.75rem)] z-30\">\n      <div className=\"rounded-xl border border-border/80 bg-popover/95 p-2 px-1 shadow-xl backdrop-blur supports-backdrop-filter:bg-popover/80\">\n        {options.length > 0 ? (\n          <>\n            <div className=\"px-3 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground\">\n              Slash Commands\n            </div>\n            <div className=\"max-h-80 overflow-y-auto px-1 [-webkit-overflow-scrolling:touch]\">\n              {options.map((option, index) => {\n                const isActive = index === activeIndex;\n                return (\n                  <button\n                    key={option.id}\n                    ref={isActive ? activeItemRef : null}\n                    type=\"button\"\n                    className={cn(\n                      \"flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors my-0.5\",\n                      isActive\n                        ? \"bg-primary/10 text-foreground ring-1 ring-primary/30\"\n                        : \"hover:bg-muted\",\n                    )}\n                    onMouseDown={(event) => {\n                      event.preventDefault();\n                      onSelect(option);\n                    }}\n                    onMouseEnter={() => onHover(index)}\n                  >\n                    <TerminalSquareIcon className=\"size-3.5 shrink-0 text-muted-foreground\" />\n                    <span className=\"truncate\">\n                      <span className=\"font-medium text-primary\">/{option.name}</span>\n                      {option.aliases.length > 0 && (\n                        <span className=\"ml-1.5 text-xs text-muted-foreground/70\">\n                          ({option.aliases.join(\", \")})\n                        </span>\n                      )}\n                      <span className=\"ml-2 text-xs text-muted-foreground\">\n                        {option.description}\n                      </span>\n                    </span>\n                  </button>\n                );\n              })}\n            </div>\n          </>\n        ) : (\n          <div className=\"px-3 py-2 text-sm text-muted-foreground\">\n            {query\n              ? `No commands match \"/${query}\".`\n              : \"No commands available.\"}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "web/src/features/chat/useFileMentions.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent } from \"react\";\nimport type { FileUIPart } from \"ai\";\nimport type { SessionFileEntry } from \"@/hooks/useSessions\";\n\nconst STOP_CHARS = /[\\s,;:!?,()[\\]{}<>\"'`]/;\nconst MENTION_TRIGGER_PREFIX = /\\s|[([{]/;\nconst LEADING_DOT_SLASH = /^\\.\\//;\nconst NON_WHITESPACE_START = /^\\S/;\nconst MAX_WORKSPACE_FILES = 500;\nconst MAX_DIRECTORY_SCANS = 200;\nconst WORKSPACE_STALE_MS = 30_000;\nconst DIRECTORY_QUERY_DEBOUNCE_MS = 300;\n\ntype MentionRange = {\n  start: number;\n  end: number;\n  query: string;\n};\n\ntype WorkspaceFile = {\n  path: string;\n  size?: number;\n};\n\nexport type MentionOptionBase = {\n  id: string;\n  type: \"attachment\" | \"workspace\";\n  label: string;\n  description?: string;\n  insertValue: string;\n  meta?: {\n    path?: string;\n    size?: number;\n    mediaType?: string;\n  };\n};\n\nexport type MentionOption = MentionOptionBase & {\n  order: number;\n};\n\nexport type MentionSections = {\n  attachments: MentionOption[];\n  workspace: MentionOption[];\n};\n\ntype UseFileMentionsArgs = {\n  text: string;\n  setText: (value: string) => void;\n  textareaRef: React.RefObject<HTMLTextAreaElement | null>;\n  attachments: (FileUIPart & { id: string })[];\n  sessionId?: string;\n  listDirectory?: (\n    sessionId: string,\n    path?: string,\n  ) => Promise<SessionFileEntry[]>;\n};\n\ntype UseFileMentionsReturn = {\n  isOpen: boolean;\n  query: string;\n  flatOptions: MentionOption[];\n  sections: MentionSections;\n  activeIndex: number;\n  setActiveIndex: (value: number) => void;\n  handleTextChange: (value: string, caret: number | null) => void;\n  handleCaretChange: (caret: number | null) => void;\n  handleKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n  selectOption: (option?: MentionOption) => void;\n  closeMenu: () => void;\n  workspaceStatus: \"idle\" | \"loading\" | \"ready\" | \"error\";\n  workspaceError: string | null;\n  retryWorkspace: () => void;\n  workspaceFileCount: number;\n};\n\nconst detectMention = (\n  text: string,\n  caret: number | null,\n): MentionRange | null => {\n  const safeCaret = Math.max(0, Math.min(text.length, caret ?? text.length));\n  const upToCaret = text.slice(0, safeCaret);\n  const triggerIndex = upToCaret.lastIndexOf(\"@\");\n  if (triggerIndex === -1) {\n    return null;\n  }\n\n  if (triggerIndex > 0) {\n    const previousChar = upToCaret[triggerIndex - 1];\n    if (previousChar && !MENTION_TRIGGER_PREFIX.test(previousChar)) {\n      return null;\n    }\n  }\n\n  const query = upToCaret.slice(triggerIndex + 1);\n  if (STOP_CHARS.test(query)) {\n    return null;\n  }\n\n  return {\n    start: triggerIndex,\n    end: safeCaret,\n    query,\n  };\n};\n\nconst isSameRange = (a: MentionRange | null, b: MentionRange | null): boolean =>\n  a?.start === b?.start && a?.end === b?.end && a?.query === b?.query;\n\nconst normalizePath = (value: string) => {\n  if (value === \".\" || value === \"./\" || value === \"\") {\n    return \".\";\n  }\n  return value.replace(LEADING_DOT_SLASH, \"\");\n};\n\nconst crawlWorkspace = async ({\n  sessionId,\n  listDirectory,\n}: {\n  sessionId: string;\n  listDirectory: UseFileMentionsArgs[\"listDirectory\"];\n}): Promise<WorkspaceFile[]> => {\n  if (!listDirectory) {\n    return [];\n  }\n\n  const files: WorkspaceFile[] = [];\n  const queue: string[] = [\".\"];\n  const visited = new Set<string>();\n\n  while (\n    queue.length > 0 &&\n    files.length < MAX_WORKSPACE_FILES &&\n    visited.size < MAX_DIRECTORY_SCANS\n  ) {\n    const current = queue.shift() as string;\n    if (visited.has(current)) {\n      continue;\n    }\n    visited.add(current);\n\n    // \".\" should be treated as undefined for API root\n    const path = current === \".\" ? undefined : current;\n    const entries = await listDirectory(sessionId, path);\n\n    for (const entry of entries) {\n      const fullPath =\n        current === \".\"\n          ? entry.name\n          : `${normalizePath(current)}/${entry.name}`;\n      if (entry.type === \"directory\") {\n        queue.push(fullPath);\n        continue;\n      }\n      files.push({\n        path: fullPath,\n        size: entry.size,\n      });\n      if (files.length >= MAX_WORKSPACE_FILES) {\n        break;\n      }\n    }\n  }\n\n  return files;\n};\n\nconst toAttachmentOptions = (\n  attachments: (FileUIPart & { id: string })[],\n): MentionOptionBase[] =>\n  attachments.map((attachment, index) => {\n    const label =\n      attachment.filename && attachment.filename.trim().length > 0\n        ? attachment.filename\n        : `Attachment ${index + 1}`;\n    return {\n      id: `upload-${attachment.id}`,\n      type: \"attachment\" as const,\n      label,\n      description: attachment.mediaType ?? \"Pending upload\",\n      insertValue: label,\n      meta: {\n        mediaType: attachment.mediaType,\n      },\n    };\n  });\n\nconst toWorkspaceOptions = (files: WorkspaceFile[]): MentionOptionBase[] =>\n  files.map((file) => {\n    const segments = file.path.split(\"/\");\n    const label = segments.at(-1) ?? file.path;\n    return {\n      id: `workspace-${file.path}`,\n      type: \"workspace\" as const,\n      label,\n      description: file.path,\n      insertValue: file.path,\n      meta: {\n        path: file.path,\n        size: file.size,\n      },\n    };\n  });\n\nconst filterOptions = (\n  options: MentionOptionBase[],\n  query: string,\n  offset: number,\n): MentionOption[] => {\n  if (!options.length) {\n    return [];\n  }\n  const normalizedQuery = query.trim().toLowerCase();\n  const matchesQuery = (value?: string) =>\n    normalizedQuery.length === 0\n      ? true\n      : value?.toLowerCase().includes(normalizedQuery);\n\n  return options\n    .filter(\n      (option) =>\n        matchesQuery(option.insertValue) ||\n        matchesQuery(option.label) ||\n        matchesQuery(option.description),\n    )\n    .map((option, index) => ({\n      ...option,\n      order: offset + index,\n    }));\n};\n\nexport const useFileMentions = ({\n  text,\n  setText,\n  textareaRef,\n  attachments,\n  sessionId,\n  listDirectory,\n}: UseFileMentionsArgs): UseFileMentionsReturn => {\n  const [range, setRange] = useState<MentionRange | null>(null);\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [workspaceFiles, setWorkspaceFiles] = useState<WorkspaceFile[]>([]);\n  const [workspaceStatus, setWorkspaceStatus] = useState<\n    \"idle\" | \"loading\" | \"ready\" | \"error\"\n  >(\"idle\");\n  const [workspaceError, setWorkspaceError] = useState<string | null>(null);\n  const workspaceRequestRef = useRef(0);\n  const directoryRequestRef = useRef(0);\n  const lastLoadedRef = useRef(0);\n  const directoryQueryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(\n    null,\n  );\n  const [directoryFiles, setDirectoryFiles] = useState<WorkspaceFile[]>([]);\n  const sessionIdRef = useRef(sessionId);\n\n  const attachmentOptions = useMemo(\n    () => toAttachmentOptions(attachments),\n    [attachments],\n  );\n  const mergedWorkspaceFiles = useMemo(() => {\n    if (directoryFiles.length === 0) {\n      return workspaceFiles;\n    }\n    const existing = new Set(workspaceFiles.map((f) => f.path));\n    const extra = directoryFiles.filter((f) => !existing.has(f.path));\n    return extra.length === 0 ? workspaceFiles : [...workspaceFiles, ...extra];\n  }, [workspaceFiles, directoryFiles]);\n\n  const workspaceOptions = useMemo(\n    () => toWorkspaceOptions(mergedWorkspaceFiles),\n    [mergedWorkspaceFiles],\n  );\n\n  const sections = useMemo(() => {\n    const attachmentsSection = filterOptions(\n      attachmentOptions,\n      range?.query ?? \"\",\n      0,\n    );\n    const workspaceSection = filterOptions(\n      workspaceOptions,\n      range?.query ?? \"\",\n      attachmentsSection.length,\n    );\n    return {\n      attachments: attachmentsSection,\n      workspace: workspaceSection,\n    };\n  }, [attachmentOptions, workspaceOptions, range?.query]);\n\n  const flatOptions = useMemo(\n    () => [...sections.attachments, ...sections.workspace],\n    [sections.attachments, sections.workspace],\n  );\n\n  useEffect(() => {\n    if (activeIndex < flatOptions.length) {\n      return;\n    }\n    setActiveIndex(flatOptions.length === 0 ? 0 : flatOptions.length - 1);\n  }, [activeIndex, flatOptions.length]);\n\n  useEffect(() => {\n    setActiveIndex(0);\n  }, []);\n\n  useEffect(() => {\n    setWorkspaceFiles([]);\n    setDirectoryFiles([]);\n    setWorkspaceStatus(\"idle\");\n    setWorkspaceError(null);\n    workspaceRequestRef.current += 1;\n    directoryRequestRef.current += 1;\n    lastLoadedRef.current = 0;\n    sessionIdRef.current = sessionId;\n  }, [sessionId]);\n\n  const loadWorkspaceFiles = useCallback(async () => {\n    if (!(sessionId && listDirectory)) {\n      return;\n    }\n    setWorkspaceStatus(\"loading\");\n    setWorkspaceError(null);\n    workspaceRequestRef.current += 1;\n    const requestId = workspaceRequestRef.current;\n    try {\n      const files = await crawlWorkspace({ sessionId, listDirectory });\n      if (workspaceRequestRef.current !== requestId) {\n        return;\n      }\n      setWorkspaceFiles(files);\n      setWorkspaceStatus(\"ready\");\n      lastLoadedRef.current = Date.now();\n    } catch (error) {\n      if (workspaceRequestRef.current !== requestId) {\n        return;\n      }\n      setWorkspaceStatus(\"error\");\n      setWorkspaceError(\n        error instanceof Error\n          ? error.message\n          : \"Failed to load workspace files\",\n      );\n    }\n  }, [sessionId, listDirectory]);\n\n  useEffect(() => {\n    if (!range) {\n      return;\n    }\n    if (!(sessionId && listDirectory)) {\n      return;\n    }\n    if (workspaceStatus === \"loading\") {\n      return;\n    }\n    const isStale =\n      workspaceStatus === \"ready\" &&\n      Date.now() - lastLoadedRef.current > WORKSPACE_STALE_MS;\n    if (workspaceStatus !== \"idle\" && !isStale) {\n      return;\n    }\n    loadWorkspaceFiles();\n  }, [range, sessionId, listDirectory, workspaceStatus, loadWorkspaceFiles]);\n\n  // Debounced directory query when query contains \"/\"\n  useEffect(() => {\n    if (directoryQueryTimerRef.current) {\n      clearTimeout(directoryQueryTimerRef.current);\n      directoryQueryTimerRef.current = null;\n    }\n    const query = range?.query ?? \"\";\n    const slashIndex = query.lastIndexOf(\"/\");\n    if (slashIndex < 0 || !(sessionId && listDirectory)) {\n      directoryRequestRef.current += 1;\n      setDirectoryFiles([]);\n      return;\n    }\n    const dirPrefix = query.slice(0, slashIndex);\n    const dirPath = dirPrefix === \"\" ? undefined : dirPrefix;\n    const capturedSessionId = sessionId;\n    directoryRequestRef.current += 1;\n    const requestId = directoryRequestRef.current;\n    directoryQueryTimerRef.current = setTimeout(async () => {\n      if (directoryRequestRef.current !== requestId) return;\n      if (sessionIdRef.current !== capturedSessionId) return;\n      try {\n        const entries = await listDirectory(capturedSessionId, dirPath);\n        if (directoryRequestRef.current !== requestId) return;\n        if (sessionIdRef.current !== capturedSessionId) return;\n        const files: WorkspaceFile[] = [];\n        for (const entry of entries) {\n          if (entry.type === \"directory\") {\n            continue;\n          }\n          const fullPath = dirPath\n            ? `${dirPath}/${entry.name}`\n            : entry.name;\n          files.push({ path: fullPath, size: entry.size });\n        }\n        setDirectoryFiles(files);\n      } catch {\n        if (directoryRequestRef.current !== requestId) return;\n        // Silently ignore — the main index is still available\n      }\n    }, DIRECTORY_QUERY_DEBOUNCE_MS);\n    return () => {\n      if (directoryQueryTimerRef.current) {\n        clearTimeout(directoryQueryTimerRef.current);\n        directoryQueryTimerRef.current = null;\n      }\n    };\n  }, [range?.query, sessionId, listDirectory]);\n\n  useEffect(() => {\n    const caret = textareaRef.current?.selectionStart ?? text.length;\n    const next = detectMention(text, caret);\n    setRange((previous) => (isSameRange(previous, next) ? previous : next));\n  }, [text, textareaRef]);\n\n  const handleTextChange = useCallback(\n    (value: string, caret: number | null) => {\n      setRange(detectMention(value, caret));\n    },\n    [],\n  );\n\n  const handleCaretChange = useCallback(\n    (caret: number | null) => {\n      setRange(detectMention(text, caret));\n    },\n    [text],\n  );\n\n  const closeMenu = useCallback(() => {\n    setRange(null);\n  }, []);\n\n  const selectOption = useCallback(\n    (option?: MentionOption) => {\n      if (!range) {\n        return;\n      }\n      const target = option ?? flatOptions[activeIndex];\n      if (!target) {\n        return;\n      }\n\n      const mentionText = `@${target.insertValue}`;\n      const before = text.slice(0, range.start);\n      const after = text.slice(range.end);\n      const needsSpace =\n        after.length === 0 || NON_WHITESPACE_START.test(after) ? \" \" : \"\";\n      const nextValue = `${before}${mentionText}${needsSpace}${after}`;\n      const nextCaret = before.length + mentionText.length + needsSpace.length;\n\n      setText(nextValue);\n      setRange(null);\n      setActiveIndex(0);\n\n      requestAnimationFrame(() => {\n        const node = textareaRef.current;\n        if (!node) {\n          return;\n        }\n        node.focus();\n        node.setSelectionRange(nextCaret, nextCaret);\n      });\n    },\n    [range, flatOptions, activeIndex, text, setText, textareaRef],\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (!range) {\n        return;\n      }\n\n      if (event.key === \"ArrowDown\") {\n        if (flatOptions.length === 0) {\n          return;\n        }\n        event.preventDefault();\n        setActiveIndex((previous) => (previous + 1) % flatOptions.length);\n        return;\n      }\n\n      if (event.key === \"ArrowUp\") {\n        if (flatOptions.length === 0) {\n          return;\n        }\n        event.preventDefault();\n        setActiveIndex((previous) =>\n          previous - 1 < 0\n            ? flatOptions.length - 1\n            : (previous - 1) % flatOptions.length,\n        );\n        return;\n      }\n\n      if (event.key === \"Enter\" || event.key === \"Tab\") {\n        if (flatOptions.length === 0) {\n          return;\n        }\n        event.preventDefault();\n        selectOption();\n        return;\n      }\n\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        closeMenu();\n      }\n    },\n    [range, flatOptions, selectOption, closeMenu],\n  );\n\n  const retryWorkspace = useCallback(() => {\n    loadWorkspaceFiles();\n  }, [loadWorkspaceFiles]);\n\n  return {\n    isOpen: Boolean(range),\n    query: range?.query ?? \"\",\n    flatOptions,\n    sections,\n    activeIndex,\n    setActiveIndex,\n    handleTextChange,\n    handleCaretChange,\n    handleKeyDown,\n    selectOption,\n    closeMenu,\n    workspaceStatus,\n    workspaceError,\n    retryWorkspace,\n    workspaceFileCount: workspaceFiles.length,\n  };\n};\n"
  },
  {
    "path": "web/src/features/chat/useSlashCommands.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent } from \"react\";\nimport type { SlashCommandDef } from \"@/hooks/useSessionStream\";\n\nexport type { SlashCommandDef };\n\nexport type SlashCommandOption = {\n  id: string;\n  name: string;\n  description: string;\n  aliases: string[];\n  insertValue: string;\n};\n\ntype SlashRange = {\n  start: number;\n  end: number;\n  query: string;\n};\n\nconst WHITESPACE_REGEX = /\\s/;\n\ntype UseSlashCommandsArgs = {\n  text: string;\n  setText: (value: string) => void;\n  textareaRef: React.RefObject<HTMLTextAreaElement | null>;\n  commands: SlashCommandDef[];\n};\n\ntype UseSlashCommandsReturn = {\n  isOpen: boolean;\n  query: string;\n  options: SlashCommandOption[];\n  activeIndex: number;\n  setActiveIndex: (value: number) => void;\n  handleTextChange: (value: string, caret: number | null) => void;\n  handleCaretChange: (caret: number | null) => void;\n  handleKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n  selectOption: (option?: SlashCommandOption) => void;\n  closeMenu: () => void;\n};\n\nconst NON_WHITESPACE_START = /^\\S/;\n\n/**\n * Detect if the cursor is inside a slash command at the beginning of input or line.\n * Slash commands are only valid at the start of input or after a newline.\n */\nconst detectSlash = (text: string, caret: number | null): SlashRange | null => {\n  const safeCaret = Math.max(0, Math.min(text.length, caret ?? text.length));\n  const upToCaret = text.slice(0, safeCaret);\n\n  // Find the last slash in the text up to caret\n  const slashIndex = upToCaret.lastIndexOf(\"/\");\n  if (slashIndex === -1) {\n    return null;\n  }\n\n  // Slash must be at the beginning of input or at the start of a line\n  if (slashIndex > 0) {\n    const prevChar = upToCaret[slashIndex - 1];\n    // Only allow slash after newline\n    if (prevChar !== \"\\n\") {\n      return null;\n    }\n  }\n\n  // Get the query after the slash\n  const query = upToCaret.slice(slashIndex + 1);\n\n  // Query should not contain spaces (slash commands are single words)\n  if (WHITESPACE_REGEX.test(query)) {\n    return null;\n  }\n\n  return {\n    start: slashIndex,\n    end: safeCaret,\n    query,\n  };\n};\n\nconst isSameRange = (a: SlashRange | null, b: SlashRange | null): boolean =>\n  a?.start === b?.start && a?.end === b?.end && a?.query === b?.query;\n\nconst toSlashOptions = (commands: SlashCommandDef[]): SlashCommandOption[] =>\n  commands.map((cmd) => ({\n    id: `slash-${cmd.name}`,\n    name: cmd.name,\n    description: cmd.description,\n    aliases: cmd.aliases,\n    insertValue: `/${cmd.name}`,\n  }));\n\nconst filterOptions = (\n  options: SlashCommandOption[],\n  query: string,\n): SlashCommandOption[] => {\n  if (!options.length) {\n    return [];\n  }\n\n  const normalizedQuery = query.trim().toLowerCase();\n  if (normalizedQuery.length === 0) {\n    return options;\n  }\n\n  return options.filter((option) => {\n    const matchesName = option.name.toLowerCase().includes(normalizedQuery);\n    const matchesAlias = option.aliases.some((alias) =>\n      alias.toLowerCase().includes(normalizedQuery),\n    );\n    return matchesName || matchesAlias;\n  });\n};\n\nexport const useSlashCommands = ({\n  text,\n  setText,\n  textareaRef,\n  commands,\n}: UseSlashCommandsArgs): UseSlashCommandsReturn => {\n  const [range, setRange] = useState<SlashRange | null>(null);\n  const [activeIndex, setActiveIndex] = useState(0);\n  // Track when we're in the middle of selecting an option to avoid race conditions\n  const isSelectingRef = useRef(false);\n\n  const allOptions = useMemo(() => toSlashOptions(commands), [commands]);\n\n  const options = useMemo(\n    () => filterOptions(allOptions, range?.query ?? \"\"),\n    [allOptions, range?.query],\n  );\n\n  // Reset active index when options change\n  useEffect(() => {\n    if (activeIndex >= options.length) {\n      setActiveIndex(options.length === 0 ? 0 : options.length - 1);\n    }\n  }, [activeIndex, options.length]);\n\n  // Reset active index when menu opens (only on range start change)\n  const rangeStart = range?.start;\n  useEffect(() => {\n    if (rangeStart !== undefined) {\n      setActiveIndex(0);\n    }\n  }, [rangeStart]);\n\n  // Detect slash on initial render\n  useEffect(() => {\n    // Skip detection while selecting an option to avoid race condition\n    // where the menu reopens due to stale caret position\n    if (isSelectingRef.current) {\n      return;\n    }\n    const caret = textareaRef.current?.selectionStart ?? text.length;\n    const next = detectSlash(text, caret);\n    setRange((previous) => (isSameRange(previous, next) ? previous : next));\n  }, [text, textareaRef]);\n\n  const handleTextChange = useCallback(\n    (value: string, caret: number | null) => {\n      // Skip detection while selecting an option to avoid race condition\n      if (isSelectingRef.current) {\n        return;\n      }\n      setRange(detectSlash(value, caret));\n    },\n    [],\n  );\n\n  const handleCaretChange = useCallback(\n    (caret: number | null) => {\n      // Skip detection while selecting an option to avoid race condition\n      if (isSelectingRef.current) {\n        return;\n      }\n      setRange(detectSlash(text, caret));\n    },\n    [text],\n  );\n\n  const closeMenu = useCallback(() => {\n    setRange(null);\n  }, []);\n\n  const selectOption = useCallback(\n    (option?: SlashCommandOption) => {\n      if (!range) {\n        return;\n      }\n      const target = option ?? options[activeIndex];\n      if (!target) {\n        return;\n      }\n\n      const before = text.slice(0, range.start);\n      const after = text.slice(range.end);\n      // Add trailing space after command\n      const needsSpace =\n        after.length === 0 || NON_WHITESPACE_START.test(after) ? \" \" : \"\";\n      const nextValue = `${before}${target.insertValue}${needsSpace}${after}`;\n      const nextCaret =\n        before.length + target.insertValue.length + needsSpace.length;\n\n      // Mark as selecting to prevent useEffect from reopening the menu\n      // due to stale caret position before requestAnimationFrame runs\n      isSelectingRef.current = true;\n\n      const node = textareaRef.current;\n      if (node) {\n        // Blur first to force IME composition to end\n        // This triggers onBlur which resets isComposing state in prompt-input\n        node.blur();\n      }\n\n      // Clear range and reset activeIndex immediately\n      setRange(null);\n      setActiveIndex(0);\n\n      // Use requestAnimationFrame to ensure blur event processing completes\n      // before updating the text\n      requestAnimationFrame(() => {\n        setText(nextValue);\n\n        requestAnimationFrame(() => {\n          try {\n            const currentNode = textareaRef.current;\n            if (currentNode) {\n              currentNode.focus();\n              currentNode.setSelectionRange(nextCaret, nextCaret);\n            }\n          } finally {\n            // Delay resetting the flag to ensure all React renders and effects\n            // triggered by blur/focus have completed.\n            // Using finally ensures the flag is always reset even if an error occurs.\n            setTimeout(() => {\n              isSelectingRef.current = false;\n            }, 0);\n          }\n        });\n      });\n    },\n    [range, options, activeIndex, text, setText, textareaRef],\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (!range) {\n        return;\n      }\n\n      if (event.key === \"ArrowDown\") {\n        if (options.length === 0) {\n          return;\n        }\n        event.preventDefault();\n        setActiveIndex((previous) => (previous + 1) % options.length);\n        return;\n      }\n\n      if (event.key === \"ArrowUp\") {\n        if (options.length === 0) {\n          return;\n        }\n        event.preventDefault();\n        setActiveIndex((previous) =>\n          previous - 1 < 0 ? options.length - 1 : (previous - 1) % options.length,\n        );\n        return;\n      }\n\n      if (event.key === \"Enter\" || event.key === \"Tab\") {\n        if (options.length === 0) {\n          return;\n        }\n        event.preventDefault();\n        selectOption();\n        return;\n      }\n\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        closeMenu();\n      }\n    },\n    [range, options, selectOption, closeMenu],\n  );\n\n  return {\n    isOpen: Boolean(range),\n    query: range?.query ?? \"\",\n    options,\n    activeIndex,\n    setActiveIndex,\n    handleTextChange,\n    handleCaretChange,\n    handleKeyDown,\n    selectOption,\n    closeMenu,\n  };\n};\n"
  },
  {
    "path": "web/src/features/sessions/create-session-dialog.tsx",
    "content": "import {\n  type ReactElement,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n  Command,\n  CommandDialog,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { FolderOpen, Home, Loader2 } from \"lucide-react\";\n\nconst HOME_DIR_REGEX = /^(\\/Users\\/[^/]+|\\/home\\/[^/]+)/;\nconst TRAILING_SLASH_REGEX = /\\/$/;\n\ntype CreateSessionDialogProps = {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onConfirm: (workDir: string, createDir?: boolean) => Promise<void>;\n  fetchWorkDirs: () => Promise<string[]>;\n  fetchStartupDir: () => Promise<string>;\n};\n\n/**\n * Format a path for display:\n * - Replace home directory with ~\n * - For long paths, show ~/.../<last-two-segments>\n */\nfunction formatPathForDisplay(path: string, maxSegments = 3): string {\n  const homeMatch = path.match(HOME_DIR_REGEX);\n  let displayPath = path;\n\n  if (homeMatch) {\n    displayPath = `~${path.slice(homeMatch[1].length)}`;\n  }\n\n  const segments = displayPath.split(\"/\").filter(Boolean);\n\n  if (segments.length <= maxSegments) {\n    return displayPath.startsWith(\"~\")\n      ? displayPath\n      : `/${segments.join(\"/\")}`;\n  }\n\n  const prefix = displayPath.startsWith(\"~\") ? \"~\" : \"\";\n  const lastSegments = segments.slice(-2).join(\"/\");\n  return `${prefix}/.../${lastSegments}`;\n}\n\n// Module-level cache for work dirs (stale-while-revalidate)\nlet cachedWorkDirs: string[] | null = null;\n\nexport function CreateSessionDialog({\n  open,\n  onOpenChange,\n  onConfirm,\n  fetchWorkDirs,\n  fetchStartupDir,\n}: CreateSessionDialogProps): ReactElement {\n  const [workDirs, setWorkDirs] = useState<string[]>(\n    () => cachedWorkDirs ?? [],\n  );\n  const [inputValue, setInputValue] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const [isCreating, setIsCreating] = useState(false);\n  const [showConfirmCreate, setShowConfirmCreate] = useState(false);\n  const [pendingPath, setPendingPath] = useState(\"\");\n  const [startupDir, setStartupDir] = useState(\"\");\n  const [commandValue, setCommandValue] = useState(\"\");\n  const isCreatingRef = useRef(false);\n  const commandListRef = useRef<HTMLDivElement>(null);\n\n  // Fetch startup dir and work dirs independently for progressive loading\n  useEffect(() => {\n    if (!open) {\n      return;\n    }\n\n    // Initialize from cache if available, still refresh in background\n    if (cachedWorkDirs) {\n      setWorkDirs(cachedWorkDirs);\n    } else {\n      setIsLoading(true);\n    }\n\n    // Startup dir resolves fast — show it immediately and highlight it\n    fetchStartupDir()\n      .then((startup) => {\n        if (startup) {\n          setStartupDir(startup);\n          setCommandValue(startup);\n        }\n      })\n      .catch(() => {\n        // Startup dir is optional for this dialog; ignore failures.\n      });\n\n    // Work dirs may take longer — update cache when done\n    fetchWorkDirs()\n      .then((dirs) => {\n        cachedWorkDirs = dirs;\n        setWorkDirs(dirs);\n      })\n      .catch((error) => {\n        console.error(\"Failed to fetch directories:\", error);\n      })\n      .finally(() => {\n        setIsLoading(false);\n      });\n  }, [open, fetchWorkDirs, fetchStartupDir]);\n\n  // Reset component state when dialog closes (cache persists at module level)\n  useEffect(() => {\n    if (!open) {\n      setInputValue(\"\");\n      setCommandValue(\"\");\n      setWorkDirs(cachedWorkDirs ?? []);\n      setIsCreating(false);\n      setShowConfirmCreate(false);\n      setPendingPath(\"\");\n      setStartupDir(\"\");\n      isCreatingRef.current = false;\n    }\n  }, [open]);\n\n  const handleSelect = useCallback(\n    async (dir: string) => {\n      if (isCreatingRef.current) return;\n      isCreatingRef.current = true;\n      setIsCreating(true);\n      try {\n        await onConfirm(dir);\n        onOpenChange(false);\n      } catch (err) {\n        if (\n          err instanceof Error &&\n          \"isDirectoryNotFound\" in err &&\n          (err as Error & { isDirectoryNotFound: boolean }).isDirectoryNotFound\n        ) {\n          setPendingPath(dir);\n          setShowConfirmCreate(true);\n        }\n      } finally {\n        setIsCreating(false);\n        isCreatingRef.current = false;\n      }\n    },\n    [onConfirm, onOpenChange],\n  );\n\n  const handleInputSubmit = useCallback(() => {\n    const trimmed = inputValue.trim();\n    if (!trimmed || isCreatingRef.current) return;\n    handleSelect(trimmed);\n  }, [inputValue, handleSelect]);\n\n  const handleConfirmCreateDir = useCallback(async () => {\n    if (!pendingPath) {\n      return;\n    }\n\n    setShowConfirmCreate(false);\n    setIsCreating(true);\n    isCreatingRef.current = true;\n    try {\n      await onConfirm(pendingPath, true);\n      onOpenChange(false);\n    } catch (err) {\n      console.error(\"Failed to create directory:\", err);\n    } finally {\n      setIsCreating(false);\n      isCreatingRef.current = false;\n      setPendingPath(\"\");\n    }\n  }, [pendingPath, onConfirm, onOpenChange]);\n\n  const handleCancelCreateDir = useCallback(() => {\n    setShowConfirmCreate(false);\n    setPendingPath(\"\");\n  }, []);\n\n  // Tab completion: fill input with first matching item's value\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key !== \"Tab\" || !commandListRef.current) return;\n\n      // Find the currently selected (highlighted) item\n      const selectedItem = commandListRef.current.querySelector<HTMLElement>(\n        \"[cmdk-item][data-selected=true]\",\n      );\n      if (!selectedItem) return;\n\n      const value = selectedItem.getAttribute(\"data-value\");\n      if (!value || value.startsWith(\"__custom__\")) return;\n\n      e.preventDefault();\n      setInputValue(value);\n    },\n    [],\n  );\n\n  // Check if the current input matches any existing work dir\n  const trimmedInput = inputValue.trim();\n  const inputMatchesExisting =\n    trimmedInput !== \"\" &&\n    workDirs.some(\n      (dir) =>\n        dir === trimmedInput ||\n        dir === trimmedInput.replace(TRAILING_SLASH_REGEX, \"\"),\n    );\n\n  const showCustomPathOption = trimmedInput !== \"\" && !inputMatchesExisting;\n\n  // Recent dirs = workDirs excluding startupDir\n  const recentDirs = useMemo(\n    () => (startupDir ? workDirs.filter((d) => d !== startupDir) : workDirs),\n    [workDirs, startupDir],\n  );\n\n  return (\n    <>\n      <CommandDialog\n        open={open}\n        onOpenChange={onOpenChange}\n        title=\"Create New Session\"\n        description=\"Search directories or type a new path\"\n        showCloseButton={false}\n      >\n        <Command value={commandValue} onValueChange={setCommandValue}>\n          <CommandInput\n            placeholder=\"Search directories or type a path...\"\n            value={inputValue}\n            onValueChange={setInputValue}\n            onKeyDown={handleKeyDown}\n          />\n          <CommandList ref={commandListRef}>\n            <CommandEmpty>\n              {trimmedInput\n                ? \"No matching directories.\"\n                : isLoading\n                  ? \"Loading directories...\"\n                  : \"Type a path to start a new session.\"}\n            </CommandEmpty>\n\n            {showCustomPathOption && (\n              <>\n                <CommandGroup heading=\"Custom Path\">\n                  <CommandItem\n                    className=\"group\"\n                    value={`__custom__${trimmedInput}`}\n                    onSelect={handleInputSubmit}\n                    disabled={isCreating}\n                  >\n                    {isCreating ? (\n                      <Loader2 className=\"animate-spin\" />\n                    ) : (\n                      <FolderOpen />\n                    )}\n                    <span className=\"flex-1 truncate\">{trimmedInput}</span>\n                    <kbd className=\"pointer-events-none ml-auto hidden select-none rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground group-data-[selected=true]:inline-flex\">\n                      ↵\n                    </kbd>\n                  </CommandItem>\n                </CommandGroup>\n                {(startupDir || recentDirs.length > 0 || isLoading) && (\n                  <CommandSeparator />\n                )}\n              </>\n            )}\n\n            {startupDir && (\n              <>\n                <CommandGroup heading=\"Current Directory\">\n                  <CommandItem\n                    className=\"group\"\n                    value={startupDir}\n                    onSelect={() => handleSelect(startupDir)}\n                    disabled={isCreating}\n                  >\n                    <Home />\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <span className=\"truncate\">\n                          {formatPathForDisplay(startupDir, 3)}\n                        </span>\n                      </TooltipTrigger>\n                      <TooltipContent side=\"right\">\n                        {startupDir}\n                      </TooltipContent>\n                    </Tooltip>\n                    <kbd className=\"pointer-events-none ml-auto hidden select-none rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground group-data-[selected=true]:inline-flex\">\n                      ↵\n                    </kbd>\n                  </CommandItem>\n                </CommandGroup>\n                {(recentDirs.length > 0 || isLoading) && <CommandSeparator />}\n              </>\n            )}\n\n            {recentDirs.length > 0 && (\n              <CommandGroup heading=\"Recent Directories\">\n                {recentDirs.map((dir) => (\n                  <CommandItem\n                    className=\"group\"\n                    key={dir}\n                    value={dir}\n                    onSelect={() => handleSelect(dir)}\n                    disabled={isCreating}\n                  >\n                    <FolderOpen />\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <span className=\"truncate\">\n                          {formatPathForDisplay(dir, 3)}\n                        </span>\n                      </TooltipTrigger>\n                      <TooltipContent side=\"right\">{dir}</TooltipContent>\n                    </Tooltip>\n                    <kbd className=\"pointer-events-none ml-auto hidden select-none rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground group-data-[selected=true]:inline-flex\">\n                      ↵\n                    </kbd>\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            )}\n\n            {isLoading && (\n              <div className=\"flex items-center justify-center py-4\">\n                <Loader2 className=\"size-4 animate-spin text-muted-foreground\" />\n              </div>\n            )}\n          </CommandList>\n        </Command>\n      </CommandDialog>\n\n      <AlertDialog open={showConfirmCreate} onOpenChange={setShowConfirmCreate}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Directory Not Found</AlertDialogTitle>\n            <AlertDialogDescription>\n              The directory{\" \"}\n              <code className=\"bg-muted px-1 py-0.5 rounded text-foreground break-all\">\n                {pendingPath}\n              </code>{\" \"}\n              does not exist. Would you like to create it?\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel onClick={handleCancelCreateDir}>\n              Cancel\n            </AlertDialogCancel>\n            <AlertDialogAction onClick={handleConfirmCreateDir}>\n              Create Directory\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/src/features/sessions/sessions.tsx",
    "content": "import type React from \"react\";\nimport {\n  memo,\n  useCallback,\n  useMemo,\n  type ReactElement,\n  useEffect,\n  useState,\n  type MouseEvent,\n  forwardRef,\n  type ComponentPropsWithoutRef,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  Plus,\n  Trash2,\n  Search,\n  X,\n  AlertTriangle,\n  RefreshCw,\n  List,\n  FolderTree,\n  ChevronDown,\n  Pencil,\n  Loader2,\n  Archive,\n  ArchiveRestore,\n  CheckSquare,\n  Square,\n  PanelLeftClose,\n} from \"lucide-react\";\nimport { Virtuoso } from \"react-virtuoso\";\nimport { KimiCliBrand } from \"@/components/kimi-cli-brand\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport {\n  Collapsible,\n  CollapsibleTrigger,\n  CollapsibleContent,\n} from \"@/components/ui/collapsible\";\nimport { hasPlatformModifier, isMacOS } from \"@/hooks/utils\";\nimport { cn, } from \"@/lib/utils\";\n\n// Top-level regex constants for performance\nconst NEWLINE_REGEX = /\\r\\n|\\r|\\n/;\nconst WHITESPACE_REGEX = /\\s+/g;\n\ntype SessionSummary = {\n  id: string;\n  title: string;\n  updatedAt: string;\n  workDir?: string | null;\n  lastUpdated: Date;\n};\n\ntype ViewMode = \"list\" | \"grouped\";\n\ntype SessionGroup = {\n  workDir: string;\n  displayName: string;\n  sessions: SessionSummary[];\n};\n\nconst VIEW_MODE_KEY = \"kimi-sessions-view-mode\";\n\n/**\n * Shorten a path to fit in limited space\n */\nfunction shortenPath(path: string, maxLen = 30): string {\n  if (path.length <= maxLen) return path;\n  const parts = path.split(\"/\").filter(Boolean);\n  if (parts.length <= 2) return path;\n  return \".../\" + parts.slice(-2).join(\"/\");\n}\n\ntype SessionsSidebarProps = {\n  sessions: SessionSummary[];\n  archivedSessions?: SessionSummary[];\n  selectedSessionId: string;\n  onSelectSession: (id: string) => void;\n  onDeleteSession: (id: string) => void;\n  onRenameSession?: (id: string, newTitle: string) => Promise<boolean>;\n  onArchiveSession?: (id: string) => Promise<boolean>;\n  onUnarchiveSession?: (id: string) => Promise<boolean>;\n  onBulkArchiveSessions?: (sessionIds: string[]) => Promise<number>;\n  onBulkUnarchiveSessions?: (sessionIds: string[]) => Promise<number>;\n  onBulkDeleteSessions?: (sessionIds: string[]) => Promise<number>;\n  onRefreshSessions?: () => Promise<void> | void;\n  onRefreshArchivedSessions?: () => Promise<void> | void;\n  onLoadMoreSessions?: () => Promise<void> | void;\n  onLoadMoreArchivedSessions?: () => Promise<void> | void;\n  hasMoreSessions?: boolean;\n  hasMoreArchivedSessions?: boolean;\n  isLoadingMore?: boolean;\n  isLoadingMoreArchived?: boolean;\n  isLoadingArchived?: boolean;\n  searchQuery: string;\n  onSearchQueryChange: (query: string) => void;\n  onOpenCreateDialog: () => void;\n  onCreateSessionInDir?: (workDir: string) => void;\n  onClose?: () => void;\n  streamStatus?: \"ready\" | \"streaming\" | \"submitted\" | \"error\";\n};\n\ntype ContextMenuState = {\n  sessionId: string;\n  x: number;\n  y: number;\n};\n\nfunction SessionsScrollerComponent(\n  props: ComponentPropsWithoutRef<\"div\">,\n  ref: React.Ref<HTMLDivElement>,\n) {\n  const { className, ...rest } = props;\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"flex-1 overflow-y-auto overflow-x-hidden [-webkit-overflow-scrolling:touch]  pb-4 pr-1\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nfunction SessionsListComponent(\n  props: ComponentPropsWithoutRef<\"div\">,\n  ref: React.Ref<HTMLDivElement>,\n) {\n  const { className, ...rest } = props;\n  return (\n    <div ref={ref} className={cn(\"flex flex-col space-y-0.5 w-full px-2 mt-1\", className)} {...rest} />\n  );\n}\n\nconst SessionsScroller = forwardRef(SessionsScrollerComponent);\nconst SessionsList = forwardRef(SessionsListComponent);\n\nSessionsScroller.displayName = \"SessionsScroller\";\nSessionsList.displayName = \"SessionsList\";\n\nexport const SessionsSidebar = memo(function SessionsSidebarComponent({\n  sessions,\n  archivedSessions = [],\n  selectedSessionId,\n  onSelectSession,\n  onDeleteSession,\n  onRenameSession,\n  onArchiveSession,\n  onUnarchiveSession,\n  onBulkArchiveSessions,\n  onBulkUnarchiveSessions,\n  onBulkDeleteSessions,\n  onRefreshSessions,\n  onRefreshArchivedSessions,\n  onLoadMoreSessions,\n  onLoadMoreArchivedSessions,\n  hasMoreSessions = false,\n  hasMoreArchivedSessions = false,\n  isLoadingMore = false,\n  isLoadingMoreArchived = false,\n  isLoadingArchived = false,\n  searchQuery,\n  onSearchQueryChange,\n  onOpenCreateDialog,\n  onCreateSessionInDir,\n  onClose,\n}: SessionsSidebarProps): ReactElement {\n  const minimumSpinMs = 600;\n  const normalizeTitle = useCallback((t: string) => {\n    // Split by any newline, join with space, then collapse whitespace\n    return String(t)\n      .split(NEWLINE_REGEX)\n      .join(\" \")\n      .replace(WHITESPACE_REGEX, \" \")\n      .trim();\n  }, []);\n  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);\n  const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; sessionId: string; sessionTitle: string }>({\n    open: false,\n    sessionId: \"\",\n    sessionTitle: \"\",\n  });\n  const [isRefreshing, setIsRefreshing] = useState(false);\n  const [editingSessionId, setEditingSessionId] = useState<string | null>(null);\n  const [editingTitle, setEditingTitle] = useState(\"\");\n\n  // Session search state\n  const [sessionSearch, setSessionSearch] = useState(searchQuery);\n\n  // View mode state with localStorage persistence\n  const [viewMode, setViewMode] = useState<ViewMode>(() => {\n    const stored = localStorage.getItem(VIEW_MODE_KEY);\n    return stored === \"grouped\" ? \"grouped\" : \"list\";\n  });\n\n  // Archived section expanded state\n  const [isArchivedExpanded, setIsArchivedExpanded] = useState(false);\n\n  // Track if we're in the context menu of an archived session\n  const [contextMenuIsArchived, setContextMenuIsArchived] = useState(false);\n\n  // Multi-select state\n  const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);\n  const [selectedSessionIds, setSelectedSessionIds] = useState<Set<string>>(new Set());\n  const [isMultiSelectArchived, setIsMultiSelectArchived] = useState(false); // true when selecting archived sessions\n  const [isBulkOperating, setIsBulkOperating] = useState(false);\n\n  useEffect(() => {\n    setSessionSearch(searchQuery);\n  }, [searchQuery]);\n\n  // Load archived sessions when the section is expanded\n  useEffect(() => {\n    if (isArchivedExpanded && onRefreshArchivedSessions) {\n      onRefreshArchivedSessions();\n    }\n  }, [isArchivedExpanded, onRefreshArchivedSessions]);\n\n  // Exit multi-select mode when switching between archived/non-archived\n  const exitMultiSelectMode = useCallback(() => {\n    setIsMultiSelectMode(false);\n    setSelectedSessionIds(new Set());\n  }, []);\n\n  const toggleSessionSelection = useCallback((sessionId: string) => {\n    setSelectedSessionIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(sessionId)) {\n        next.delete(sessionId);\n      } else {\n        next.add(sessionId);\n      }\n      return next;\n    });\n  }, []);\n\n  const toggleSelectAllSessions = useCallback((sessionList: SessionSummary[]) => {\n    setSelectedSessionIds((prev) => {\n      // If all are selected, deselect all\n      if (prev.size === sessionList.length && sessionList.every((s) => prev.has(s.id))) {\n        return new Set();\n      }\n      // Otherwise select all\n      return new Set(sessionList.map((s) => s.id));\n    });\n  }, []);\n\n  const handleBulkArchive = useCallback(async () => {\n    if (!onBulkArchiveSessions || selectedSessionIds.size === 0) return;\n    setIsBulkOperating(true);\n    try {\n      await onBulkArchiveSessions(Array.from(selectedSessionIds));\n      exitMultiSelectMode();\n    } finally {\n      setIsBulkOperating(false);\n    }\n  }, [onBulkArchiveSessions, selectedSessionIds, exitMultiSelectMode]);\n\n  const handleBulkUnarchive = useCallback(async () => {\n    if (!onBulkUnarchiveSessions || selectedSessionIds.size === 0) return;\n    setIsBulkOperating(true);\n    try {\n      await onBulkUnarchiveSessions(Array.from(selectedSessionIds));\n      exitMultiSelectMode();\n    } finally {\n      setIsBulkOperating(false);\n    }\n  }, [onBulkUnarchiveSessions, selectedSessionIds, exitMultiSelectMode]);\n\n  const handleBulkDelete = useCallback(async () => {\n    if (!onBulkDeleteSessions || selectedSessionIds.size === 0) return;\n    setIsBulkOperating(true);\n    try {\n      await onBulkDeleteSessions(Array.from(selectedSessionIds));\n      exitMultiSelectMode();\n    } finally {\n      setIsBulkOperating(false);\n    }\n  }, [onBulkDeleteSessions, selectedSessionIds, exitMultiSelectMode]);\n\n  useEffect(() => {\n    const handle = window.setTimeout(() => {\n      onSearchQueryChange(sessionSearch.trim());\n    }, 300);\n    return () => window.clearTimeout(handle);\n  }, [sessionSearch, onSearchQueryChange]);\n\n  const handleViewModeChange = useCallback((mode: ViewMode) => {\n    setViewMode(mode);\n    localStorage.setItem(VIEW_MODE_KEY, mode);\n  }, []);\n\n  const newSessionShortcutModifier = isMacOS() ? \"Cmd\" : \"Ctrl\";\n\n  // Enhanced search: support both title and workDir\n  const filteredSessions = useMemo(() => {\n    const search = sessionSearch.trim().toLowerCase();\n    if (!search) return sessions;\n    return sessions.filter(\n      (s) =>\n        s.title.toLowerCase().includes(search) ||\n        s.workDir?.toLowerCase().includes(search)\n    );\n  }, [sessions, sessionSearch]);\n\n  // Group sessions by workDir\n  const sessionGroups = useMemo((): SessionGroup[] => {\n    if (viewMode !== \"grouped\") return [];\n\n    const groups = new Map<string, SessionSummary[]>();\n    for (const session of filteredSessions) {\n      const key = session.workDir || \"__other__\";\n      const existing = groups.get(key) || [];\n      groups.set(key, [...existing, session]);\n    }\n\n    return Array.from(groups.entries())\n      .map(([key, items]) => ({\n        workDir: key,\n        displayName: key === \"__other__\" ? \"Other\" : shortenPath(key),\n        sessions: items,\n      }))\n      .sort((a, b) => {\n        // \"Other\" always at bottom\n        if (a.workDir === \"__other__\") return 1;\n        if (b.workDir === \"__other__\") return -1;\n\n        // Sort by latest session time (newest first)\n        const aLatest = Math.max(...a.sessions.map(s => s.lastUpdated.getTime()));\n        const bLatest = Math.max(...b.sessions.map(s => s.lastUpdated.getTime()));\n        return bLatest - aLatest;\n      });\n  }, [filteredSessions, viewMode]);\n\n  useEffect(() => {\n    if (!contextMenu) {\n      return;\n    }\n\n    const closeMenu = () => {\n      setContextMenu(null);\n    };\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setContextMenu(null);\n      }\n    };\n\n    window.addEventListener(\"click\", closeMenu);\n    window.addEventListener(\"contextmenu\", closeMenu);\n    window.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      window.removeEventListener(\"click\", closeMenu);\n      window.removeEventListener(\"contextmenu\", closeMenu);\n      window.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [contextMenu]);\n\n  const handleSessionContextMenu = (\n    event: MouseEvent<HTMLButtonElement>,\n    sessionId: string,\n    isArchived = false,\n  ) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const menuWidth = 200;\n    const menuHeight = 32;\n    const padding = 8;\n    const viewportWidth = window.innerWidth;\n    const viewportHeight = window.innerHeight;\n\n    const proposedX =\n      event.clientX + menuWidth + padding > viewportWidth\n        ? viewportWidth - menuWidth - padding\n        : event.clientX;\n    const proposedY =\n      event.clientY + menuHeight + padding > viewportHeight\n        ? viewportHeight - menuHeight - padding\n        : event.clientY;\n\n    setContextMenu({\n      sessionId,\n      x: Math.max(padding, proposedX),\n      y: Math.max(padding, proposedY),\n    });\n    setContextMenuIsArchived(isArchived);\n  };\n\n  const handleMenuAction = async (action: \"delete\" | \"rename\" | \"archive\" | \"unarchive\" | \"select-multiple\") => {\n    if (!contextMenu) {\n      return;\n    }\n\n    const sessionId = contextMenu.sessionId;\n    const isArchived = contextMenuIsArchived;\n    setContextMenu(null);\n\n    if (action === \"delete\") {\n      const session = isArchived\n        ? archivedSessions.find((s) => s.id === sessionId)\n        : sessions.find((s) => s.id === sessionId);\n      openDeleteConfirm(session);\n    } else if (action === \"rename\") {\n      const session = sessions.find((s) => s.id === sessionId);\n      if (session) {\n        setEditingSessionId(session.id);\n        setEditingTitle(normalizeTitle(session.title));\n      }\n    } else if (action === \"archive\" && onArchiveSession) {\n      await onArchiveSession(sessionId);\n    } else if (action === \"unarchive\" && onUnarchiveSession) {\n      await onUnarchiveSession(sessionId);\n    } else if (action === \"select-multiple\") {\n      setIsMultiSelectMode(true);\n      setIsMultiSelectArchived(isArchived);\n      setSelectedSessionIds(new Set([sessionId]));\n    }\n  };\n\n  const handleSaveEdit = async () => {\n    if (!(editingSessionId && onRenameSession)) {\n      handleCancelEdit();\n      return;\n    }\n\n    const trimmedTitle = editingTitle.trim();\n    if (!trimmedTitle) {\n      handleCancelEdit();\n      return;\n    }\n\n    const success = await onRenameSession(editingSessionId, trimmedTitle);\n    if (success) {\n      handleCancelEdit();\n    }\n  };\n\n  const handleCancelEdit = () => {\n    setEditingSessionId(null);\n    setEditingTitle(\"\");\n  };\n\n  const openDeleteConfirm = useCallback(\n    (session?: SessionSummary) => {\n      if (!session) {\n        return;\n      }\n      setDeleteConfirm({\n        open: true,\n        sessionId: session.id,\n        sessionTitle: normalizeTitle(session.title ?? \"Unknown Session\"),\n      });\n    },\n    [normalizeTitle],\n  );\n\n  const handleConfirmDelete = () => {\n    if (deleteConfirm.sessionId) {\n      onDeleteSession(deleteConfirm.sessionId);\n    }\n    setDeleteConfirm({ open: false, sessionId: \"\", sessionTitle: \"\" });\n  };\n\n  const handleCancelDelete = () => {\n    setDeleteConfirm({ open: false, sessionId: \"\", sessionTitle: \"\" });\n  };\n\n  const handleRefreshSessions = async () => {\n    if (!onRefreshSessions || isRefreshing) {\n      return;\n    }\n    setIsRefreshing(true);\n    const startedAt = Date.now();\n    try {\n      await Promise.resolve(onRefreshSessions());\n    } finally {\n      const elapsed = Date.now() - startedAt;\n      if (elapsed < minimumSpinMs) {\n        await new Promise((resolve) => setTimeout(resolve, minimumSpinMs - elapsed));\n      }\n      setIsRefreshing(false);\n    }\n  };\n\n  const handleLoadMore = async () => {\n    if (!onLoadMoreSessions || isLoadingMore || !hasMoreSessions) {\n      return;\n    }\n    await Promise.resolve(onLoadMoreSessions());\n  };\n\n  const renderLoadMore = () => {\n    if (!(hasMoreSessions || isLoadingMore)) {\n      return null;\n    }\n    return (\n      <div className=\"flex items-center justify-center py-2\">\n        {isLoadingMore ? (\n          <Loader2 className=\"size-4 animate-spin text-muted-foreground\" />\n        ) : (\n          <button\n            type=\"button\"\n            className=\"text-xs text-muted-foreground hover:text-foreground\"\n            onClick={handleLoadMore}\n          >\n            Load more\n          </button>\n        )}\n      </div>\n    );\n  };\n\n  const renderContextMenu = () => {\n    if (!contextMenu) {\n      return null;\n    }\n\n    const hasBulkOperations = onBulkArchiveSessions || onBulkUnarchiveSessions || onBulkDeleteSessions;\n\n    const menu = (\n      <div\n        className=\"fixed z-120 min-w-40 rounded-md border border-border bg-popover p-1 text-sm shadow-md\"\n        onClick={(event) => event.stopPropagation()}\n        onKeyDown={(event) => {\n          if (event.key === \"Escape\") {\n            event.stopPropagation();\n          }\n        }}\n        role=\"menu\"\n        style={{ top: contextMenu.y, left: contextMenu.x }}\n      >\n        {/* Show Rename only for non-archived sessions */}\n        {onRenameSession && !contextMenuIsArchived && (\n          <button\n            className=\"flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent\"\n            onClick={() => handleMenuAction(\"rename\")}\n            type=\"button\"\n          >\n            <Pencil className=\"size-3.5\" />\n            Rename\n          </button>\n        )}\n        {/* Show Archive for non-archived sessions */}\n        {onArchiveSession && !contextMenuIsArchived && (\n          <button\n            className=\"flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent\"\n            onClick={() => handleMenuAction(\"archive\")}\n            type=\"button\"\n          >\n            <Archive className=\"size-3.5\" />\n            Archive\n          </button>\n        )}\n        {/* Show Unarchive for archived sessions */}\n        {onUnarchiveSession && contextMenuIsArchived && (\n          <button\n            className=\"flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent\"\n            onClick={() => handleMenuAction(\"unarchive\")}\n            type=\"button\"\n          >\n            <ArchiveRestore className=\"size-3.5\" />\n            Unarchive\n          </button>\n        )}\n        {/* Show Select Multiple option */}\n        {hasBulkOperations && (\n          <button\n            className=\"flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent\"\n            onClick={() => handleMenuAction(\"select-multiple\")}\n            type=\"button\"\n          >\n            <CheckSquare className=\"size-3.5\" />\n            Select Multiple\n          </button>\n        )}\n        <button\n          className=\"flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs text-destructive hover:bg-destructive/10\"\n          onClick={() => handleMenuAction(\"delete\")}\n          type=\"button\"\n        >\n          <Trash2 className=\"size-3.5\" />\n          Delete session\n        </button>\n      </div>\n    );\n\n    return typeof document === \"undefined\"\n      ? menu\n      : createPortal(menu, document.body);\n  };\n\n  return (\n    <>\n      <aside className=\"flex h-full min-h-0 flex-col\">\n        <div className=\"flex min-h-0 flex-1 flex-col gap-2 overflow-hidden\">\n          <div className=\"flex items-center justify-between px-3 pt-2\">\n            <KimiCliBrand size=\"sm\" showVersion={true} />\n            {onClose && (\n              <button\n                type=\"button\"\n                aria-label=\"Close sidebar\"\n                className=\"inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary/50 hover:text-foreground\"\n                onClick={onClose}\n              >\n                <PanelLeftClose className=\"size-4\" />\n              </button>\n            )}\n          </div>\n\n          {/* Sessions */}\n          <div className=\"flex items-center justify-between px-3 pt-3\">\n            <h4 className=\"text-xs font-medium text-muted-foreground uppercase tracking-wide\">Sessions</h4>\n            <div className=\"flex items-center gap-1\">\n              <button\n                aria-label=\"Refresh sessions\"\n                className=\"cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-60\"\n                onClick={handleRefreshSessions}\n                disabled={isRefreshing || !onRefreshSessions}\n                aria-busy={isRefreshing}\n                title=\"Refresh Sessions\"\n                type=\"button\"\n              >\n                <RefreshCw className={`size-4 ${isRefreshing ? \"animate-spin\" : \"\"}`} />\n              </button>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <button\n                    aria-label=\"New Session\"\n                    className=\"cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n                    onClick={(e) => {\n                      if (hasPlatformModifier(e)) {\n                        const url = new URL(window.location.origin + window.location.pathname);\n                        url.searchParams.set(\"action\", \"create\");\n                        window.open(url.toString(), \"_blank\");\n                      } else {\n                        onOpenCreateDialog?.();\n                      }\n                    }}\n                    type=\"button\"\n                  >\n                    <Plus className=\"size-4\" />\n                  </button>\n                </TooltipTrigger>\n                <TooltipContent className=\"flex flex-col items-center gap-1\" side=\"bottom\">\n                  <div className=\"flex items-center gap-2\">\n                    <span>New session</span>\n                    <KbdGroup>\n                      <Kbd>Shift</Kbd>\n                      <span className=\"text-muted-foreground\">+</span>\n                      <Kbd>{newSessionShortcutModifier}</Kbd>\n                      <span className=\"text-muted-foreground\">+</span>\n                      <Kbd>O</Kbd>\n                    </KbdGroup>\n                  </div>\n                  <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                    <span>{newSessionShortcutModifier}+Click to open in new tab</span>\n                  </div>\n                </TooltipContent>\n              </Tooltip>\n            </div>\n          </div>\n\n          {/* Multi-select action bar */}\n          {isMultiSelectMode && (\n            <div className=\"mx-2 flex items-center justify-between gap-2 rounded-md bg-secondary/80 px-2 py-1.5\">\n              {/* Left: checkbox toggle and count */}\n              <div className=\"flex items-center gap-1.5\">\n                <button\n                  type=\"button\"\n                  className=\"text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50\"\n                  onClick={() => toggleSelectAllSessions(isMultiSelectArchived ? archivedSessions : filteredSessions)}\n                  disabled={isBulkOperating}\n                  aria-label={selectedSessionIds.size === (isMultiSelectArchived ? archivedSessions : filteredSessions).length ? \"Deselect all\" : \"Select all\"}\n                >\n                  {selectedSessionIds.size === (isMultiSelectArchived ? archivedSessions : filteredSessions).length && selectedSessionIds.size > 0 ? (\n                    <CheckSquare className=\"size-4\" />\n                  ) : (\n                    <Square className=\"size-4\" />\n                  )}\n                </button>\n                <span className=\"text-xs text-muted-foreground\">\n                  {selectedSessionIds.size} selected\n                </span>\n              </div>\n              {/* Right: action buttons */}\n              <div className=\"flex items-center\">\n                {/* Archive/Unarchive button */}\n                {isMultiSelectArchived ? (\n                  onBulkUnarchiveSessions && (\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <button\n                          type=\"button\"\n                          className=\"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground transition-colors disabled:opacity-50\"\n                          onClick={handleBulkUnarchive}\n                          disabled={isBulkOperating || selectedSessionIds.size === 0}\n                        >\n                          {isBulkOperating ? (\n                            <Loader2 className=\"size-4 animate-spin\" />\n                          ) : (\n                            <ArchiveRestore className=\"size-4\" />\n                          )}\n                        </button>\n                      </TooltipTrigger>\n                      <TooltipContent side=\"bottom\">Unarchive</TooltipContent>\n                    </Tooltip>\n                  )\n                ) : (\n                  onBulkArchiveSessions && (\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <button\n                          type=\"button\"\n                          className=\"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground transition-colors disabled:opacity-50\"\n                          onClick={handleBulkArchive}\n                          disabled={isBulkOperating || selectedSessionIds.size === 0}\n                        >\n                          {isBulkOperating ? (\n                            <Loader2 className=\"size-4 animate-spin\" />\n                          ) : (\n                            <Archive className=\"size-4\" />\n                          )}\n                        </button>\n                      </TooltipTrigger>\n                      <TooltipContent side=\"bottom\">Archive</TooltipContent>\n                    </Tooltip>\n                  )\n                )}\n                {/* Delete button */}\n                {onBulkDeleteSessions && (\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <button\n                        type=\"button\"\n                        className=\"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors disabled:opacity-50\"\n                        onClick={handleBulkDelete}\n                        disabled={isBulkOperating || selectedSessionIds.size === 0}\n                      >\n                        {isBulkOperating ? (\n                          <Loader2 className=\"size-4 animate-spin\" />\n                        ) : (\n                          <Trash2 className=\"size-4\" />\n                        )}\n                      </button>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"bottom\">Delete</TooltipContent>\n                  </Tooltip>\n                )}\n                {/* Divider */}\n                <div className=\"mx-1 h-4 w-px bg-border\" />\n                {/* Cancel button */}\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <button\n                      type=\"button\"\n                      className=\"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground transition-colors\"\n                      onClick={exitMultiSelectMode}\n                      disabled={isBulkOperating}\n                    >\n                      <X className=\"size-4\" />\n                    </button>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"bottom\">Done</TooltipContent>\n                </Tooltip>\n              </div>\n            </div>\n          )}\n\n          {/* Session search and view toggle */}\n          {!isMultiSelectMode && (\n          <div className=\"px-2 flex items-center gap-2\">\n            <div className=\"relative flex-1 min-w-0\">\n              <Search className=\"absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\" />\n              <input\n                type=\"text\"\n                placeholder=\"Search sessions...\"\n                value={sessionSearch}\n                onChange={(e) => setSessionSearch(e.target.value)}\n                className=\"h-8 w-full rounded-md border border-input bg-background pl-8 pr-8 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring\"\n              />\n              {sessionSearch && (\n                <button\n                  type=\"button\"\n                  onClick={() => setSessionSearch(\"\")}\n                  className=\"absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer rounded-sm p-0.5 text-muted-foreground hover:text-foreground\"\n                  aria-label=\"Clear search\"\n                >\n                  <X className=\"size-3.5\" />\n                </button>\n              )}\n            </div>\n            <ToggleGroup\n              type=\"single\"\n              variant=\"outline\"\n              value={viewMode}\n              onValueChange={(value) => value && handleViewModeChange(value as ViewMode)}\n              className=\"shrink-0\"\n            >\n              <ToggleGroupItem value=\"list\" aria-label=\"List view\" title=\"List view\" className=\"h-8 w-8 px-0\">\n                <List className=\"size-3.5\" />\n              </ToggleGroupItem>\n              <ToggleGroupItem value=\"grouped\" aria-label=\"Grouped view\" title=\"Grouped by folder\" className=\"h-8 w-8 px-0\">\n                <FolderTree className=\"size-3.5\" />\n              </ToggleGroupItem>\n            </ToggleGroup>\n          </div>\n          )}\n\n          <div className=\"flex-1 min-h-0 flex flex-col\">\n            <div className=\"flex-1 min-h-0\">\n            {viewMode === \"grouped\" ? (\n              <div className=\"flex h-full flex-col\">\n                <div className=\"flex-1 overflow-y-auto overflow-x-hidden [-webkit-overflow-scrolling:touch] px-3 pb-4 pr-1\">\n                  <ul className=\"space-y-1\">\n                    {sessionGroups.map((group) => (\n                      <li key={group.workDir} className=\"group/dir\">\n                        <Collapsible defaultOpen={group.sessions.some(s => s.id === selectedSessionId)}>\n                          <div className=\"flex items-center\">\n                            <CollapsibleTrigger className=\"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-secondary/50 group\">\n                              <ChevronDown className=\"size-3 transition-transform group-data-[state=closed]:-rotate-90\" />\n                              <Tooltip>\n                                <TooltipTrigger asChild>\n                                  <span className=\"flex-1 truncate text-left font-medium\">\n                                    {group.displayName}\n                                  </span>\n                                </TooltipTrigger>\n                                {group.workDir !== \"__other__\" && (\n                                  <TooltipContent\n                                    side=\"right\"\n                                  >\n                                    {group.workDir}\n                                  </TooltipContent>\n                                )}\n                              </Tooltip>\n                              <span className=\"text-[10px] text-muted-foreground\">\n                                ({group.sessions.length})\n                              </span>\n                            </CollapsibleTrigger>\n                            {group.workDir !== \"__other__\" && onCreateSessionInDir && (\n                              <Tooltip>\n                                <TooltipTrigger asChild>\n                                  <button\n                                    type=\"button\"\n                                    aria-label={`New session in ${group.displayName}`}\n                                    className=\"shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground opacity-0 group-hover/dir:opacity-100 hover:bg-accent hover:text-foreground transition-all\"\n                                    onClick={(e) => {\n                                      e.stopPropagation();\n                                      if (hasPlatformModifier(e)) {\n                                        const url = new URL(window.location.origin + window.location.pathname);\n                                        url.searchParams.set(\"action\", \"create-in-dir\");\n                                        url.searchParams.set(\"workDir\", group.workDir);\n                                        window.open(url.toString(), \"_blank\");\n                                      } else {\n                                        onCreateSessionInDir(group.workDir);\n                                      }\n                                    }}\n                                  >\n                                    <Plus className=\"size-3.5\" />\n                                  </button>\n                                </TooltipTrigger>\n                                <TooltipContent className=\"flex flex-col items-center gap-1\" side=\"right\">\n                                  <span>New session here</span>\n                                  <span className=\"text-xs text-muted-foreground\">{newSessionShortcutModifier}+Click to open in new tab</span>\n                                </TooltipContent>\n                              </Tooltip>\n                            )}\n                          </div>\n                          <CollapsibleContent>\n                            <ul className=\"pl-3 space-y-1 mt-1\">\n                              {group.sessions.map((session) => {\n                                const isActive = session.id === selectedSessionId;\n                                const isEditing = editingSessionId === session.id;\n                                return (\n                                  <li key={session.id}>\n                                    <div className=\"flex w-full items-center gap-2\">\n                                      <button\n                                        className={`flex-1 min-w-0 cursor-pointer text-left rounded-lg px-3 py-2 transition-colors ${\n                                          isActive\n                                            ? \"bg-secondary\"\n                                            : \"hover:bg-secondary/60\"\n                                        }`}\n                                        onClick={() => !isEditing && onSelectSession(session.id)}\n                                        onContextMenu={(event) =>\n                                          !isEditing && handleSessionContextMenu(event, session.id)\n                                        }\n                                        type=\"button\"\n                                      >\n                                        {isEditing ? (\n                                          <input\n                                            autoFocus\n                                            value={editingTitle}\n                                            onChange={(e) => setEditingTitle(e.target.value)}\n                                            onBlur={handleSaveEdit}\n                                            onKeyDown={(e) => {\n                                              if (e.key === \"Enter\") {\n                                                e.preventDefault();\n                                                handleSaveEdit();\n                                              }\n                                              if (e.key === \"Escape\") {\n                                                e.preventDefault();\n                                                handleCancelEdit();\n                                              }\n                                            }}\n                                            onClick={(e) => e.stopPropagation()}\n                                            className=\"w-full text-sm font-medium text-foreground bg-background border border-input rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-ring\"\n                                          />\n                                        ) : (\n                                          <Tooltip delayDuration={500}>\n                                            <TooltipTrigger asChild>\n                                              <p className=\"text-sm font-medium text-foreground truncate\">\n                                                {normalizeTitle(session.title)}\n                                              </p>\n                                            </TooltipTrigger>\n                                            <TooltipContent side=\"right\" className=\"max-w-md\">\n                                              {normalizeTitle(session.title)}\n                                            </TooltipContent>\n                                          </Tooltip>\n                                        )}\n                                        {!isEditing && (\n                                          <span className=\"text-[10px] text-muted-foreground mt-1 block\">\n                                            {session.updatedAt}\n                                          </span>\n                                        )}\n                                      </button>\n                                      <button\n                                        type=\"button\"\n                                        aria-label=\"Delete session\"\n                                        className=\"md:hidden inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive\"\n                                        onClick={(event) => {\n                                          event.stopPropagation();\n                                          openDeleteConfirm(session);\n                                        }}\n                                      >\n                                        <Trash2 className=\"size-3.5\" />\n                                      </button>\n                                    </div>\n                                  </li>\n                                );\n                              })}\n                            </ul>\n                          </CollapsibleContent>\n                        </Collapsible>\n                      </li>\n                    ))}\n                  </ul>\n                  {renderLoadMore()}\n                </div>\n              </div>\n            ) : (\n              <Virtuoso\n                data={filteredSessions}\n                className=\"h-full\"\n                computeItemKey={(_index, session) => session.id}\n                components={{\n                  Scroller: SessionsScroller,\n                  List: SessionsList,\n                  Footer: renderLoadMore,\n                }}\n                endReached={() => {\n                  if (hasMoreSessions) {\n                    handleLoadMore();\n                  }\n                }}\n                itemContent={(_index, session) => {\n                  const isActive = session.id === selectedSessionId;\n                  const isEditing = editingSessionId === session.id;\n                  const isSelected = isMultiSelectMode && !isMultiSelectArchived && selectedSessionIds.has(session.id);\n                  const showCheckbox = isMultiSelectMode && !isMultiSelectArchived;\n                  return (\n                    <div className={`flex w-full items-center gap-2  transition-colors rounded-lg ${\n                          isSelected\n                            ? \"bg-primary/10 ring-1 ring-primary/30\"\n                            : isActive\n                            ? \"bg-secondary\"\n                            : \"hover:bg-secondary/60\"\n                        }`}>\n                      {showCheckbox && (\n                        <button\n                          type=\"button\"\n                          className=\"ml-2 shrink-0 cursor-pointer\"\n                          onClick={() => toggleSessionSelection(session.id)}\n                        >\n                          {isSelected ? (\n                            <CheckSquare className=\"size-4 text-primary\" />\n                          ) : (\n                            <Square className=\"size-4 text-muted-foreground\" />\n                          )}\n                        </button>\n                      )}\n                      <button\n                        className={`flex-1 min-w-0 cursor-pointer text-left rounded-md px-2.5 py-1.5 transition-colors ${\n                          showCheckbox ? \"\" : (isActive\n                            ? \"bg-secondary\"\n                            : \"hover:bg-secondary/60\")\n                        }`}\n                        onClick={() => {\n                          if (showCheckbox) {\n                            toggleSessionSelection(session.id);\n                          } else if (!isEditing) {\n                            onSelectSession(session.id);\n                          }\n                        }}\n                        onContextMenu={(event) =>\n                          !(isEditing || showCheckbox) && handleSessionContextMenu(event, session.id)\n                        }\n                        type=\"button\"\n                      >\n                        {isEditing ? (\n                          <input\n                            autoFocus\n                            value={editingTitle}\n                            onChange={(e) => setEditingTitle(e.target.value)}\n                            onBlur={handleSaveEdit}\n                            onKeyDown={(e) => {\n                              if (e.key === \"Enter\") {\n                                e.preventDefault();\n                                handleSaveEdit();\n                              }\n                              if (e.key === \"Escape\") {\n                                e.preventDefault();\n                                handleCancelEdit();\n                              }\n                            }}\n                            onClick={(e) => e.stopPropagation()}\n                            className=\"w-full text-sm font-medium text-foreground bg-background border border-input rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-ring\"\n                          />\n                        ) : (\n                          <div className=\"flex items-center gap-2\">\n                            <Tooltip delayDuration={500}>\n                              <TooltipTrigger asChild>\n                                <p className=\"text-sm font-medium text-foreground truncate flex-1\">\n                                  {normalizeTitle(session.title)}\n                                </p>\n                              </TooltipTrigger>\n                              <TooltipContent side=\"right\" className=\"max-w-md\">\n                                {normalizeTitle(session.title)}\n                              </TooltipContent>\n                            </Tooltip>\n                            <span className=\"text-[10px] text-muted-foreground shrink-0\">\n                              {session.updatedAt}\n                            </span>\n                          </div>\n                        )}\n                      </button>\n                      {/* Mobile action buttons */}\n                      {!showCheckbox && onArchiveSession && (\n                        <button\n                          type=\"button\"\n                          aria-label=\"Archive session\"\n                          className=\"md:hidden inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent\"\n                          onClick={(event) => {\n                            event.stopPropagation();\n                            onArchiveSession(session.id);\n                          }}\n                        >\n                          <Archive className=\"size-3.5\" />\n                        </button>\n                      )}\n                      {!showCheckbox && (\n                        <button\n                          type=\"button\"\n                          aria-label=\"Delete session\"\n                          className=\"md:hidden inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive\"\n                          onClick={(event) => {\n                            event.stopPropagation();\n                            openDeleteConfirm(session);\n                          }}\n                        >\n                          <Trash2 className=\"size-3.5\" />\n                        </button>\n                      )}\n                    </div>\n                  );\n                }}\n              />\n            )}\n            </div>\n\n            {/* Archived Sessions Section */}\n            {(onArchiveSession || onUnarchiveSession) && (\n              <div className=\"mx-2 mb-2 shrink-0 rounded-lg border border-border bg-muted/30\">\n                <Collapsible open={isArchivedExpanded} onOpenChange={setIsArchivedExpanded}>\n                  <CollapsibleTrigger className=\"flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted/50 group\">\n                    <ChevronDown className=\"size-3 transition-transform group-data-[state=closed]:-rotate-90\" />\n                    <Archive className=\"size-3.5\" />\n                    <span className=\"flex-1 text-left font-medium\">Archived</span>\n                    <span className=\"text-[10px] text-muted-foreground/70 bg-muted px-1.5 py-0.5 rounded\">\n                      {archivedSessions.length}\n                    </span>\n                  </CollapsibleTrigger>\n                  <CollapsibleContent>\n                    {isLoadingArchived ? (\n                      <div className=\"flex items-center justify-center py-4\">\n                        <Loader2 className=\"size-4 animate-spin text-muted-foreground\" />\n                      </div>\n                    ) : archivedSessions.length === 0 ? (\n                      <p className=\"px-3 py-3 text-xs text-muted-foreground\">No archived sessions</p>\n                    ) : (\n                      <div className=\"space-y-1 px-1 pb-2 max-h-[50vh] overflow-y-auto\">\n                        <ul className=\"space-y-1\">\n                          {archivedSessions.map((session) => {\n                            const isActive = session.id === selectedSessionId;\n                            const isSelected = isMultiSelectMode && isMultiSelectArchived && selectedSessionIds.has(session.id);\n                            const showCheckbox = isMultiSelectMode && isMultiSelectArchived;\n                            return (\n                              <li key={session.id}>\n                                <div className={`flex w-full items-center gap-2 rounded-lg transition-colors ${\n                                  isSelected\n                                    ? \"bg-primary/10 ring-1 ring-primary/30\"\n                                    : \"\"\n                                }`}>\n                                  {showCheckbox && (\n                                    <button\n                                      type=\"button\"\n                                      className=\"ml-2 shrink-0 cursor-pointer\"\n                                      onClick={() => toggleSessionSelection(session.id)}\n                                    >\n                                      {isSelected ? (\n                                        <CheckSquare className=\"size-4 text-primary\" />\n                                      ) : (\n                                        <Square className=\"size-4 text-muted-foreground\" />\n                                      )}\n                                    </button>\n                                  )}\n                                  <button\n                                    className={`flex-1 min-w-0 cursor-pointer text-left rounded-md px-2.5 py-1.5 transition-colors ${\n                                      showCheckbox ? \"\" : (isActive\n                                        ? \"bg-secondary\"\n                                        : \"hover:bg-secondary/60\")\n                                    }`}\n                                    onClick={() => {\n                                      if (showCheckbox) {\n                                        toggleSessionSelection(session.id);\n                                      } else {\n                                        onSelectSession(session.id);\n                                      }\n                                    }}\n                                    onContextMenu={(event) =>\n                                      !showCheckbox && handleSessionContextMenu(event, session.id, true)\n                                    }\n                                    type=\"button\"\n                                  >\n                                    <div className=\"flex items-center gap-2\">\n                                      <Tooltip delayDuration={500}>\n                                        <TooltipTrigger asChild>\n                                          <p className=\"text-sm font-medium text-foreground truncate flex-1 opacity-70\">\n                                            {normalizeTitle(session.title)}\n                                          </p>\n                                        </TooltipTrigger>\n                                        <TooltipContent side=\"right\" className=\"max-w-md\">\n                                          {normalizeTitle(session.title)}\n                                        </TooltipContent>\n                                      </Tooltip>\n                                      <span className=\"text-[10px] text-muted-foreground shrink-0\">\n                                        {session.updatedAt}\n                                      </span>\n                                    </div>\n                                  </button>\n                                  {/* Mobile action buttons for archived sessions */}\n                                  {!showCheckbox && onUnarchiveSession && (\n                                    <button\n                                      type=\"button\"\n                                      aria-label=\"Unarchive session\"\n                                      className=\"md:hidden inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent\"\n                                      onClick={(event) => {\n                                        event.stopPropagation();\n                                        onUnarchiveSession(session.id);\n                                      }}\n                                    >\n                                      <ArchiveRestore className=\"size-3.5\" />\n                                    </button>\n                                  )}\n                                  {!showCheckbox && (\n                                    <button\n                                      type=\"button\"\n                                      aria-label=\"Delete session\"\n                                      className=\"md:hidden inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive\"\n                                      onClick={(event) => {\n                                        event.stopPropagation();\n                                        openDeleteConfirm(session);\n                                      }}\n                                    >\n                                      <Trash2 className=\"size-3.5\" />\n                                    </button>\n                                  )}\n                                </div>\n                              </li>\n                            );\n                          })}\n                        </ul>\n                        {/* Load more archived sessions */}\n                        {(hasMoreArchivedSessions || isLoadingMoreArchived) && (\n                          <div className=\"flex items-center justify-center py-2\">\n                            {isLoadingMoreArchived ? (\n                              <Loader2 className=\"size-4 animate-spin text-muted-foreground\" />\n                            ) : (\n                              <button\n                                type=\"button\"\n                                className=\"text-xs text-muted-foreground hover:text-foreground\"\n                                onClick={() => onLoadMoreArchivedSessions?.()}\n                              >\n                                Load more\n                              </button>\n                            )}\n                          </div>\n                        )}\n                      </div>\n                    )}\n                  </CollapsibleContent>\n                </Collapsible>\n              </div>\n            )}\n          </div>\n        </div>\n      </aside>\n      {renderContextMenu()}\n\n      {/* Delete Confirmation Dialog */}\n      <Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && handleCancelDelete()}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2 text-destructive\">\n              <AlertTriangle className=\"size-5\" />\n              Delete Session\n            </DialogTitle>\n            <DialogDescription>\n              Are you sure you want to delete <strong className=\"text-foreground\">{deleteConfirm.sessionTitle}</strong>?\n              This action cannot be undone.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter className=\"gap-2 w-full justify-end\">\n            <Button variant=\"outline\" onClick={handleCancelDelete}>\n              Cancel\n            </Button>\n            <Button variant=\"destructive\" onClick={handleConfirmDelete}>\n              Delete\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n});\n"
  },
  {
    "path": "web/src/features/tool/components/display-content.tsx",
    "content": "\"use client\";\n\nimport { CodeBlock } from \"@/components/ai-elements/code-block\";\nimport { LazyDiff as Diff, LazyHunk as Hunk } from \"@/components/ui/diff/lazy\";\nimport type { File, Hunk as HunkType, Line } from \"@/components/ui/diff/utils\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  ChevronDownIcon,\n  CircleCheckIcon,\n  CircleDashedIcon,\n  CircleIcon,\n} from \"lucide-react\";\nimport type { ComponentProps } from \"react\";\nimport { Suspense, useEffect, useMemo, useState } from \"react\";\n\nexport type DisplayItem = {\n  type: string;\n  data: unknown;\n};\n\nexport type DisplayContentProps = ComponentProps<\"div\"> & {\n  display: DisplayItem[];\n};\n\ntype MCPResourceData = {\n  type: \"resource\";\n  resource: {\n    uri?: string;\n    mimeType?: string;\n    blob?: string;\n    text?: string;\n  };\n};\n\ntype MCPTextData = {\n  type: \"text\";\n  text: string;\n  annotations?: {\n    audience?: string[];\n  };\n};\n\n/**\n * Type for image data from ipython tool\n * Format: { type: \"image\", data: \"base64...\" }\n */\ntype MCPImageData = {\n  type: \"image\";\n  data: string;\n  mimeType?: string;\n};\n\n/**\n * Wrapper type for nested MCP content\n * Some tools (like ipython) wrap content in an extra { data: ... } layer\n * Note: This is handled at runtime via isNestedData() type guard\n */\ntype MCPNestedData = {\n  data: MCPImageData | MCPResourceData | MCPTextData;\n};\n\ntype MCPContentData = MCPResourceData | MCPTextData | MCPImageData;\n\n/**\n * Type for image_search_by_image results\n * Google Lens API response format\n */\ntype ImageSearchByImageResult = {\n  link: string;\n  title: string;\n  thumbnailUrl?: string;\n  imageUrl?: string;\n  source?: string;\n};\n\n/**\n * Type for image_search_by_text results\n * Internal image search API response format\n */\ntype ImageSearchByTextResult = {\n  requestId: string;\n  images: Array<{\n    original: string;\n    thumbnail?: string;\n    clipThumbnail?: string;\n    meta?: {\n      title?: string;\n      source?: string;\n      sourceUrl?: string;\n      keywords?: string[];\n    };\n  }>;\n};\n\n/**\n * Type for web_search results\n * Web search API response format\n */\ntype WebSearchResult = {\n  requestId: string;\n  chunk: {\n    chunks: Array<{\n      text: string;\n      url: string;\n      title: string;\n      siteName?: string;\n      date?: string;\n      score?: number;\n      labels?: Array<{\n        type: string;\n        text: string;\n        hover?: string;\n        icon?: string;\n      }>;\n    }>;\n  };\n};\n\n/**\n * Type for search_response display type (from backend search tool)\n * Contains search chunks with page metadata\n */\ntype SearchResponseResult = {\n  requestId: string;\n  needSearch?: boolean;\n  keywords?: string[];\n  chunkResult: {\n    requestId: string;\n    chunks: Array<{\n      id: string;\n      text: string;\n      score: number;\n      page: {\n        id: string;\n        url: string;\n        title: string;\n        snippet?: string;\n        contentType?: string;\n        pageType?: string;\n        siteName?: string;\n        siteIcon?: string;\n        rankScore?: number;\n        refIndex?: number;\n        debugInfo?: Record<string, unknown>;\n      };\n      debugInfo?: Record<string, unknown>;\n    }>;\n  };\n};\n\n/**\n * Renders image_search_by_text results in a grid\n * Shows images from internal search API with hover overlays\n */\nconst ImageSearchByTextResults = ({\n  result,\n}: {\n  result: ImageSearchByTextResult;\n}) => (\n  <div className=\"my-2\">\n    <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3\">\n      {result.images.map((image, idx) => (\n        <a\n          key={`${result.requestId}-${idx}`}\n          href={image.original}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"group relative overflow-hidden rounded-md border border-border/40 bg-card/20 transition-all hover:border-border hover:bg-card/40\"\n        >\n          {/* biome-ignore lint/correctness/useImageSize: Dynamic image with unknown dimensions */}\n          {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError is a lifecycle event, not interactive */}\n          <img\n            src={image.clipThumbnail || image.thumbnail || image.original}\n            alt={image.meta?.title || `Image ${idx + 1}`}\n            className=\"aspect-square w-full object-cover\"\n            onError={(e) => {\n              (e.currentTarget.parentElement as HTMLElement).style.display = \"none\";\n            }}\n          />\n          {image.meta && (\n            <div className=\"absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100\">\n              {image.meta.title && (\n                <div className=\"text-xs font-medium text-white line-clamp-2\">\n                  {image.meta.title}\n                </div>\n              )}\n              {image.meta.source && (\n                <div className=\"mt-0.5 text-xs text-white/80\">\n                  {image.meta.source}\n                </div>\n              )}\n            </div>\n          )}\n        </a>\n      ))}\n    </div>\n  </div>\n);\n\n/**\n * Renders image_search_by_image results (Google Lens)\n * Shows search results with thumbnails and metadata in a list layout\n */\nconst ImageSearchByImageResults = ({\n  items,\n}: {\n  items: ImageSearchByImageResult[];\n}) => (\n  <div className=\"my-2 space-y-3\">\n    {items.map((item) => (\n      <div\n        key={item.link || item.title}\n        className=\"flex flex-col gap-3 rounded-md border border-border/40 bg-card/20 p-3 hover:bg-card/40 sm:flex-row\"\n      >\n        {item.thumbnailUrl && (\n          <a\n            href={item.link}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex-shrink-0\"\n          >\n            {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError is a lifecycle event, not interactive */}\n            <img\n              src={item.thumbnailUrl}\n              alt={item.title}\n              width={80}\n              height={80}\n              className=\"h-32 w-full rounded object-cover sm:h-20 sm:w-20\"\n              onError={(e) => {\n                (e.currentTarget.parentElement as HTMLElement).style.display = \"none\";\n              }}\n            />\n          </a>\n        )}\n        <div className=\"min-w-0 flex-1\">\n          <a\n            href={item.link}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"font-medium text-foreground hover:text-primary text-sm line-clamp-2\"\n          >\n            {item.title}\n          </a>\n          {item.source && (\n            <div className=\"mt-1 text-xs text-muted-foreground\">\n              {item.source}\n            </div>\n          )}\n        </div>\n      </div>\n    ))}\n  </div>\n);\n\n/**\n * Renders web_search results\n * Shows search result chunks with source information\n */\nconst WebSearchResults = ({ result }: { result: WebSearchResult }) => (\n  <div className=\"my-2 space-y-2\">\n    {result.chunk.chunks.map((chunk, idx) => (\n      <div\n        key={`${result.requestId}-${idx}`}\n        className=\"rounded-md border border-border/40 bg-card/20 px-3 py-2 hover:bg-card/40\"\n      >\n        {/* Title row with metadata */}\n        <div className=\"flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-2\">\n          <a\n            href={chunk.url}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex-1 font-medium text-foreground hover:text-primary text-sm line-clamp-1\"\n          >\n            {chunk.title}\n          </a>\n          {chunk.date && (\n            <span className=\"shrink-0 text-xs text-muted-foreground\">\n              {chunk.date}\n            </span>\n          )}\n        </div>\n\n        {/* Site name and labels */}\n        {(chunk.siteName || (chunk.labels && chunk.labels.length > 0)) && (\n          <div className=\"mt-0.5 flex items-center gap-1.5\">\n            {chunk.siteName && (\n              <span className=\"text-xs text-muted-foreground\">\n                {chunk.siteName}\n              </span>\n            )}\n            {chunk.labels?.map((label) => (\n              <span\n                key={`${result.requestId}-${idx}-${label.type}-${label.text}`}\n                className=\"inline-flex items-center gap-0.5 rounded bg-muted/60 px-1 py-0.5 text-[10px]\"\n                title={label.hover}\n              >\n                {label.icon && (\n                  // biome-ignore lint/correctness/useImageSize: Small icon with fixed size\n                  <img src={label.icon} alt=\"\" className=\"h-2.5 w-2.5\" />\n                )}\n                {label.text}\n              </span>\n            ))}\n          </div>\n        )}\n\n        {/* Content snippet */}\n        <p className=\"mt-1 text-xs text-muted-foreground line-clamp-2\">\n          {chunk.text}\n        </p>\n      </div>\n    ))}\n  </div>\n);\n\n/**\n * Renders search_response results from backend search tool\n * Shows search result chunks with page metadata\n */\nconst SearchResponseResults = ({\n  result,\n}: {\n  result: SearchResponseResult;\n}) => (\n  <div className=\"my-2 space-y-2\">\n    {result.chunkResult.chunks.map((chunk) => (\n      <div\n        key={chunk.id}\n        className=\"rounded-md border border-border/40 bg-card/20 px-3 py-2 hover:bg-card/40\"\n      >\n        {/* Title row with site icon */}\n        <div className=\"flex items-center gap-2\">\n          {chunk.page.siteIcon && (\n            // biome-ignore lint/correctness/useImageSize: Small icon with fixed size\n            <img\n              src={chunk.page.siteIcon}\n              alt=\"\"\n              className=\"h-4 w-4 shrink-0 rounded-sm\"\n            />\n          )}\n          <a\n            href={chunk.page.url}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex-1 font-medium text-foreground hover:text-primary text-sm line-clamp-1\"\n          >\n            {chunk.page.title}\n          </a>\n        </div>\n\n        {/* Site name */}\n        {chunk.page.siteName && (\n          <div className=\"mt-0.5 text-xs text-muted-foreground\">\n            {chunk.page.siteName}\n          </div>\n        )}\n\n        {/* Content snippet */}\n        <p className=\"mt-1 text-xs text-muted-foreground line-clamp-3\">\n          {chunk.text}\n        </p>\n      </div>\n    ))}\n  </div>\n);\n\n/**\n * Type for diff display item (from DiffDisplayBlock)\n */\ntype DiffDisplayData = {\n  type: \"diff\";\n  path: string;\n  old_text: string;\n  new_text: string;\n};\n\n/**\n * Type guard for diff display data\n */\nfunction isDiffDisplayData(data: unknown): data is DiffDisplayData {\n  if (typeof data !== \"object\" || data === null) {\n    return false;\n  }\n  const record = data as Record<string, unknown>;\n  return (\n    \"old_text\" in record &&\n    \"new_text\" in record &&\n    typeof record.old_text === \"string\" &&\n    typeof record.new_text === \"string\"\n  );\n}\n\n/**\n * Convert structuredPatch output to Diff component format\n */\ntype StructuredPatch = typeof import(\"diff\").structuredPatch;\n\nlet diffModulePromise: Promise<typeof import(\"diff\")> | null = null;\n\nconst loadDiffModule = async (): Promise<typeof import(\"diff\")> => {\n  if (!diffModulePromise) {\n    diffModulePromise = import(\"diff\");\n  }\n  return diffModulePromise;\n};\n\nfunction convertPatchToFile(\n  structuredPatch: StructuredPatch,\n  oldStr: string,\n  newStr: string,\n): File | null {\n  const patch = structuredPatch(\"file\", \"file\", oldStr, newStr, \"\", \"\");\n\n  if (patch.hunks.length === 0) {\n    return null;\n  }\n\n  const hunks: HunkType[] = patch.hunks.map((hunk) => {\n    const lines: Line[] = [];\n    let oldLineNumber = hunk.oldStart;\n    let newLineNumber = hunk.newStart;\n\n    for (const line of hunk.lines) {\n      // Skip \"no newline\" markers\n      if (line.startsWith(\"\\\\\")) {\n        continue;\n      }\n\n      const prefix = line[0];\n      const content = line.slice(1);\n\n      if (prefix === \" \") {\n        lines.push({\n          type: \"normal\",\n          isNormal: true,\n          oldLineNumber,\n          newLineNumber,\n          content: [{ value: content, type: \"normal\" }],\n        });\n        oldLineNumber += 1;\n        newLineNumber += 1;\n      } else if (prefix === \"-\") {\n        lines.push({\n          type: \"delete\",\n          isDelete: true,\n          lineNumber: oldLineNumber,\n          content: [{ value: content, type: \"delete\" }],\n        });\n        oldLineNumber += 1;\n      } else if (prefix === \"+\") {\n        lines.push({\n          type: \"insert\",\n          isInsert: true,\n          lineNumber: newLineNumber,\n          content: [{ value: content, type: \"insert\" }],\n        });\n        newLineNumber += 1;\n      }\n    }\n\n    return {\n      type: \"hunk\" as const,\n      content: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,\n      oldStart: hunk.oldStart,\n      oldLines: hunk.oldLines,\n      newStart: hunk.newStart,\n      newLines: hunk.newLines,\n      lines,\n    };\n  });\n\n  return {\n    hunks,\n    oldPath: \"file\",\n    newPath: \"file\",\n    type: \"modify\",\n    oldEndingNewLine: true,\n    newEndingNewLine: true,\n    oldMode: \"\",\n    newMode: \"\",\n    oldRevision: \"\",\n    newRevision: \"\",\n  };\n}\n\n/**\n * Renders diff content\n */\nconst DiffContent = ({ data }: { data: DiffDisplayData }) => {\n  const [file, setFile] = useState<File | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n  const [isExpanded, setIsExpanded] = useState(false);\n\n  const { old_text: oldText, new_text: newText, path: filePath } = data;\n\n  useEffect(() => {\n    let cancelled = false;\n    setIsLoading(true);\n    setFile(null);\n\n    const loadDiff = async (): Promise<void> => {\n      const { structuredPatch } = await loadDiffModule();\n      const nextFile = convertPatchToFile(structuredPatch, oldText, newText);\n      if (!cancelled) {\n        setFile(nextFile);\n        setIsLoading(false);\n      }\n    };\n\n    loadDiff().catch((error: unknown) => {\n      console.error(\"[DiffContent] Failed to load diff:\", error);\n      if (!cancelled) {\n        setIsLoading(false);\n      }\n    });\n\n    return () => {\n      cancelled = true;\n    };\n  }, [newText, oldText]);\n\n  // Calculate added/removed line counts and create truncated hunks\n  const { addedLines, removedLines, totalLines, truncatedHunks } = useMemo(() => {\n    if (!file) {\n      return { addedLines: 0, removedLines: 0, totalLines: 0, truncatedHunks: [] as HunkType[] };\n    }\n\n    let added = 0;\n    let removed = 0;\n    let total = 0;\n    const maxCollapsedLines = 5;\n\n    // Count lines\n    for (const hunk of file.hunks) {\n      if (hunk.type === \"hunk\") {\n        for (const line of hunk.lines) {\n          total += 1;\n          if (line.type === \"insert\") {\n            added += 1;\n          } else if (line.type === \"delete\") {\n            removed += 1;\n          }\n        }\n      }\n    }\n\n    // Create truncated hunks for collapsed view\n    const truncated: HunkType[] = [];\n    let lineCount = 0;\n\n    for (const hunk of file.hunks) {\n      if (hunk.type !== \"hunk\") continue;\n      if (lineCount >= maxCollapsedLines) break;\n\n      const remainingLines = maxCollapsedLines - lineCount;\n      const linesToTake = Math.min(hunk.lines.length, remainingLines);\n\n      if (linesToTake > 0) {\n        truncated.push({\n          ...hunk,\n          lines: hunk.lines.slice(0, linesToTake),\n        });\n        lineCount += linesToTake;\n      }\n    }\n\n    return { addedLines: added, removedLines: removed, totalLines: total, truncatedHunks: truncated };\n  }, [file]);\n\n  const maxCollapsedLines = 5;\n  const hasMoreLines = totalLines > maxCollapsedLines;\n  const displayHunks = isExpanded ? file?.hunks ?? [] : truncatedHunks;\n\n  if (isLoading) {\n    return (\n      <div className=\"my-2 rounded-md border border-border/40 bg-card/20 px-4 py-6 text-center text-sm text-muted-foreground\">\n        Loading diff...\n      </div>\n    );\n  }\n\n  if (!file || totalLines === 0) {\n    // Return a minimal placeholder to avoid zero-height elements in virtualized lists\n    return (\n      <div className=\"my-2 rounded-md border border-border/40 bg-card/20 px-3 py-1.5 text-xs font-mono text-muted-foreground\">\n        {filePath || \"No changes\"}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        \"my-2 overflow-hidden rounded-md border border-border/40 bg-card/20\",\n        // Hide line numbers column - our old/new data is just a snippet, not full file\n        \"[&_td:nth-child(2)]:hidden\",\n      )}\n    >\n      {/* Title bar with file path and stats */}\n      {hasMoreLines ? (\n        <button\n          type=\"button\"\n          className={cn(\n            \"flex w-full items-center justify-between gap-2 border-b border-border/40 bg-muted/30 px-3 py-1.5 text-left\",\n            \"cursor-pointer hover:bg-muted/50\",\n          )}\n          onClick={() => setIsExpanded(!isExpanded)}\n          aria-expanded={isExpanded}\n        >\n          <span className=\"text-xs font-mono text-muted-foreground truncate flex-1\">\n            {filePath || \"diff\"}\n          </span>\n          <div className=\"flex items-center gap-2 shrink-0\">\n            {addedLines > 0 && (\n              <span className=\"text-xs text-green-600 dark:text-green-400\">+{addedLines}</span>\n            )}\n            {removedLines > 0 && (\n              <span className=\"text-xs text-orange-600 dark:text-orange-400\">-{removedLines}</span>\n            )}\n            <ChevronDownIcon\n              className={cn(\n                \"size-3 text-muted-foreground transition-transform duration-200\",\n                isExpanded && \"rotate-180\",\n              )}\n            />\n          </div>\n        </button>\n      ) : (\n        <div className=\"flex items-center justify-between gap-2 border-b border-border/40 bg-muted/30 px-3 py-1.5\">\n          <span className=\"text-xs font-mono text-muted-foreground truncate flex-1\">\n            {filePath || \"diff\"}\n          </span>\n          <div className=\"flex items-center gap-2 shrink-0\">\n            {addedLines > 0 && (\n              <span className=\"text-xs text-green-600 dark:text-green-400\">+{addedLines}</span>\n            )}\n            {removedLines > 0 && (\n              <span className=\"text-xs text-orange-600 dark:text-orange-400\">-{removedLines}</span>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Diff content */}\n      <Suspense\n        fallback={\n          <div className=\"px-4 py-6 text-center text-sm text-muted-foreground\">\n            Loading diff...\n          </div>\n        }\n      >\n        <Diff hunks={displayHunks} type={file.type}>\n          {displayHunks.map((hunk) => (\n            <Hunk key={hunk.content} hunk={hunk} />\n          ))}\n        </Diff>\n      </Suspense>\n\n      {/* Show more indicator when collapsed */}\n      {!isExpanded && hasMoreLines && (\n        <button\n          type=\"button\"\n          className=\"w-full border-t border-border/40 bg-muted/20 px-3 py-1.5 text-center text-xs text-muted-foreground cursor-pointer hover:bg-muted/40\"\n          onClick={() => setIsExpanded(true)}\n        >\n          Show {totalLines - maxCollapsedLines} more lines...\n        </button>\n      )}\n    </div>\n  );\n};\n\n/**\n * Renders JSON content\n */\nconst JSONContent = ({ data }: { data: unknown }) => (\n  <div className=\"my-2 rounded-md bg-muted/40 p-3 text-xs\">\n    <pre className=\"whitespace-pre-wrap font-mono\">\n      {JSON.stringify(data, null, 2)}\n    </pre>\n  </div>\n);\n\n/**\n * Renders plain text content\n */\nconst PlainTextContent = ({ text }: { text: string }) => (\n  <div className=\"my-2 rounded-md bg-muted/40 p-3 text-sm\">\n    <pre className=\"whitespace-pre-wrap font-mono text-xs\">{text}</pre>\n  </div>\n);\n\n/**\n * Renders image resource with blob data\n */\nconst ImageBlobResource = ({\n  blob,\n  mimeType,\n  filename,\n  uri,\n}: {\n  blob: string;\n  mimeType: string;\n  filename: string;\n  uri?: string;\n}) => {\n  const [error, setError] = useState(false);\n  return (\n    <div className=\"my-2\">\n      {uri && (\n        <div className=\"mb-1 text-xs text-muted-foreground\">\n          Generated: {filename}\n        </div>\n      )}\n      {error ? (\n        <div className=\"flex items-center gap-2 rounded-md border border-border/40 bg-muted px-3 py-2 text-xs text-muted-foreground\">\n          Failed to load image: {filename}\n        </div>\n      ) : (\n        /* biome-ignore lint/correctness/useImageSize: Dynamic image with unknown dimensions */\n        /* biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError is a lifecycle event */\n        <img\n          src={`data:${mimeType};base64,${blob}`}\n          alt={filename}\n          className=\"h-auto max-w-full overflow-hidden rounded-md border border-border/40\"\n          onError={() => setError(true)}\n        />\n      )}\n    </div>\n  );\n};\n\n/**\n * Renders image resource with URI only\n */\nconst ImageURIResource = ({\n  uri,\n  filename,\n}: {\n  uri: string;\n  filename: string;\n}) => (\n  <div className=\"my-2 rounded-md bg-muted/40 p-3 text-sm\">\n    <div className=\"font-medium text-foreground\">Generated image</div>\n    <div className=\"mt-1 text-xs text-muted-foreground\">File: {filename}</div>\n    <div className=\"mt-1 break-all font-mono text-xs opacity-70\">{uri}</div>\n  </div>\n);\n\n/**\n * Renders generic resource info\n */\nconst GenericResource = ({\n  uri,\n  mimeType,\n}: {\n  uri: string;\n  mimeType?: string;\n}) => (\n  <div className=\"my-2 rounded-md bg-muted/40 p-3 text-sm text-muted-foreground\">\n    <div className=\"font-medium\">Resource:</div>\n    <div className=\"mt-1 break-all font-mono text-xs\">{uri}</div>\n    {mimeType && (\n      <div className=\"mt-1 text-xs opacity-70\">Type: {mimeType}</div>\n    )}\n  </div>\n);\n\n/**\n * Type guard for WebSearchResult\n */\nfunction isWebSearchResult(data: unknown): data is WebSearchResult {\n  if (typeof data !== \"object\" || data === null) {\n    return false;\n  }\n\n  const record = data as Record<string, unknown>;\n  if (!(\"requestId\" in record && \"chunk\" in record)) {\n    return false;\n  }\n\n  const { chunk } = record;\n  if (typeof chunk !== \"object\" || chunk === null) {\n    return false;\n  }\n\n  const chunkRecord = chunk as Record<string, unknown>;\n  return \"chunks\" in chunkRecord && Array.isArray(chunkRecord.chunks);\n}\n\n/**\n * Type guard for ImageSearchByTextResult\n */\nfunction isImageSearchByTextResult(\n  data: unknown,\n): data is ImageSearchByTextResult {\n  if (typeof data !== \"object\" || data === null) {\n    return false;\n  }\n\n  const record = data as Record<string, unknown>;\n  return (\n    \"requestId\" in record && \"images\" in record && Array.isArray(record.images)\n  );\n}\n\n/**\n * Type guard for ImageSearchByImageResult array\n */\nfunction isImageSearchByImageResult(\n  data: unknown,\n): data is ImageSearchByImageResult[] {\n  if (!Array.isArray(data) || data.length === 0) {\n    return false;\n  }\n\n  const first = data[0];\n  return typeof first === \"object\" && first !== null && \"imageUrl\" in first;\n}\n\n/**\n * Type guard for SearchResponseResult\n * Handles both direct format and nested { data: ... } wrapper\n */\nfunction isSearchResponseResult(data: unknown): data is SearchResponseResult {\n  if (typeof data !== \"object\" || data === null) {\n    return false;\n  }\n\n  const record = data as Record<string, unknown>;\n\n  // Check for nested { data: ... } wrapper\n  const innerData =\n    \"data\" in record && typeof record.data === \"object\" && record.data !== null\n      ? (record.data as Record<string, unknown>)\n      : record;\n\n  if (!(\"requestId\" in innerData && \"chunkResult\" in innerData)) {\n    return false;\n  }\n\n  const { chunkResult } = innerData;\n  if (typeof chunkResult !== \"object\" || chunkResult === null) {\n    return false;\n  }\n\n  const chunkRecord = chunkResult as Record<string, unknown>;\n  return \"chunks\" in chunkRecord && Array.isArray(chunkRecord.chunks);\n}\n\n/**\n * Unwrap SearchResponseResult from potential { data: ... } wrapper\n */\nfunction unwrapSearchResponseResult(data: unknown): SearchResponseResult {\n  const record = data as Record<string, unknown>;\n  if (\n    \"data\" in record &&\n    typeof record.data === \"object\" &&\n    record.data !== null &&\n    \"chunkResult\" in (record.data as Record<string, unknown>)\n  ) {\n    return record.data as SearchResponseResult;\n  }\n  return data as SearchResponseResult;\n}\n\n/**\n * Renders text content (search results, JSON, or plain text)\n */\nconst TextContent = ({ content }: { content: MCPTextData }) => {\n  try {\n    const parsed = JSON.parse(content.text);\n\n    if (isWebSearchResult(parsed)) {\n      return <WebSearchResults result={parsed} />;\n    }\n\n    if (isImageSearchByTextResult(parsed)) {\n      return <ImageSearchByTextResults result={parsed} />;\n    }\n\n    if (isImageSearchByImageResult(parsed)) {\n      return <ImageSearchByImageResults items={parsed} />;\n    }\n\n    if (isDiffDisplayData(parsed)) {\n      return <DiffContent data={parsed} />;\n    }\n\n    return <JSONContent data={parsed} />;\n  } catch {\n    return <PlainTextContent text={content.text} />;\n  }\n};\n\n/**\n * Renders resource content (images, files, etc.)\n */\nconst ResourceContent = ({ content }: { content: MCPResourceData }) => {\n  const { uri, mimeType, blob, text } = content.resource;\n\n  // Handle image resources\n  if (mimeType?.startsWith(\"image/\")) {\n    if (blob) {\n      const filename = uri?.split(\"/\").pop() || \"Generated image\";\n      return (\n        <ImageBlobResource\n          blob={blob}\n          mimeType={mimeType}\n          filename={filename}\n          uri={uri}\n        />\n      );\n    }\n\n    if (uri) {\n      const filename = uri.split(\"/\").pop() || \"image\";\n      return <ImageURIResource uri={uri} filename={filename} />;\n    }\n  }\n\n  // Handle text resources\n  if (mimeType?.startsWith(\"text/\") && text) {\n    return <PlainTextContent text={text} />;\n  }\n\n  // Fallback: show URI or generic message\n  if (uri) {\n    return <GenericResource uri={uri} mimeType={mimeType} />;\n  }\n\n  return null;\n};\n\n/**\n * Renders a base64 image\n */\nconst ImageContent = ({ content }: { content: MCPImageData }) => {\n  const [error, setError] = useState(false);\n  const mimeType = content.mimeType || \"image/png\";\n  return (\n    <div className=\"my-2\">\n      {error ? (\n        <div className=\"flex items-center gap-2 rounded-md border border-border/40 bg-muted px-3 py-2 text-xs text-muted-foreground\">\n          Failed to load image\n        </div>\n      ) : (\n        /* biome-ignore lint/correctness/useImageSize: Dynamic image with unknown dimensions */\n        /* biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError is a lifecycle event */\n        <img\n          src={`data:${mimeType};base64,${content.data}`}\n          alt=\"Generated output\"\n          className=\"h-auto max-w-full overflow-hidden rounded-md border border-border/40\"\n          onError={() => setError(true)}\n        />\n      )}\n    </div>\n  );\n};\n\n/**\n * Type guard for nested MCP data\n */\nfunction isNestedData(data: unknown): data is MCPNestedData {\n  if (typeof data !== \"object\" || data === null) {\n    return false;\n  }\n  const record = data as Record<string, unknown>;\n  return (\n    \"data\" in record && typeof record.data === \"object\" && record.data !== null\n  );\n}\n\n/**\n * Type guard for image data\n */\nfunction isImageData(data: unknown): data is MCPImageData {\n  if (typeof data !== \"object\" || data === null) {\n    return false;\n  }\n  const record = data as Record<string, unknown>;\n  return record.type === \"image\" && typeof record.data === \"string\";\n}\n\n/**\n * Renders MCP content (resources, text, search results, images, etc.)\n */\nconst MCPContentResource = ({ data }: { data: unknown }) => {\n  // Handle nested data structure (e.g., from ipython tool)\n  // Format: { data: { type: \"image\", data: \"base64...\" } }\n  if (isNestedData(data)) {\n    return <MCPContentResource data={data.data} />;\n  }\n\n  // Handle image data\n  if (isImageData(data)) {\n    return <ImageContent content={data} />;\n  }\n\n  const content = data as MCPContentData;\n\n  if (content.type === \"text\") {\n    return <TextContent content={content as MCPTextData} />;\n  }\n\n  if (content.type === \"resource\") {\n    return <ResourceContent content={content as MCPResourceData} />;\n  }\n\n  return null;\n};\n\n/**\n * Shell display block data\n * Backend format: { type: \"shell\", language: \"bash\", command: \"ls -la\" }\n */\ntype ShellDisplayData = {\n  language?: string;\n  command: string;\n};\n\n/**\n * Renders a shell command with syntax highlighting\n */\nconst ShellContent = ({ data }: { data: ShellDisplayData }) => (\n  <CodeBlock\n    code={data.command}\n    language={data.language || \"bash\"}\n  />\n);\n\n/**\n * Todo display block data\n * Backend format: { type: \"todo\", items: [{ status: \"completed\", content: \"...\" }] }\n */\ntype TodoItem = {\n  status: \"completed\" | \"done\" | \"in_progress\" | \"pending\" | string;\n  content?: string;\n  title?: string;\n};\n\ntype TodoDisplayData = {\n  items: TodoItem[];\n};\n\nconst isTodoDone = (status: string): boolean =>\n  status === \"completed\" || status === \"done\";\n\n/**\n * Renders a todo/task list with status icons\n */\nconst TodoContent = ({ data }: { data: TodoDisplayData }) => (\n  <div className=\"my-2 divide-y divide-border/40 rounded-md border border-border/50 text-sm\">\n    {data.items.map((item, index) => {\n      const text = item.content || item.title || \"\";\n      const done = isTodoDone(item.status);\n      const inProgress = item.status === \"in_progress\";\n      return (\n        <div\n          key={`${index}-${text}`}\n          className=\"flex items-start gap-2.5 px-3 py-2\"\n        >\n          <span className=\"mt-0.5 shrink-0\">\n            {done ? (\n              <CircleCheckIcon className=\"size-3.5 text-muted-foreground\" />\n            ) : inProgress ? (\n              <CircleDashedIcon className=\"size-3.5 text-muted-foreground/60\" />\n            ) : (\n              <CircleIcon className=\"size-3.5 text-muted-foreground/30\" />\n            )}\n          </span>\n          <span\n            className={cn(\n              done &&\n                \"text-muted-foreground line-through decoration-muted-foreground/40\",\n              !(done || inProgress) && \"text-muted-foreground/70\",\n            )}\n          >\n            {text}\n          </span>\n        </div>\n      );\n    })}\n  </div>\n);\n\n/** Renders a direct image URL with error fallback */\nconst DirectImage = ({ src }: { src: string }) => {\n  const [error, setError] = useState(false);\n  return (\n    <div className=\"my-2\">\n      {error ? (\n        <div className=\"flex items-center gap-2 rounded-md border border-border/40 bg-muted px-3 py-2 text-xs text-muted-foreground\">\n          Failed to load image\n        </div>\n      ) : (\n        /* biome-ignore lint/correctness/useImageSize: Dynamic image with unknown dimensions */\n        /* biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError is a lifecycle event */\n        <img\n          src={src}\n          alt=\"Generated content\"\n          className=\"h-auto max-w-full overflow-hidden rounded-md border border-border/40\"\n          onError={() => setError(true)}\n        />\n      )}\n    </div>\n  );\n};\n\n/**\n * Renders a single display item\n */\nconst DisplayItemRenderer = ({ item }: { item: DisplayItem }) => {\n  switch (item.type) {\n    case \"mcp_content\":\n      return <MCPContentResource data={item.data} />;\n\n    // Add more display types here as needed\n    case \"image\":\n      return <DirectImage src={String(item.data)} />;\n\n    case \"search_response\":\n      // Handle search response from backend search tool\n      if (isSearchResponseResult(item.data)) {\n        return (\n          <SearchResponseResults\n            result={unwrapSearchResponseResult(item.data)}\n          />\n        );\n      }\n      // Fallback to default renderer\n      return (\n        <div className=\"my-2 rounded-md bg-muted/40 p-3 text-xs\">\n          <div className=\"mb-1 font-medium text-muted-foreground\">\n            Display type: {item.type}\n          </div>\n          <pre className=\"whitespace-pre-wrap font-mono text-xs opacity-70\">\n            {JSON.stringify(item.data, null, 2)}\n          </pre>\n        </div>\n      );\n\n    case \"diff\":\n      // Handle diff display from edit_file tool\n      // item itself contains old_text/new_text/path (DiffDisplayBlock from backend)\n      if (isDiffDisplayData(item)) {\n        return <DiffContent data={item} />;\n      }\n      // Fallback to default renderer\n      return (\n        <div className=\"my-2 rounded-md bg-muted/40 p-3 text-xs\">\n          <div className=\"mb-1 font-medium text-muted-foreground\">\n            Display type: {item.type}\n          </div>\n          <pre className=\"whitespace-pre-wrap font-mono text-xs opacity-70\">\n            {JSON.stringify(item.data, null, 2)}\n          </pre>\n        </div>\n      );\n\n    case \"shell\": {\n      // Fallback: some backends nest payload in `item.data`, others put fields directly on `item`\n      const shellData = (item.data ?? item) as unknown as ShellDisplayData;\n      if (shellData.command) {\n        return <ShellContent data={shellData} />;\n      }\n      return <JSONContent data={item.data} />;\n    }\n\n    case \"todo\": {\n      // Fallback: some backends nest payload in `item.data`, others put fields directly on `item`\n      const todoData = (item.data ?? item) as unknown as TodoDisplayData;\n      if (todoData.items && Array.isArray(todoData.items)) {\n        return <TodoContent data={todoData} />;\n      }\n      return <JSONContent data={item.data} />;\n    }\n\n    case \"brief\": {\n      // Handle brief status message (short text shown to user)\n      // Backend sends { type: \"brief\", text: \"message\" } (BriefDisplayBlock from kosong)\n      // Note: color is inherited from parent (ToolDisplay sets text-destructive when isError)\n      const briefItem = item as unknown as { type: string; text?: string; data?: unknown };\n      const briefText = briefItem.text ?? String(briefItem.data ?? \"\");\n      return (\n        <pre className=\"whitespace-pre-wrap text-xs\">\n          {briefText}\n        </pre>\n      );\n    }\n\n    default:\n      // Fallback: render as JSON\n      return (\n        <div className=\"my-2 rounded-md bg-muted/40 p-3 text-xs\">\n          <div className=\"mb-1 font-medium text-muted-foreground\">\n            Display type: {item.type}\n          </div>\n          <pre className=\"whitespace-pre-wrap font-mono text-xs opacity-70\">\n            {JSON.stringify(item.data, null, 2)}\n          </pre>\n        </div>\n      );\n  }\n};\n\n/**\n * Component for rendering tool display content\n * Handles various display types including MCP resources (images, etc.)\n */\nexport const DisplayContent = ({\n  className,\n  display,\n  ...props\n}: DisplayContentProps) => {\n  if (!display || display.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className={cn(\"space-y-2\", className)} {...props}>\n      {display.map((item, index) => (\n        <DisplayItemRenderer key={`${item.type}-${index}`} item={item} />\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "web/src/features/tool/store.ts",
    "content": "import { create } from \"zustand\";\n\nexport type TodoItem = {\n  title: string;\n  status: \"pending\" | \"in_progress\" | \"done\";\n};\n\ntype ToolEventsState = {\n  /** Files written during the current session/turn */\n  newFiles: string[];\n\n  /** Add a file path when WriteFile completes successfully */\n  addNewFile: (path: string) => void;\n  /** Clear all new files (e.g., when opening files panel or starting new turn) */\n  clearNewFiles: () => void;\n\n  /** Current todo list from SetTodoList tool */\n  todoItems: TodoItem[];\n  setTodoItems: (items: TodoItem[]) => void;\n  clearTodoItems: () => void;\n};\n\nexport const useToolEventsStore = create<ToolEventsState>((set) => ({\n  newFiles: [],\n  addNewFile: (path) =>\n    set((state) => ({\n      newFiles: [...state.newFiles, path],\n    })),\n  clearNewFiles: () => set({ newFiles: [] }),\n\n  todoItems: [],\n  setTodoItems: (items) => set({ todoItems: items }),\n  clearTodoItems: () => set({ todoItems: [] }),\n}));\n\n/**\n * Handle tool result events and update store accordingly.\n * Call this from useSessionStream when a ToolResult event is received.\n *\n * @param isReplay - If true, this is a replay of history, skip notifications\n */\nexport function handleToolResult(\n  toolName: string,\n  toolArguments: string,\n  isError: boolean,\n  isReplay: boolean,\n) {\n  if (isError || isReplay) return;\n\n  try {\n    const args = JSON.parse(toolArguments);\n    const { addNewFile } = useToolEventsStore.getState();\n\n    // WriteFile: track by tool name (path/file_path is too generic)\n    const lower = toolName.toLowerCase();\n    if (\n      lower.includes(\"writefile\") ||\n      lower.includes(\"write-file\") ||\n      lower.includes(\"write_file\")\n    ) {\n      const filePath = args.path || args.file_path;\n      if (filePath) {\n        addNewFile(filePath);\n      }\n    }\n\n    // Generic output parameters - these always indicate file creation\n    if (args.output_file) addNewFile(args.output_file);\n    if (args.output_path) addNewFile(args.output_path);\n    if (args.download_dir) addNewFile(args.download_dir);\n  } catch {\n    // Ignore parse errors\n  }\n}\n"
  },
  {
    "path": "web/src/hooks/types.ts",
    "content": "import type { ChatStatus, FileUIPart, ToolUIPart } from \"ai\";\nimport type { QuestionItem } from \"./wireTypes\";\n\nexport type NoPreviewAttachment = {\n  kind: \"nopreview\";\n  filename: string;\n};\n\nexport type VideoNoPreviewAttachment = {\n  kind: \"video-nopreview\";\n  mediaType: string;\n  filename: string;\n};\n\nexport type MessageAttachmentPart = FileUIPart | NoPreviewAttachment | VideoNoPreviewAttachment;\n\n// Re-export API types for convenience\nexport type { Session } from \"../lib/api/models\";\n\n/**\n * A single step recorded from a subagent's activity.\n * Accumulated as SubagentEvents arrive and rendered inside the parent Task tool call.\n */\nexport type SubagentStep =\n  | { kind: \"thinking\"; text: string }\n  | { kind: \"text\"; text: string }\n  | {\n      kind: \"tool-call\";\n      toolCallId: string;\n      toolName: string;\n      /** Raw accumulated arguments string (for streaming ToolCallPart) */\n      rawArgs?: string;\n      input?: unknown;\n      status: \"running\" | \"success\" | \"error\";\n      output?: string;\n      errorText?: string;\n    };\n\n/**\n * Live message in the chat - this is a UI-specific type\n * that extends beyond what the API provides\n */\nexport type LiveMessage = {\n  /** Unique identifier for this UI message (React key) */\n  id: string;\n  /** Backend message ID from StatusUpdate event (identifies the turn) */\n  messageId?: string;\n  /** 0-based turn index, set on user messages at TurnBegin */\n  turnIndex?: number;\n  role: \"user\" | \"assistant\";\n  content?: string;\n  attachments?: MessageAttachmentPart[];\n  isStreaming?: boolean;\n  variant?:\n    | \"text\"\n    | \"chain-of-thought\"\n    | \"tool\"\n    | \"code\"\n    | \"thinking\"\n    | \"message-id\"\n    | \"status\";\n  /** Thinking/reasoning content from the model */\n  thinking?: string;\n  /** Duration of thinking in seconds */\n  thinkingDuration?: number;\n  chainOfThought?: {\n    title: string;\n    steps: {\n      label: string;\n      description: string;\n    }[];\n    revealedSteps: number;\n    relatedSources?: string[];\n  };\n  toolCall?: {\n    title: string;\n    type: ToolUIPart[\"type\"];\n    state:\n      | ToolUIPart[\"state\"]\n      | \"approval-requested\"\n      | \"approval-responded\"\n      | \"question-requested\"\n      | \"question-responded\"\n      | \"output-denied\";\n    input?: ToolUIPart[\"input\"];\n    /** Tool call ID for tracking */\n    toolCallId?: string;\n    /**\n     * Tool result fields (aligned with backend ToolReturnValue)\n     * @see kosong.tooling.ToolReturnValue\n     */\n    /** The output content returned by the tool (for model) */\n    output?: string;\n    /** An explanatory message to be given to the model */\n    message?: string;\n    /** Content blocks to be displayed to the user */\n    display?: Array<{ type: string; data: unknown }>;\n    /** Extra debugging/testing data */\n    extras?: Record<string, unknown>;\n    /** Whether the tool call resulted in an error */\n    isError?: boolean;\n    /** Error text for display (derived from message when isError) */\n    errorText?: string;\n    /** Media parts extracted from tool output (images/videos from ReadMediaFile etc.) */\n    mediaParts?: Array<{ type: \"image_url\" | \"video_url\"; url: string }>;\n    approval?: {\n      id: string;\n      action: string;\n      description: string;\n      sender: string;\n      toolCallId?: string;\n      submitted?: boolean;\n      resolved?: boolean;\n      approved?: boolean;\n      reason?: string;\n      response?: unknown;\n    };\n    question?: {\n      id: string;\n      toolCallId: string;\n      questions: QuestionItem[];\n      rpcMessageId?: string | number;\n      submitted?: boolean;\n      resolved?: boolean;\n      answers?: Record<string, string>;\n    };\n    /** Steps from a subagent (Task tool) — populated by SubagentEvent processing */\n    subagentSteps?: SubagentStep[];\n    /** Whether the subagent is still actively running */\n    subagentRunning?: boolean;\n  };\n  codeSnippet?: {\n    title: string;\n    code: string;\n    language: string;\n    description?: string;\n  };\n};\n\n/**\n * Session operations returned by useSessions\n * Uses API types: Session\n */\nexport type SessionOperations = {\n  sessions: import(\"../lib/api/models\").Session[];\n  selectedSessionId: string;\n  isLoading: boolean;\n  error: string | null;\n  refreshSessions: () => Promise<void>;\n  loadMoreSessions: () => Promise<void>;\n  hasMoreSessions: boolean;\n  isLoadingMore: boolean;\n  searchQuery: string;\n  setSearchQuery: (query: string) => void;\n  refreshSession: (\n    sessionId: string,\n  ) => Promise<import(\"../lib/api/models\").Session | null>;\n  createSession: () => Promise<import(\"../lib/api/models\").Session>;\n  deleteSession: (sessionId: string) => Promise<boolean>;\n  selectSession: (sessionId: string) => void;\n  applySessionStatus: (\n    status: import(\"../lib/api/models\").SessionStatus,\n  ) => void;\n  getRelativeTime: (session: import(\"../lib/api/models\").Session) => string;\n};\n\n/**\n * Chat operations\n */\nexport type ChatOperations = {\n  messages: LiveMessage[];\n  status: ChatStatus;\n  sendMessage: (text: string, attachments?: FileUIPart[]) => Promise<void>;\n  cancelStream: () => void;\n  clearMessages: () => void;\n};\n"
  },
  {
    "path": "web/src/hooks/use-theme.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport { flushSync } from \"react-dom\";\n\nexport type Theme = \"light\" | \"dark\";\n\nconst THEME_STORAGE_KEY = \"kimi-theme\";\nconst THEME_SWITCHING_ATTR = \"data-theme-switching\";\nconst THEME_SWITCH_DURATION_MS = 260;\n\ntype ThemeState = {\n  theme: Theme;\n  hasUserPreference: boolean;\n};\n\nexport type ThemeTransitionEvent = Pick<MouseEvent, \"clientX\" | \"clientY\">;\n\ntype ThemeTransitionPoint = {\n  x: number;\n  y: number;\n};\n\ntype UseThemeResult = {\n  theme: Theme;\n  setTheme: (next: Theme) => void;\n  toggleTheme: () => void;\n  toggleThemeWithTransition: (event?: ThemeTransitionEvent) => Promise<void>;\n};\n\nfunction getInitialTheme(): ThemeState {\n  if (typeof window === \"undefined\") {\n    return { theme: \"light\", hasUserPreference: false };\n  }\n\n  const stored = window.localStorage.getItem(THEME_STORAGE_KEY);\n  if (stored === \"light\" || stored === \"dark\") {\n    return { theme: stored, hasUserPreference: true };\n  }\n\n  const prefersDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n  return { theme: prefersDark ? \"dark\" : \"light\", hasUserPreference: false };\n}\n\nfunction getTransitionPoint(\n  event?: ThemeTransitionEvent,\n): ThemeTransitionPoint {\n  return {\n    x: event?.clientX ?? window.innerWidth / 2,\n    y: event?.clientY ?? window.innerHeight / 2,\n  };\n}\n\nfunction getMaxRadius(point: ThemeTransitionPoint): number {\n  const maxX = Math.max(point.x, window.innerWidth - point.x);\n  const maxY = Math.max(point.y, window.innerHeight - point.y);\n  return Math.hypot(maxX, maxY);\n}\n\nfunction startThemeSwitching(root: HTMLElement): void {\n  root.setAttribute(THEME_SWITCHING_ATTR, \"true\");\n}\n\nfunction stopThemeSwitching(root: HTMLElement): void {\n  root.removeAttribute(THEME_SWITCHING_ATTR);\n}\n\nfunction stopThemeSwitchingNextFrame(root: HTMLElement): void {\n  requestAnimationFrame(() => {\n    requestAnimationFrame(() => {\n      stopThemeSwitching(root);\n    });\n  });\n}\n\nexport function useTheme(): UseThemeResult {\n  const [state, setState] = useState<ThemeState>(() => getInitialTheme());\n  const { theme, hasUserPreference } = state;\n\n  // Apply theme to <html> and persist user preference\n  useEffect(() => {\n    if (typeof document === \"undefined\") return;\n    const root = document.documentElement;\n    root.classList.toggle(\"dark\", theme === \"dark\");\n    root.style.colorScheme = theme;\n\n    if (hasUserPreference) {\n      window.localStorage.setItem(THEME_STORAGE_KEY, theme);\n    } else {\n      window.localStorage.removeItem(THEME_STORAGE_KEY);\n    }\n  }, [theme, hasUserPreference]);\n\n  // Sync with system preference only when the user has no explicit choice\n  useEffect(() => {\n    if (typeof window === \"undefined\") return;\n    const media = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    const handleChange = (event: MediaQueryListEvent) => {\n      setState((prev) => {\n        if (prev.hasUserPreference) return prev;\n        return {\n          theme: event.matches ? \"dark\" : \"light\",\n          hasUserPreference: false,\n        };\n      });\n    };\n\n    media.addEventListener(\"change\", handleChange);\n    return () => media.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  const setTheme = useCallback((next: Theme) => {\n    setState({ theme: next, hasUserPreference: true });\n  }, []);\n\n  const toggleTheme = useCallback(() => {\n    setState((prev) => ({\n      theme: prev.theme === \"dark\" ? \"light\" : \"dark\",\n      hasUserPreference: true,\n    }));\n  }, []);\n\n  const toggleThemeWithTransition = useCallback(\n    async (event?: ThemeTransitionEvent) => {\n      const canUseViewTransition =\n        typeof document !== \"undefined\" &&\n        typeof window !== \"undefined\" &&\n        typeof document.startViewTransition === \"function\" &&\n        !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n      if (!canUseViewTransition) {\n        if (typeof document !== \"undefined\") {\n          const root = document.documentElement;\n          startThemeSwitching(root);\n          flushSync(() => {\n            toggleTheme();\n          });\n          stopThemeSwitchingNextFrame(root);\n        } else {\n          toggleTheme();\n        }\n        return;\n      }\n\n      const root = document.documentElement;\n      startThemeSwitching(root);\n\n      const point = getTransitionPoint(\n        event ? { clientX: event.clientX, clientY: event.clientY } : undefined,\n      );\n      const isDark = root.classList.contains(\"dark\");\n      const radius = getMaxRadius(point);\n      const start = `circle(0px at ${point.x}px ${point.y}px)`;\n      const end = `circle(${radius}px at ${point.x}px ${point.y}px)`;\n\n      const transition = document.startViewTransition(() => {\n        flushSync(() => {\n          toggleTheme();\n        });\n      });\n\n      await transition.ready;\n\n      // Light→Dark: animate OLD (light) shrinking to reveal dark underneath\n      // Dark→Light: animate NEW (light) expanding to cover dark\n      const pseudoElement = isDark\n        ? \"::view-transition-new(root)\"\n        : \"::view-transition-old(root)\";\n\n      const keyframes = isDark\n        ? { clipPath: [start, end] }\n        : { clipPath: [end, start] };\n\n      root.animate(keyframes, {\n        duration: THEME_SWITCH_DURATION_MS,\n        easing: \"cubic-bezier(0.22, 1, 0.36, 1)\",\n        fill: \"both\",\n        pseudoElement,\n      });\n\n      transition.finished.finally(() => {\n        stopThemeSwitching(root);\n      });\n    },\n    [toggleTheme],\n  );\n\n  return { theme, setTheme, toggleTheme, toggleThemeWithTransition };\n}\n"
  },
  {
    "path": "web/src/hooks/useGitDiffStats.ts",
    "content": "import { useState, useCallback, useEffect, useRef } from \"react\";\nimport type { GitDiffStats } from \"../lib/api/models\";\nimport { getAuthHeader } from \"../lib/auth\";\nimport { getApiBaseUrl } from \"./utils\";\n\ntype UseGitDiffStatsReturn = {\n  stats: GitDiffStats | null;\n  isLoading: boolean;\n  error: string | null;\n  refresh: () => Promise<void>;\n};\n\nconst CACHE_TTL_MS = 10000; // 10 seconds cache\nconst POLL_INTERVAL_MS = 30000; // 30 seconds polling\n\n/**\n * Hook for fetching git diff stats for a session\n */\nexport function useGitDiffStats(sessionId: string | null): UseGitDiffStatsReturn {\n  const [stats, setStats] = useState<GitDiffStats | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  // Cache to avoid unnecessary requests\n  const cacheRef = useRef<{\n    sessionId: string;\n    stats: GitDiffStats;\n    timestamp: number;\n  } | null>(null);\n\n  const fetchStats = useCallback(async (forceRefresh = false) => {\n    if (!sessionId) {\n      setStats(null);\n      return;\n    }\n\n    // Check cache\n    const now = Date.now();\n    if (\n      !forceRefresh &&\n      cacheRef.current &&\n      cacheRef.current.sessionId === sessionId &&\n      now - cacheRef.current.timestamp < CACHE_TTL_MS\n    ) {\n      setStats(cacheRef.current.stats);\n      return;\n    }\n\n    setIsLoading(true);\n    setError(null);\n\n    try {\n      const basePath = getApiBaseUrl();\n      const response = await fetch(\n        `${basePath}/api/sessions/${encodeURIComponent(sessionId)}/git-diff`,\n        { headers: getAuthHeader() }\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to fetch git diff stats\");\n      }\n\n      const data = await response.json();\n      // Convert snake_case to camelCase\n      const gitDiffStats: GitDiffStats = {\n        isGitRepo: data.is_git_repo,\n        hasChanges: data.has_changes ?? false,\n        totalAdditions: data.total_additions ?? 0,\n        totalDeletions: data.total_deletions ?? 0,\n        files: (data.files ?? []).map((f: Record<string, unknown>) => ({\n          path: f.path,\n          additions: f.additions,\n          deletions: f.deletions,\n          status: f.status,\n        })),\n        error: data.error ?? null,\n      };\n\n      // Update cache\n      cacheRef.current = {\n        sessionId,\n        stats: gitDiffStats,\n        timestamp: now,\n      };\n\n      setStats(gitDiffStats);\n    } catch (err) {\n      const message =\n        err instanceof Error ? err.message : \"Failed to fetch git diff stats\";\n      setError(message);\n      setStats(null);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [sessionId]);\n\n  // Initial fetch and polling\n  useEffect(() => {\n    fetchStats();\n\n    const interval = setInterval(() => {\n      fetchStats();\n    }, POLL_INTERVAL_MS);\n\n    return () => clearInterval(interval);\n  }, [fetchStats]);\n\n  // Clear cache and stats when session changes\n  useEffect(() => {\n    if (cacheRef.current && cacheRef.current.sessionId !== sessionId) {\n      cacheRef.current = null;\n      setStats(null);\n    }\n  }, [sessionId]);\n\n  const refresh = useCallback(async () => {\n    await fetchStats(true);\n  }, [fetchStats]);\n\n  return {\n    stats,\n    isLoading,\n    error,\n    refresh,\n  };\n}\n"
  },
  {
    "path": "web/src/hooks/useGlobalConfig.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { apiClient } from \"@/lib/apiClient\";\nimport type {\n  GlobalConfig,\n  UpdateGlobalConfigRequest,\n  UpdateGlobalConfigResponse,\n} from \"@/lib/api/models\";\n\ntype UpdateGlobalConfigArgs = {\n  defaultModel?: string;\n  defaultThinking?: boolean;\n  restartRunningSessions?: boolean;\n  forceRestartBusySessions?: boolean;\n};\n\nexport type UseGlobalConfigReturn = {\n  config: GlobalConfig | null;\n  isLoading: boolean;\n  isUpdating: boolean;\n  error: string | null;\n  refresh: () => Promise<void>;\n  update: (args: UpdateGlobalConfigArgs) => Promise<UpdateGlobalConfigResponse>;\n};\n\nexport function useGlobalConfig(): UseGlobalConfigReturn {\n  const [config, setConfig] = useState<GlobalConfig | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n  const [isUpdating, setIsUpdating] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const isInitializedRef = useRef(false);\n\n  const refresh = useCallback(async () => {\n    setIsLoading(true);\n    setError(null);\n    try {\n      const nextConfig = await apiClient.config.getGlobalConfigApiConfigGet();\n      setConfig(nextConfig);\n    } catch (err) {\n      const message =\n        err instanceof Error ? err.message : \"Failed to load global config\";\n      setError(message);\n      console.error(\"[useGlobalConfig] Failed to load global config:\", err);\n    } finally {\n      setIsLoading(false);\n    }\n  }, []);\n\n  const update = useCallback(\n    async (\n      args: UpdateGlobalConfigArgs,\n    ): Promise<UpdateGlobalConfigResponse> => {\n      setIsUpdating(true);\n      setError(null);\n      try {\n        const body: UpdateGlobalConfigRequest = {\n          defaultModel: args.defaultModel ?? undefined,\n          defaultThinking: args.defaultThinking ?? undefined,\n          restartRunningSessions: args.restartRunningSessions ?? undefined,\n          forceRestartBusySessions: args.forceRestartBusySessions ?? undefined,\n        };\n\n        const resp = await apiClient.config.updateGlobalConfigApiConfigPatch({\n          updateGlobalConfigRequest: body,\n        });\n        setConfig(resp.config);\n        return resp;\n      } catch (err) {\n        const message =\n          err instanceof Error ? err.message : \"Failed to update global config\";\n        setError(message);\n        console.error(\"[useGlobalConfig] Failed to update global config:\", err);\n        throw err;\n      } finally {\n        setIsUpdating(false);\n      }\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (isInitializedRef.current) {\n      return;\n    }\n    isInitializedRef.current = true;\n    refresh();\n  }, [refresh]);\n\n  // Re-fetch config when another tab/session changes it (broadcast via custom event)\n  useEffect(() => {\n    const handler = () => {\n      refresh();\n    };\n    window.addEventListener(\"kimi:config-update\", handler);\n    return () => window.removeEventListener(\"kimi:config-update\", handler);\n  }, [refresh]);\n\n  return {\n    config,\n    isLoading,\n    isUpdating,\n    error,\n    refresh,\n    update,\n  };\n}\n"
  },
  {
    "path": "web/src/hooks/useSessionStream.ts",
    "content": "/**\n * Session stream hook - connects to the session WebSocket for real-time chat\n * This hook manages the WebSocket connection and processes wire protocol messages\n *\n * -----------------------------------------------------------------------------\n * High-level architecture (read this before editing)\n * -----------------------------------------------------------------------------\n *\n * This hook is the \"transport + reducer\" for the live chat stream:\n * - Transport: maintain exactly one active WebSocket for the currently selected `sessionId`\n * - Reducer: transform the server's JSON-RPC event stream into `LiveMessage[]` for the UI\n *\n * The UI contract is intentionally simple:\n * - `messages`: append-only timeline (with in-place updates while streaming)\n * - `status`: \"ready\" | \"submitted\" | \"streaming\" | \"error\"\n * - `contextUsage/currentStep`: lightweight progress info\n *\n * -------------------------\n * Data flow / event pipeline\n * -------------------------\n *\n *   Server (JSON-RPC) ─┐\n *                      │ WebSocket `.onmessage` (string)\n *                      ▼\n *                `handleMessage(data)`\n *                      │ JSON.parse → `WireMessage`\n *                      │ extractEvent → `WireEvent`\n *                      ▼\n *                `processEvent(event)`\n *                      │\n *                      ├─ updates small scalar states (status/contextUsage/step)\n *                      ├─ updates \"current streaming buffers\" (refs)\n *                      └─ updates `messages` via `setMessages(...)`\n *\n * The \"streaming buffers\" are refs (not state) because they are just accumulators\n * used to build the next message content (think/text/tool args) without fighting\n * React's async render model.\n *\n * ---------------------------------------\n * The hard constraint: no cross-session leak\n * ---------------------------------------\n *\n * Session switches (including \"enter draft mode\" which sets `sessionId = null`)\n * must be atomic from the UI's perspective:\n * - stop old stream\n * - clear per-session accumulators\n * - (optionally) connect to the new session\n *\n * Why this is tricky:\n * - WebSocket callbacks are async and can fire after we \"switch pages\".\n * - Calling `ws.close()` does NOT guarantee that previously scheduled callbacks\n *   won't run afterwards.\n *\n * Our solution is two layers:\n * 1) `useLayoutEffect([sessionId])` for teardown before paint (reduces visual flicker).\n * 2) WebSocket identity guards in every callback:\n *      `if (wsRef.current !== ws) return;`\n *    This makes late events harmless: only the currently active socket is allowed\n *    to mutate React state.\n *\n * ---------------------------------------------\n * Three \"tabs\" people may mean (disambiguation)\n * ---------------------------------------------\n *\n * 1) UI sidebar switching (switching between sessions):\n *\n *    This is in a single React tree. It changes the active context by changing\n *    `sessionId`.\n *\n *    Correctness requirement:\n *    - After a UI switch, no events from the previous session are allowed to\n *      mutate the new screen's state.\n *\n *    Mechanism used here:\n *    - `useLayoutEffect([sessionId])` teardown before paint\n *    - identity guard `if (wsRef.current !== ws) return;` in every callback\n *\n * 2) Browser tabs (two Kimi pages open in Chrome, etc.):\n *\n *    Each browser tab is its own JS runtime, so it has its own hook instance and\n *    its own `wsRef/messages/state`. They are naturally isolated on the client.\n *\n *    The only coupling is server-side (e.g. concurrent session limits), which\n *    shows up as close codes or errors. That policy is *handled* here but is not\n *    part of the core state model.\n *\n * 3) Multi-stream in one UI (render multiple sessions at once inside one page):\n *\n *    NOT supported by this hook by design. This hook intentionally enforces\n *    \"one active stream → one message timeline\" to stay easy to reason about.\n *\n *    If we ever need true multi-stream in one page, the clean design is:\n *\n *      ┌──────────────────────────┐\n *      │ Map<sessionId, ViewState>│   (store)\n *      └───────────┬──────────────┘\n *                  │ route by connection/session\n *          ┌───────▼────────┐\n *          │ reducer(event) │   (per session entry)\n *          └───────┬────────┘\n *                  │ select by sessionId\n *          ┌───────▼───────────┐\n *          │ UI renders one key │\n *          └────────────────────┘\n *\n *    Key property: events must be routed to the store entry that *owns* the\n *    connection that produced them.\n */\nimport {\n  useState,\n  useCallback,\n  useRef,\n  useEffect,\n  useLayoutEffect,\n} from \"react\";\nimport type { ChatStatus, ToolUIPart } from \"ai\";\nimport type { LiveMessage, MessageAttachmentPart, SubagentStep } from \"./types\";\nimport type { SessionStatus } from \"@/lib/api/models\";\nimport { getAuthToken } from \"@/lib/auth\";\nimport {\n  type ContentPart,\n  type TokenUsage,\n  type WireMessage,\n  type WireEvent,\n  type ToolCallState,\n  type JsonRpcRequest,\n  type JsonRpcResponse,\n  type ApprovalRequestEvent,\n  type ApprovalResponseDecision,\n  type QuestionRequestEvent,\n  type SessionStatusPayload,\n  type SubagentEventWire,\n  extractEvent,\n} from \"./wireTypes\";\nimport { createMessageId, getApiBaseUrl } from \"./utils\";\nimport { kimiCliVersion } from \"@/lib/version\";\nimport { handleToolResult, useToolEventsStore, type TodoItem } from \"@/features/tool/store\";\nimport { v4 as uuidV4 } from \"uuid\";\n\n// Regex patterns moved to top level for performance\nconst DATA_URL_MEDIA_TYPE_REGEX = /^data:([^;,]+)[;,]/;\nconst NUMBERED_LIST_ITEM_REGEX = /^\\d+\\.\\s+(.+)$/;\nconst IMAGE_TAG_REGEX = /<image\\s+path=\"([^\"]+)\"\\s+content_type=\"([^\"]+)\">/i;\nconst VIDEO_TAG_REGEX = /<video\\s+path=\"([^\"]+)\"\\s+content_type=\"([^\"]+)\">/i;\nconst DOCUMENT_TAG_REGEX =\n  /<document\\s+path=\"([^\"]+)\"\\s+content_type=\"([^\"]+)\">/i;\nconst LEGACY_UPLOADS_REGEX = /`uploads\\/([^`]+)`/;\nconst HTTP_TO_WS_REGEX = /^http/;\nconst NEWLINE_REGEX = /\\r?\\n/;\n// Match <image path=\"...\"> or <video path=\"...\"> tags (path attribute only, no content_type required)\nconst MEDIA_TAG_PATH_REGEX = /<(?:image|video)\\s+[^>]*path=\"([^\"]*\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\\/uploads\\/([^\"]+))\"/g;\nconst BROWSER_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"data:\", \"blob:\"]);\n\n/** Extract the URL from a media output part (image_url or video_url) */\nconst extractMediaUrl = (part: Record<string, unknown>): string => {\n  const imgUrl = (part.image_url as { url?: string })?.url;\n  const vidUrl = (part.video_url as { url?: string })?.url;\n  return imgUrl ?? vidUrl ?? \"\";\n};\n\n/** Check if a URL can be rendered in the browser (http/https/data/blob) */\nconst isBrowserUrl = (url: string): boolean => {\n  try {\n    return BROWSER_URL_PROTOCOLS.has(new URL(url).protocol);\n  } catch {\n    return false;\n  }\n};\n\nexport type SlashCommandDef = {\n  name: string;\n  description: string;\n  aliases: string[];\n};\n\ntype UseSessionStreamOptions = {\n  /** Session ID to connect to */\n  sessionId: string | null;\n  /** Base URL for WebSocket connection (defaults to current host) */\n  baseUrl?: string;\n  /** Callback when messages change */\n  onMessagesChange?: (messages: LiveMessage[]) => void;\n  /** Callback when connection status changes */\n  onConnectionChange?: (connected: boolean) => void;\n  /** Callback when an error occurs */\n  onError?: (error: Error) => void;\n  /** Callback when session status changes */\n  onSessionStatus?: (status: SessionStatus) => void;\n  /** Callback when first turn is complete (for auto-renaming) */\n  onFirstTurnComplete?: () => void;\n};\n\ntype UseSessionStreamReturn = {\n  /** Current messages */\n  messages: LiveMessage[];\n  /** Chat status */\n  status: ChatStatus;\n  /** Latest runtime session status snapshot */\n  sessionStatus: SessionStatus | null;\n  /** Whether the stream is still replaying history */\n  isReplayingHistory: boolean;\n  /** Whether waiting for the first response after sending a prompt */\n  isAwaitingFirstResponse: boolean;\n  /** Current context usage (0-1) */\n  contextUsage: number;\n  /** Current token usage for the active step, if available */\n  tokenUsage: TokenUsage | null;\n  /** Current step number */\n  currentStep: number;\n  /** Whether connected to the session stream */\n  isConnected: boolean;\n  /** Send a message to the session (will auto-connect if not connected) */\n  sendMessage: (text: string) => Promise<void>;\n  /** Respond to an approval request */\n  respondToApproval: (\n    requestId: string,\n    response: ApprovalResponseDecision,\n    reason?: string,\n  ) => Promise<void>;\n  /** Respond to a question request */\n  respondToQuestion: (\n    requestId: string,\n    answers: Record<string, string>,\n  ) => Promise<void>;\n  /** Send a cancel request for the current turn */\n  cancel: () => void;\n  /** Disconnect from the stream */\n  disconnect: () => void;\n  /** Reconnect to the session */\n  reconnect: () => void;\n  /** Connect to the session stream */\n  connect: () => void;\n  /** Set messages directly */\n  setMessages: React.Dispatch<React.SetStateAction<LiveMessage[]>>;\n  /** Clear all messages */\n  clearMessages: () => void;\n  /** Connection error if any */\n  error: Error | null;\n  /** Whether plan mode is active */\n  planMode: boolean;\n  /** Set plan mode via silent RPC (no context message) */\n  sendSetPlanMode: (enabled: boolean) => void;\n  /** Available slash commands from the server */\n  slashCommands: SlashCommandDef[];\n};\n\ntype PendingApprovalEntry = {\n  requestId: string;\n  toolCallId: string;\n  messageId?: string;\n  rpcId?: string | number;\n  submitted?: boolean;\n};\n\ntype PendingQuestionEntry = {\n  requestId: string;\n  toolCallId: string;\n  messageId?: string;\n  rpcId?: string | number;\n  submitted?: boolean;\n};\n\n/**\n * Hook for connecting to a session's WebSocket stream\n */\nexport function useSessionStream(\n  options: UseSessionStreamOptions,\n): UseSessionStreamReturn {\n  const {\n    sessionId,\n    baseUrl,\n    onMessagesChange,\n    onConnectionChange,\n    onError,\n    onSessionStatus,\n    onFirstTurnComplete,\n  } = options;\n\n  const [messages, setMessagesInternal] = useState<LiveMessage[]>([]);\n  const [status, setStatus] = useState<ChatStatus>(\"ready\");\n  const [sessionStatus, setSessionStatus] = useState<SessionStatus | null>(\n    null,\n  );\n  const [contextUsage, setContextUsage] = useState(0);\n  const [tokenUsage, setTokenUsage] = useState<TokenUsage | null>(null);\n  const [planMode, setPlanMode] = useState(false);\n  const [currentStep, setCurrentStep] = useState(0);\n  const [isConnected, setIsConnected] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n  const [isAwaitingFirstResponse, setIsAwaitingFirstResponse] = useState(false);\n  const [isReplayingHistory, setIsReplayingHistory] = useState(true);\n  const [slashCommands, setSlashCommands] = useState<SlashCommandDef[]>([]);\n\n  // Refs\n  /**\n   * The single source of truth for \"which WebSocket is allowed to mutate React state\".\n   *\n   * Important nuance: this ref represents the *current connection attempt*, not only\n   * \"the currently open socket\".\n   *\n   * Why this exists:\n   * - WebSocket callbacks (`onmessage/onclose/onerror/onopen`) are async and can fire\n   *   after the UI has already switched to another session (or draft mode).\n   * - Simply calling `ws.close()` or setting `wsRef.current = null` does NOT prevent\n   *   already-scheduled callbacks from running.\n   *\n   * Our invariant:\n   * - Only callbacks belonging to `wsRef.current` may call `setMessages`, `setStatus`, etc.\n   * - Every callback starts with `if (wsRef.current !== ws) return;` to ignore late events.\n   */\n  const wsRef = useRef<WebSocket | null>(null);\n  const reconnectTimeoutRef = useRef<number | null>(null);\n  const connectRef = useRef<() => void>(() => undefined);\n  const disconnectRef = useRef<() => void>(() => undefined);\n  const reconnectRef = useRef<() => void>(() => undefined);\n  const resetStateRef = useRef<(preserveSlashCommands?: boolean) => void>(() => undefined);\n  const historyCompleteTimeoutRef = useRef<number | null>(null);\n  const isReplayingRef = useRef(true); // Track if we're still replaying history\n  const pendingMessageRef = useRef<string | null>(null); // Message to send after connection\n  const awaitingIdleRef = useRef(false); // Track pending idle after cancel\n  const awaitingFirstResponseRef = useRef(false); // Track if waiting for first event of a turn\n  const lastStatusSeqRef = useRef<number | null>(null);\n  const lastWsMessageTimeRef = useRef<number>(0); // Last time a WS message was received\n  const watchdogIntervalRef = useRef<number | null>(null); // Stale connection watchdog\n  const statusRef = useRef<ChatStatus>(\"ready\"); // Synced copy of status for watchdog\n\n  // First turn tracking for auto-rename (simplified: backend reads from wire.jsonl)\n  const hasTurnStartedRef = useRef(false); // Whether at least one turn has started\n  const firstTurnCompleteCalledRef = useRef(false); // Whether onFirstTurnComplete was called\n\n  // Initialize message tracking\n  const initializeIdRef = useRef<string | null>(null);\n  const initializeRetryCountRef = useRef(0); // Track retry attempts for initialize\n  const MAX_INITIALIZE_RETRIES = 5; // Maximum retry attempts\n  const usingCachedCommandsRef = useRef(false); // Track if using cached slash commands\n  const slashCommandsLenRef = useRef(0); // Track slashCommands length without state dependency\n\n  // Current state accumulators\n  const currentThinkingRef = useRef(\"\");\n  const currentTextRef = useRef(\"\");\n  const currentToolCallsRef = useRef<Map<string, ToolCallState>>(new Map());\n  const currentToolCallIdRef = useRef<string | null>(null);\n  const thinkingMessageIdRef = useRef<string | null>(null);\n  const textMessageIdRef = useRef<string | null>(null);\n  const pendingApprovalRequestsRef = useRef<Map<string, PendingApprovalEntry>>(\n    new Map(),\n  );\n  const pendingQuestionRequestsRef = useRef<Map<string, PendingQuestionEntry>>(\n    new Map(),\n  );\n\n  // Track if current turn is a /clear command (needs UI clear on turn end)\n  const pendingClearRef = useRef(false);\n\n  // Turn counter for fork feature\n  const turnCounterRef = useRef(0);\n\n  // Track compaction indicator message so we can remove it on CompactionEnd\n  const compactionMessageIdRef = useRef<string | null>(null);\n\n  // Track MCP loading indicator message so we can remove it on MCPLoadingEnd\n  const mcpLoadingMessageIdRef = useRef<string | null>(null);\n\n  // Wrapped setMessages\n  const setMessages: typeof setMessagesInternal = useCallback((action) => {\n    setMessagesInternal(action);\n  }, []);\n\n  const setAwaitingFirstResponse = useCallback((value: boolean) => {\n    awaitingFirstResponseRef.current = value;\n    setIsAwaitingFirstResponse(value);\n  }, []);\n  const clearAwaitingFirstResponse = useCallback(() => {\n    if (!awaitingFirstResponseRef.current) {\n      return;\n    }\n    setAwaitingFirstResponse(false);\n  }, [setAwaitingFirstResponse]);\n\n  const normalizeSessionStatus = useCallback(\n    (payload: SessionStatusPayload): SessionStatus => ({\n      sessionId: payload.session_id,\n      state: payload.state,\n      seq: payload.seq,\n      workerId: payload.worker_id ?? undefined,\n      reason: payload.reason ?? undefined,\n      detail: payload.detail ?? undefined,\n      updatedAt: new Date(payload.updated_at),\n    }),\n    [],\n  );\n\n  const completeStreamingMessages = useCallback(() => {\n    setMessages((prev) =>\n      prev.map((msg) => {\n        let updated = msg;\n        if (msg.isStreaming) {\n          updated = { ...updated, isStreaming: false };\n        }\n        if (msg.toolCall?.subagentRunning) {\n          updated = {\n            ...updated,\n            toolCall: { ...updated.toolCall!, subagentRunning: false },\n          };\n        }\n        return updated;\n      }),\n    );\n  }, [setMessages]);\n\n  // Mark all non-terminal tool calls as interrupted and dismiss stale\n  // approval/question dialogs.  Called only when the backend confirms no\n  // active turn (idle / stopped / error), so it won't dismiss legitimate\n  // pending approvals on a busy session (e.g. after a tab switch).\n  const interruptStaleToolCalls = useCallback(() => {\n    pendingApprovalRequestsRef.current.clear();\n    pendingQuestionRequestsRef.current.clear();\n    setMessages((prev) =>\n      prev.map((msg) => {\n        if (msg.variant !== \"tool\" || !msg.toolCall) return msg;\n        const state = msg.toolCall.state;\n        if (\n          state === \"approval-requested\" ||\n          state === \"question-requested\" ||\n          state === \"input-streaming\" ||\n          state === \"input-available\"\n        ) {\n          return {\n            ...msg,\n            isStreaming: false,\n            toolCall: {\n              ...msg.toolCall,\n              state: \"output-denied\",\n              ...(state === \"approval-requested\" && msg.toolCall.approval\n                ? {\n                    approval: {\n                      ...msg.toolCall.approval,\n                      submitted: true,\n                      resolved: true,\n                      approved: false,\n                      response: \"reject\",\n                    },\n                  }\n                : {}),\n              ...(state === \"question-requested\" && msg.toolCall.question\n                ? {\n                    question: {\n                      ...msg.toolCall.question,\n                      submitted: true,\n                      resolved: true,\n                    },\n                  }\n                : {}),\n            },\n          };\n        }\n        return msg;\n      }),\n    );\n  }, [setMessages]);\n\n  const applySessionStatus = useCallback(\n    (payload: SessionStatusPayload) => {\n      const normalized = normalizeSessionStatus(payload);\n      const lastSeq = lastStatusSeqRef.current;\n      if (lastSeq !== null && normalized.seq <= lastSeq) {\n        return;\n      }\n      lastStatusSeqRef.current = normalized.seq;\n      setSessionStatus(normalized);\n      onSessionStatus?.(normalized);\n      isReplayingRef.current = false;\n      setIsReplayingHistory(false);\n\n      switch (normalized.state) {\n        case \"busy\": {\n          if (!awaitingIdleRef.current) {\n            setStatus(\"streaming\");\n          }\n          break;\n        }\n        case \"restarting\": {\n          setStatus(\"submitted\");\n          break;\n        }\n        case \"error\": {\n          setStatus(\"error\");\n          setAwaitingFirstResponse(false);\n          awaitingIdleRef.current = false;\n          completeStreamingMessages();\n          interruptStaleToolCalls();\n          break;\n        }\n        case \"stopped\":\n        case \"idle\": {\n          setStatus(\"ready\");\n          setAwaitingFirstResponse(false);\n          awaitingIdleRef.current = false;\n          completeStreamingMessages();\n          interruptStaleToolCalls();\n\n          // Trigger onFirstTurnComplete only after at least one turn has completed\n          if (hasTurnStartedRef.current && !firstTurnCompleteCalledRef.current) {\n            firstTurnCompleteCalledRef.current = true;\n            onFirstTurnComplete?.();\n          }\n          break;\n        }\n      }\n    },\n    [\n      completeStreamingMessages,\n      interruptStaleToolCalls,\n      normalizeSessionStatus,\n      onSessionStatus,\n      setAwaitingFirstResponse,\n      onFirstTurnComplete,\n    ],\n  );\n\n  const updateMessageById = useCallback(\n    (messageId: string, transform: (message: LiveMessage) => LiveMessage) => {\n      setMessages((prev) =>\n        prev.map((message) =>\n          message.id === messageId ? transform(message) : message,\n        ),\n      );\n    },\n    [setMessages],\n  );\n\n  const safeStringify = useCallback((value: unknown): string => {\n    if (value === null || value === undefined) {\n      return \"\";\n    }\n    if (typeof value === \"string\") {\n      return value;\n    }\n    try {\n      return JSON.stringify(value);\n    } catch {\n      return String(value);\n    }\n  }, []);\n\n  type ParsedUserInput = { text: string; attachments: MessageAttachmentPart[] };\n\n  const parseMediaTypeFromDataUrl = useCallback(\n    (url: string): string | null => {\n      if (!url.startsWith(\"data:\")) {\n        return null;\n      }\n      const match = DATA_URL_MEDIA_TYPE_REGEX.exec(url);\n      return match?.[1] ?? null;\n    },\n    [],\n  );\n\n  const getSessionUploadUrl = useCallback(\n    (filename?: string): string | undefined => {\n      if (!(sessionId && filename)) {\n        return undefined;\n      }\n      const basePath = baseUrl ?? getApiBaseUrl();\n      const token = getAuthToken();\n      const tokenParam = token ? `?token=${encodeURIComponent(token)}` : \"\";\n      return `${basePath}/api/sessions/${encodeURIComponent(\n        sessionId,\n      )}/uploads/${encodeURIComponent(filename)}${tokenParam}`;\n    },\n    [baseUrl, sessionId],\n  );\n\n  const parseUserInput = useCallback(\n    (input: string | ContentPart[]): ParsedUserInput => {\n      if (typeof input === \"string\") {\n        return { text: input, attachments: [] };\n      }\n\n      const textParts: string[] = [];\n      const attachments: MessageAttachmentPart[] = [];\n      const uploadedFilePaths: string[] = [];\n      let inUploadedFilesBlock = false;\n      const collectUploadedFilePath = (line: string): boolean => {\n        const match = NUMBERED_LIST_ITEM_REGEX.exec(line.trim());\n        if (!match) {\n          return false;\n        }\n        const filePath = match[1].trim();\n        if (\n          !(\n            filePath &&\n            (filePath.startsWith(\"/\") || filePath.startsWith(\"uploads/\"))\n          )\n        ) {\n          return false;\n        }\n        uploadedFilePaths.push(filePath);\n        return true;\n      };\n\n      // Pending metadata for associating with next image_url part\n      let pendingFilename: string | undefined;\n      let pendingMediaType: string | undefined;\n\n      // State for collecting document content\n      let inDocument = false;\n      let documentFilename: string | undefined;\n      let documentMediaType: string | undefined;\n      let documentContent: string[] = [];\n\n      for (const part of input) {\n        if (part.type === \"text\" || part.type === \"input_text\") {\n          const text = part.text;\n\n          // New format: <image path=\"/path/to/uploads/file.name\" content_type=\"image/png\">\n          const imageTagMatch = IMAGE_TAG_REGEX.exec(text);\n          if (imageTagMatch) {\n            // Extract filename from path\n            const fullPath = imageTagMatch[1];\n            pendingFilename = fullPath.split(\"/\").pop() ?? fullPath;\n            pendingMediaType = imageTagMatch[2];\n            continue; // Skip this text part, it's just metadata\n          }\n\n          // New format: </image> closing tag - skip it\n          if (text.trim() === \"</image>\") {\n            continue;\n          }\n\n          // New format: <video path=\"/path/to/uploads/file.name\" content_type=\"video/mp4\">\n          const videoTagMatch = VIDEO_TAG_REGEX.exec(text);\n          if (videoTagMatch) {\n            // Extract filename from path\n            const fullPath = videoTagMatch[1];\n            pendingFilename = fullPath.split(\"/\").pop() ?? fullPath;\n            pendingMediaType = videoTagMatch[2];\n            continue; // Skip this text part, it's just metadata\n          }\n\n          // New format: </video> closing tag - create attachment if no video_url follows\n          if (text.trim() === \"</video>\") {\n            // If we have pending video metadata but no video_url part will follow,\n            // create a video attachment from the session uploads.\n            if (pendingFilename && pendingMediaType?.startsWith(\"video/\")) {\n              const url = getSessionUploadUrl(pendingFilename);\n              if (url) {\n                attachments.push({\n                  type: \"file\",\n                  mediaType: pendingMediaType,\n                  filename: pendingFilename,\n                  url,\n                });\n              } else {\n                attachments.push({\n                  kind: \"video-nopreview\",\n                  mediaType: pendingMediaType,\n                  filename: pendingFilename,\n                });\n              }\n              pendingFilename = undefined;\n              pendingMediaType = undefined;\n            }\n            continue;\n          }\n\n          // New format: <document path=\"/path/to/uploads/...\" content_type=\"...\"> - start collecting\n          const documentTagMatch = DOCUMENT_TAG_REGEX.exec(text);\n          if (documentTagMatch) {\n            inDocument = true;\n            // Extract filename from path\n            const fullPath = documentTagMatch[1];\n            documentFilename = fullPath.split(\"/\").pop() ?? fullPath;\n            documentMediaType = documentTagMatch[2];\n            documentContent = [];\n            continue;\n          }\n\n          // New format: </document> - finalize document attachment\n          if (text.trim() === \"</document>\") {\n            if (inDocument && documentFilename) {\n              const content = documentContent.join(\"\");\n              const bytes = new TextEncoder().encode(content);\n              const base64 = btoa(String.fromCharCode(...bytes));\n              const dataUrl = `data:${documentMediaType ?? \"text/plain\"};base64,${base64}`;\n              attachments.push({\n                type: \"file\",\n                mediaType: documentMediaType ?? \"text/plain\",\n                filename: documentFilename,\n                url: dataUrl,\n              });\n            }\n            inDocument = false;\n            documentFilename = undefined;\n            documentMediaType = undefined;\n            documentContent = [];\n            continue;\n          }\n\n          // If inside document, collect content instead of adding to textParts\n          if (inDocument) {\n            documentContent.push(text);\n            continue;\n          }\n\n          const lines = text.split(NEWLINE_REGEX);\n          const filteredLines: string[] = [];\n\n          for (const line of lines) {\n            if (line.includes(\"<uploaded_files>\")) {\n              inUploadedFilesBlock = true;\n              continue;\n            }\n            if (line.includes(\"</uploaded_files>\")) {\n              inUploadedFilesBlock = false;\n              continue;\n            }\n            if (inUploadedFilesBlock) {\n              collectUploadedFilePath(line);\n              continue;\n            }\n            if (collectUploadedFilePath(line)) {\n              continue;\n            }\n            filteredLines.push(line);\n          }\n\n          const filteredText = filteredLines.join(\"\\n\");\n\n          // Legacy format: `uploads/file.name`\n          const legacyMatch = LEGACY_UPLOADS_REGEX.exec(filteredText);\n          if (legacyMatch) {\n            pendingFilename = legacyMatch[1];\n          }\n\n          // Only add non-metadata text parts\n          if (filteredText.trim()) {\n            textParts.push(filteredText);\n          }\n          continue;\n        }\n\n        if (part.type === \"image_url\") {\n          const inferredMediaType = parseMediaTypeFromDataUrl(\n            part.image_url.url,\n          );\n          attachments.push({\n            type: \"file\",\n            mediaType: pendingMediaType ?? inferredMediaType ?? \"image/*\",\n            filename: pendingFilename,\n            url: part.image_url.url,\n          });\n          pendingFilename = undefined;\n          pendingMediaType = undefined;\n        }\n\n        if (part.type === \"video_url\") {\n          const inferredMediaType = parseMediaTypeFromDataUrl(\n            part.video_url.url,\n          );\n          attachments.push({\n            type: \"file\",\n            mediaType: pendingMediaType ?? inferredMediaType ?? \"video/*\",\n            filename: pendingFilename,\n            url: part.video_url.url,\n          });\n          pendingFilename = undefined;\n          pendingMediaType = undefined;\n        }\n      }\n\n      if (uploadedFilePaths.length > 0) {\n        const existingFilenames = new Set(\n          attachments\n            .map((attachment) => attachment.filename)\n            .filter((filename): filename is string => Boolean(filename)),\n        );\n        const seenUploadedFilenames = new Set<string>();\n        for (const filePath of uploadedFilePaths) {\n          const filename = filePath.split(\"/\").pop() ?? filePath;\n          if (!filename) {\n            continue;\n          }\n          if (\n            existingFilenames.has(filename) ||\n            seenUploadedFilenames.has(filename)\n          ) {\n            continue;\n          }\n          attachments.push({\n            kind: \"nopreview\",\n            filename,\n          });\n          seenUploadedFilenames.add(filename);\n        }\n      }\n\n      return { text: textParts.join(\"\\n\\n\").trim(), attachments };\n    },\n    [getSessionUploadUrl, parseMediaTypeFromDataUrl],\n  );\n\n  const upsertMessage = useCallback(\n    (incoming: LiveMessage) => {\n      setMessages((prev) => {\n        const index = prev.findIndex((message) => message.id === incoming.id);\n        if (index === -1) {\n          return [...prev, incoming];\n        }\n        const next = [...prev];\n        next[index] = { ...next[index], ...incoming };\n        return next;\n      });\n    },\n    [setMessages],\n  );\n\n  // Notify parent of changes\n  useEffect(() => {\n    onMessagesChange?.(messages);\n  }, [messages, onMessagesChange]);\n\n  // Notify parent of connection changes\n  useEffect(() => {\n    onConnectionChange?.(isConnected);\n  }, [isConnected, onConnectionChange]);\n\n  // Create unique message ID\n  const getNextMessageId = useCallback(\n    (prefix: \"user\" | \"assistant\"): string => createMessageId(prefix),\n    [],\n  );\n\n  // Reset state for new step\n  const resetStepState = useCallback(() => {\n    currentThinkingRef.current = \"\";\n    currentTextRef.current = \"\";\n    thinkingMessageIdRef.current = null;\n    textMessageIdRef.current = null;\n  }, []);\n\n  // Reset all state\n  const resetState = useCallback((preserveSlashCommands = false) => {\n    resetStepState();\n    currentToolCallsRef.current?.clear();\n    currentToolCallIdRef.current = null;\n    pendingApprovalRequestsRef.current?.clear();\n    pendingQuestionRequestsRef.current?.clear();\n    pendingClearRef.current = false;\n    setCurrentStep(0);\n    setContextUsage(0);\n    setTokenUsage(null);\n    setPlanMode(false);\n    setError(null);\n    setSessionStatus(null);\n    lastStatusSeqRef.current = null;\n    isReplayingRef.current = true;\n    setIsReplayingHistory(true);\n    setAwaitingFirstResponse(false);\n    // Reset first turn tracking\n    hasTurnStartedRef.current = false;\n    firstTurnCompleteCalledRef.current = false;\n    // Reset turn counter\n    turnCounterRef.current = 0;\n    // Clear history_complete timeout\n    if (historyCompleteTimeoutRef.current) {\n      window.clearTimeout(historyCompleteTimeoutRef.current);\n      historyCompleteTimeoutRef.current = null;\n    }\n    // Handle slashCommands: preserve or clear\n    if (!preserveSlashCommands) {\n      setSlashCommands([]);\n      slashCommandsLenRef.current = 0;\n      usingCachedCommandsRef.current = false;\n    } else if (slashCommandsLenRef.current > 0) {\n      usingCachedCommandsRef.current = true;\n    }\n  }, [resetStepState, setAwaitingFirstResponse]);\n\n  // Process a SubagentEvent: accumulate inner events into parent Task tool's subagentSteps\n  const processSubagentEvent = useCallback(\n    (taskToolCallId: string, innerType: string, innerPayload: unknown) => {\n      setMessages((prev) => {\n        // Find the parent Task tool message by toolCallId\n        const parentIdx = prev.findIndex(\n          (msg) => msg.toolCall?.toolCallId === taskToolCallId,\n        );\n        if (parentIdx === -1) return prev;\n\n        const parentMsg = prev[parentIdx];\n        const steps: SubagentStep[] = [\n          ...(parentMsg.toolCall?.subagentSteps ?? []),\n        ];\n\n        switch (innerType) {\n          case \"ContentPart\": {\n            const cp = innerPayload as {\n              type: string;\n              think?: string;\n              text?: string;\n            };\n            if (cp.type === \"think\" && cp.think) {\n              const last = steps[steps.length - 1];\n              if (last?.kind === \"thinking\") {\n                steps[steps.length - 1] = {\n                  ...last,\n                  text: last.text + cp.think,\n                };\n              } else {\n                steps.push({ kind: \"thinking\", text: cp.think });\n              }\n            } else if (cp.type === \"text\" && cp.text) {\n              const last = steps[steps.length - 1];\n              if (last?.kind === \"text\") {\n                steps[steps.length - 1] = {\n                  ...last,\n                  text: last.text + cp.text,\n                };\n              } else {\n                steps.push({ kind: \"text\", text: cp.text });\n              }\n            }\n            break;\n          }\n\n          case \"ToolCall\": {\n            const tc = innerPayload as {\n              type: string;\n              id: string;\n              function: { name: string; arguments: string };\n            };\n            const initialArgs = tc.function.arguments || \"\";\n            let parsedInput: unknown;\n            try {\n              parsedInput = JSON.parse(initialArgs || \"{}\");\n            } catch {\n              // not valid JSON yet\n            }\n            steps.push({\n              kind: \"tool-call\",\n              toolCallId: tc.id,\n              toolName: tc.function.name,\n              rawArgs: initialArgs,\n              input: parsedInput,\n              status: \"running\",\n            });\n            break;\n          }\n\n          case \"ToolCallPart\": {\n            const tcp = innerPayload as { arguments_part: string };\n            // Find the last running tool-call step and append arguments\n            for (let i = steps.length - 1; i >= 0; i--) {\n              const step = steps[i];\n              if (step.kind === \"tool-call\" && step.status === \"running\") {\n                const newArgs = (step.rawArgs ?? \"\") + tcp.arguments_part;\n                let parsedInput: unknown;\n                try {\n                  parsedInput = JSON.parse(newArgs);\n                } catch {\n                  // not complete JSON yet\n                }\n                steps[i] = {\n                  ...step,\n                  rawArgs: newArgs,\n                  input: parsedInput ?? step.input,\n                };\n                break;\n              }\n            }\n            break;\n          }\n\n          case \"ToolResult\": {\n            const tr = innerPayload as {\n              tool_call_id: string;\n              return_value: {\n                is_error: boolean;\n                output: Array<{ text?: string }> | string;\n                message: string;\n              };\n            };\n            for (let i = steps.length - 1; i >= 0; i--) {\n              const step = steps[i];\n              if (\n                step.kind === \"tool-call\" &&\n                step.toolCallId === tr.tool_call_id\n              ) {\n                const outputStr = Array.isArray(tr.return_value.output)\n                  ? tr.return_value.output\n                      .map((p) => p.text ?? \"\")\n                      .filter(Boolean)\n                      .join(\"\\n\")\n                  : tr.return_value.output;\n                steps[i] = {\n                  ...step,\n                  status: tr.return_value.is_error ? \"error\" : \"success\",\n                  output: outputStr || undefined,\n                  errorText: tr.return_value.is_error\n                    ? tr.return_value.message || undefined\n                    : undefined,\n                };\n                break;\n              }\n            }\n            break;\n          }\n\n          case \"SubagentEvent\": {\n            // Nested subagent — deep nesting is rare in practice.\n            // For now we skip nested SubagentEvents; the parent subagent's\n            // direct tool calls/text/thinking are already captured.\n            break;\n          }\n\n          default:\n            // Ignore StepBegin, TurnBegin, TurnEnd, StatusUpdate, etc.\n            break;\n        }\n\n        const next = [...prev];\n        next[parentIdx] = {\n          ...parentMsg,\n          toolCall: {\n            ...parentMsg.toolCall!,\n            subagentSteps: steps,\n            subagentRunning: true,\n          },\n        };\n        return next;\n      });\n    },\n    [setMessages],\n  );\n\n  // Process a single wire event\n  const processEvent = useCallback(\n    (event: WireEvent, isReplay = false, rpcMessageId?: string | number) => {\n      switch (event.type) {\n        case \"TurnBegin\": {\n          // Reset step state to ensure slash commands create new messages\n          resetStepState();\n\n          const parsedUserInput = parseUserInput(event.payload.user_input);\n\n          // Track turn index for fork feature\n          const currentTurnIndex = turnCounterRef.current;\n          turnCounterRef.current += 1;\n\n          // Track that at least one turn has started (for auto-rename trigger)\n          if (!isReplay) {\n            hasTurnStartedRef.current = true;\n          }\n\n          // Check if this is a /clear or /reset command (needs UI clear)\n          const userText = parsedUserInput.text.trim();\n          pendingClearRef.current =\n            userText === \"/clear\" || userText === \"/reset\";\n\n          // Add user message\n          const userMessageId = getNextMessageId(\"user\");\n          const userMessage: LiveMessage = {\n            id: userMessageId,\n            role: \"user\",\n            turnIndex: currentTurnIndex,\n            content:\n              parsedUserInput.text ||\n              (parsedUserInput.attachments.length > 0\n                ? \"\"\n                : safeStringify(event.payload.user_input ?? \"\")),\n            attachments:\n              parsedUserInput.attachments.length > 0\n                ? parsedUserInput.attachments\n                : undefined,\n          };\n\n          upsertMessage(userMessage);\n          break;\n        }\n\n        case \"StepBegin\": {\n          setCurrentStep(event.payload.n);\n          resetStepState();\n          if (!isReplay) {\n            setStatus(\"streaming\");\n          }\n          break;\n        }\n\n        case \"ContentPart\": {\n          if (!isReplay) {\n            clearAwaitingFirstResponse();\n          }\n          if (event.payload.type === \"think\" && event.payload.think) {\n            // Accumulate thinking content\n            currentThinkingRef.current += event.payload.think;\n\n            // Create or update thinking message\n            if (!thinkingMessageIdRef.current) {\n              thinkingMessageIdRef.current = getNextMessageId(\"assistant\");\n              const thinkingMsg: LiveMessage = {\n                id: thinkingMessageIdRef.current!,\n                role: \"assistant\",\n                variant: \"thinking\",\n                thinking: currentThinkingRef.current,\n                isStreaming: !isReplay,\n              };\n              if (textMessageIdRef.current) {\n                // Text message already exists, insert thinking before it\n                setMessages((prev) => {\n                  const textIdx = prev.findIndex(\n                    (m) => m.id === textMessageIdRef.current,\n                  );\n                  if (textIdx !== -1) {\n                    const next = [...prev];\n                    next.splice(textIdx, 0, thinkingMsg);\n                    return next;\n                  }\n                  return [...prev, thinkingMsg];\n                });\n              } else {\n                upsertMessage(thinkingMsg);\n              }\n            } else {\n              setMessages((prev) =>\n                prev.map((msg) =>\n                  msg.id === thinkingMessageIdRef.current\n                    ? { ...msg, thinking: currentThinkingRef.current }\n                    : msg,\n                ),\n              );\n            }\n          } else if (event.payload.type === \"text\" && event.payload.text) {\n            // Mark thinking as complete if it exists\n            if (thinkingMessageIdRef.current) {\n              setMessages((prev) =>\n                prev.map((msg) =>\n                  msg.id === thinkingMessageIdRef.current\n                    ? { ...msg, isStreaming: false }\n                    : msg,\n                ),\n              );\n            }\n\n            // Accumulate text content\n            currentTextRef.current += event.payload.text;\n\n            // Create or update text message\n            if (!textMessageIdRef.current) {\n              textMessageIdRef.current = getNextMessageId(\"assistant\");\n              upsertMessage({\n                id: textMessageIdRef.current!,\n                role: \"assistant\",\n                variant: \"text\",\n                turnIndex: turnCounterRef.current > 0 ? turnCounterRef.current - 1 : undefined,\n                content: currentTextRef.current,\n                isStreaming: !isReplay,\n              });\n            } else {\n              setMessages((prev) =>\n                prev.map((msg) =>\n                  msg.id === textMessageIdRef.current\n                    ? { ...msg, content: currentTextRef.current }\n                    : msg,\n                ),\n              );\n            }\n          }\n          break;\n        }\n\n        case \"ToolCall\": {\n          if (!isReplay) {\n            clearAwaitingFirstResponse();\n          }\n          const toolCall = event.payload;\n          currentToolCallIdRef.current = toolCall.id;\n\n          // Initialize tool call state\n          const initialArgs = toolCall.function.arguments || \"\";\n          currentToolCallsRef.current.set(toolCall.id, {\n            id: toolCall.id,\n            name: toolCall.function.name,\n            arguments: initialArgs,\n            argumentsComplete: false,\n            messageId: undefined,\n          });\n\n          // Parse initial arguments if available\n          let parsedInput: unknown;\n          if (initialArgs) {\n            try {\n              parsedInput = JSON.parse(initialArgs);\n            } catch {\n              // Not valid JSON yet, leave as undefined\n            }\n          }\n\n          // Create tool message\n          const toolMessageId = getNextMessageId(\"assistant\");\n          upsertMessage({\n            id: toolMessageId,\n            role: \"assistant\",\n            variant: \"tool\",\n            toolCall: {\n              title: toolCall.function.name,\n              type: \"tool-call\" as ToolUIPart[\"type\"],\n              state: \"input-streaming\" as ToolUIPart[\"state\"],\n              toolCallId: toolCall.id,\n              input: parsedInput,\n            },\n            isStreaming: !isReplay,\n          });\n\n          // Store message ID in tool call state for later updates\n          const tc = currentToolCallsRef.current.get(toolCall.id);\n          if (tc) {\n            tc.messageId = toolMessageId;\n          }\n          break;\n        }\n\n        case \"ToolCallPart\": {\n          if (currentToolCallIdRef.current) {\n            const tc = currentToolCallsRef.current.get(\n              currentToolCallIdRef.current,\n            );\n            if (tc) {\n              tc.arguments += event.payload.arguments_part;\n\n              const messageId = tc.messageId;\n              if (messageId) {\n                let parsedInput: unknown = tc.arguments;\n                try {\n                  parsedInput = JSON.parse(tc.arguments);\n                } catch {\n                  // Not complete JSON yet\n                }\n\n                setMessages((prev) =>\n                  prev.map((msg) =>\n                    msg.id === messageId && msg.toolCall\n                      ? {\n                          ...msg,\n                          toolCall: {\n                            ...msg.toolCall,\n                            state: \"input-available\" as ToolUIPart[\"state\"],\n                            input: parsedInput,\n                          },\n                        }\n                      : msg,\n                  ),\n                );\n              }\n            }\n          }\n          break;\n        }\n\n        case \"ToolResult\": {\n          if (!isReplay) {\n            clearAwaitingFirstResponse();\n          }\n          const { tool_call_id, return_value } = event.payload;\n          const tc = currentToolCallsRef.current.get(tool_call_id);\n\n          const outputStr = Array.isArray(return_value.output)\n            ? return_value.output\n                .map((part) => part.text ?? \"\")\n                .filter(Boolean)\n                .join(\"\\n\")\n            : return_value.output;\n\n          // Extract media parts (image_url/video_url) from output array\n          let mediaParts: Array<{ type: \"image_url\" | \"video_url\"; url: string }> = [];\n          if (Array.isArray(return_value.output)) {\n            mediaParts = return_value.output\n              .filter((part: Record<string, unknown>) => part.type === \"image_url\" || part.type === \"video_url\")\n              .map((part: Record<string, unknown>) => ({\n                type: part.type as \"image_url\" | \"video_url\",\n                url: extractMediaUrl(part),\n              }))\n              .filter((p) => p.url);\n\n            // For non-browser-renderable URLs (e.g. ms:// from Kimi model),\n            // try to construct serving URLs from file paths in text output tags\n            const hasNonBrowserUrl = mediaParts.some((p) => !isBrowserUrl(p.url));\n            if (hasNonBrowserUrl) {\n              const textOutput = return_value.output\n                .map((p: Record<string, unknown>) => (p.text as string) ?? \"\")\n                .filter(Boolean)\n                .join(\"\");\n              // Collect all API URLs from media tags in order\n              const apiUrls: string[] = [];\n              for (const match of textOutput.matchAll(MEDIA_TAG_PATH_REGEX)) {\n                const [, , sid, filename] = match;\n                apiUrls.push(`/api/sessions/${sid}/uploads/${encodeURIComponent(filename)}`);\n              }\n              if (apiUrls.length > 0) {\n                let apiIdx = 0;\n                mediaParts = mediaParts.map((p) => {\n                  if (isBrowserUrl(p.url)) return p;\n                  const url = apiUrls[apiIdx] ?? apiUrls[apiUrls.length - 1];\n                  apiIdx++;\n                  return { ...p, url };\n                });\n              }\n            }\n          }\n\n          const messageStr = return_value.message;\n\n          if (tc) {\n            tc.argumentsComplete = true;\n            tc.result = {\n              isError: return_value.is_error,\n              output: outputStr || undefined,\n              message: messageStr || undefined,\n            };\n          }\n\n          // Match message by toolCallId directly - this is robust against:\n          // 1. Out-of-order ToolResult (parallel tool calls)\n          // 2. Missing tc.messageId (race conditions)\n          // 3. Replay mode (messages already have toolCallId)\n          setMessages((prev) =>\n            prev.map((msg) => {\n              if (msg.toolCall?.toolCallId !== tool_call_id) return msg;\n              return {\n                ...msg,\n                toolCall: {\n                  ...msg.toolCall,\n                  state: return_value.is_error\n                    ? (\"output-error\" as ToolUIPart[\"state\"])\n                    : (\"output-available\" as ToolUIPart[\"state\"]),\n                  // Aligned with backend ToolReturnValue\n                  output: outputStr || undefined,\n                  message: messageStr || undefined,\n                  display: return_value.display,\n                  extras: return_value.extras,\n                  isError: return_value.is_error,\n                  errorText: return_value.is_error\n                    ? messageStr || undefined\n                    : undefined,\n                  mediaParts: mediaParts.length > 0 ? mediaParts : undefined,\n                  // Mark subagent as complete when its parent Task tool receives result\n                  subagentRunning: msg.toolCall.subagentSteps\n                    ? false\n                    : msg.toolCall.subagentRunning,\n                },\n                isStreaming: false,\n              };\n            }),\n          );\n\n          if (currentToolCallIdRef.current === tool_call_id) {\n            currentToolCallIdRef.current = null;\n          }\n\n          // Handle tool-specific events (e.g., WriteFile → new files notification)\n          if (tc) {\n            handleToolResult(\n              tc.name,\n              tc.arguments,\n              return_value.is_error,\n              isReplay,\n            );\n          }\n\n          // Extract todo list from display blocks\n          if (!isReplay && Array.isArray(return_value.display)) {\n            const todoBlock = return_value.display.find(\n              (d: { type: string }) => d.type === \"todo\",\n            );\n            if (todoBlock) {\n              useToolEventsStore.getState().setTodoItems(\n                (todoBlock as unknown as { type: string; items: TodoItem[] }).items,\n              );\n            }\n          }\n          break;\n        }\n\n        case \"ApprovalRequest\": {\n          if (!isReplay) {\n            clearAwaitingFirstResponse();\n          }\n          const payload = event.payload;\n          const tc = currentToolCallsRef.current.get(payload.tool_call_id);\n\n          const approvalState = {\n            id: payload.id,\n            action: payload.action,\n            description: payload.description,\n            sender: payload.sender,\n            toolCallId: payload.tool_call_id,\n            rpcMessageId,\n            submitted: false,\n            resolved: false,\n          };\n\n          if (tc) {\n            tc.approval = approvalState;\n          } else {\n            const fallbackState: ToolCallState = {\n              id: payload.tool_call_id,\n              name: payload.action,\n              arguments: \"\",\n              argumentsComplete: false,\n              messageId: undefined,\n              approval: approvalState,\n            };\n            currentToolCallsRef.current.set(\n              payload.tool_call_id,\n              fallbackState,\n            );\n          }\n\n          let messageId = tc?.messageId;\n\n          if (messageId) {\n            updateMessageById(messageId, (message) => {\n              if (!message.toolCall) {\n                return message;\n              }\n              return {\n                ...message,\n                isStreaming: false,\n                toolCall: {\n                  ...message.toolCall,\n                  state: \"approval-requested\",\n                  approval: approvalState,\n                },\n              };\n            });\n          } else {\n            const fallbackMessageId = getNextMessageId(\"assistant\");\n            const approvalMessage: LiveMessage = {\n              id: fallbackMessageId,\n              role: \"assistant\",\n              variant: \"tool\",\n              isStreaming: false,\n              toolCall: {\n                title: payload.action,\n                type: \"tool-call\" as ToolUIPart[\"type\"],\n                state: \"approval-requested\",\n                approval: approvalState,\n              },\n            };\n\n            currentToolCallsRef.current.set(payload.tool_call_id, {\n              ...(currentToolCallsRef.current.get(payload.tool_call_id) ?? {\n                id: payload.tool_call_id,\n                name: payload.action,\n                arguments: \"\",\n                argumentsComplete: false,\n              }),\n              messageId: fallbackMessageId,\n            });\n\n            setMessages((prev) => [...prev, approvalMessage]);\n            messageId = fallbackMessageId;\n          }\n\n          pendingApprovalRequestsRef.current.set(payload.id, {\n            requestId: payload.id,\n            toolCallId: payload.tool_call_id,\n            messageId,\n            rpcId: rpcMessageId,\n            submitted: false,\n          });\n\n          break;\n        }\n\n        case \"ApprovalRequestResolved\": {\n          const { request_id, response } = event.payload;\n          const pending = pendingApprovalRequestsRef.current.get(request_id);\n\n          let tc: ToolCallState | undefined;\n\n          if (pending) {\n            tc = currentToolCallsRef.current.get(pending.toolCallId);\n          }\n\n          if (!tc) {\n            for (const entry of currentToolCallsRef.current.values()) {\n              if (entry.approval?.id === request_id) {\n                tc = entry;\n                break;\n              }\n            }\n          }\n\n          const approval = tc?.approval ?? {\n            id: request_id,\n            action: \"\",\n            description: \"\",\n            sender: \"\",\n            toolCallId: pending?.toolCallId ?? \"\",\n          };\n\n          let approved: boolean | undefined;\n          let reason: string | undefined;\n\n          if (typeof response === \"boolean\") {\n            approved = response;\n          } else if (response && typeof response === \"object\") {\n            const candidate = response as {\n              approved?: unknown;\n              reason?: unknown;\n            };\n            if (typeof candidate.approved === \"boolean\") {\n              approved = candidate.approved;\n            }\n            if (typeof candidate.reason === \"string\") {\n              reason = candidate.reason;\n            }\n          } else if (typeof response === \"string\") {\n            const normalizedResponse = response.toLowerCase();\n            if (\n              normalizedResponse === \"approve\" ||\n              normalizedResponse === \"approve_for_session\" ||\n              normalizedResponse === \"approval\" ||\n              normalizedResponse === \"approved\"\n            ) {\n              approved = true;\n            } else if (normalizedResponse === \"reject\") {\n              approved = false;\n            } else {\n              reason = response;\n            }\n          }\n\n          const updatedApproval = {\n            ...approval,\n            response,\n            resolved: true,\n            submitted: true,\n            approved,\n            reason: reason ?? approval.reason,\n          };\n\n          if (tc) {\n            tc.approval = updatedApproval;\n          }\n\n          const messageId = tc?.messageId ?? pending?.messageId;\n          const nextState =\n            approved === false ? \"output-denied\" : \"input-available\";\n          const nextStreaming = approved !== false;\n\n          if (messageId) {\n            updateMessageById(messageId, (message) => {\n              if (!message.toolCall) {\n                return message;\n              }\n\n              // Don't overwrite terminal states — a late ApprovalRequestResolved\n              // arriving after cancel() must not flip a denied tool back to active.\n              const currentState = message.toolCall.state;\n              if (\n                currentState === \"output-denied\" ||\n                currentState === \"output-available\" ||\n                currentState === \"output-error\"\n              ) {\n                return {\n                  ...message,\n                  toolCall: {\n                    ...message.toolCall,\n                    approval: updatedApproval,\n                  },\n                };\n              }\n\n              return {\n                ...message,\n                isStreaming: nextStreaming,\n                toolCall: {\n                  ...message.toolCall,\n                  state: nextState,\n                  approval: updatedApproval,\n                  errorText:\n                    approved === false\n                      ? (updatedApproval.reason ?? message.toolCall.errorText)\n                      : message.toolCall.errorText,\n                },\n              };\n            });\n          }\n\n          if (pending) {\n            pendingApprovalRequestsRef.current.delete(pending.requestId);\n          } else {\n            pendingApprovalRequestsRef.current.delete(request_id);\n          }\n\n          break;\n        }\n\n        case \"QuestionRequest\": {\n          if (!isReplay) {\n            clearAwaitingFirstResponse();\n          }\n          const qPayload = (event as QuestionRequestEvent).payload;\n          const qtc = currentToolCallsRef.current.get(qPayload.tool_call_id);\n\n          const questionState = {\n            id: qPayload.id,\n            toolCallId: qPayload.tool_call_id,\n            questions: qPayload.questions,\n            rpcMessageId,\n            submitted: false,\n            resolved: false,\n          };\n\n          let qMessageId = qtc?.messageId;\n\n          if (qMessageId) {\n            updateMessageById(qMessageId, (message) => {\n              if (!message.toolCall) {\n                return message;\n              }\n              return {\n                ...message,\n                isStreaming: false,\n                toolCall: {\n                  ...message.toolCall,\n                  state: \"question-requested\",\n                  question: questionState,\n                },\n              };\n            });\n          } else {\n            const fallbackMessageId = getNextMessageId(\"assistant\");\n            const questionMessage: LiveMessage = {\n              id: fallbackMessageId,\n              role: \"assistant\",\n              variant: \"tool\",\n              isStreaming: false,\n              toolCall: {\n                title: \"AskUserQuestion\",\n                type: \"tool-call\" as ToolUIPart[\"type\"],\n                state: \"question-requested\",\n                question: questionState,\n              },\n            };\n\n            currentToolCallsRef.current.set(qPayload.tool_call_id, {\n              ...(currentToolCallsRef.current.get(qPayload.tool_call_id) ?? {\n                id: qPayload.tool_call_id,\n                name: \"AskUserQuestion\",\n                arguments: \"\",\n                argumentsComplete: false,\n              }),\n              messageId: fallbackMessageId,\n            });\n\n            setMessages((prev) => [...prev, questionMessage]);\n            qMessageId = fallbackMessageId;\n          }\n\n          pendingQuestionRequestsRef.current.set(qPayload.id, {\n            requestId: qPayload.id,\n            toolCallId: qPayload.tool_call_id,\n            messageId: qMessageId,\n            rpcId: rpcMessageId,\n            submitted: false,\n          });\n\n          break;\n        }\n\n        case \"SubagentEvent\": {\n          const subPayload = (event as SubagentEventWire).payload;\n          processSubagentEvent(\n            subPayload.task_tool_call_id,\n            subPayload.event.type,\n            subPayload.event.payload,\n          );\n          break;\n        }\n\n        case \"StatusUpdate\": {\n          const nextContextUsage = event.payload.context_usage;\n          if (typeof nextContextUsage === \"number\") {\n            setContextUsage(nextContextUsage);\n          }\n\n          const nextTokenUsage = event.payload.token_usage;\n          if (nextTokenUsage) {\n            setTokenUsage(nextTokenUsage);\n          }\n\n          const nextPlanMode = event.payload.plan_mode;\n          if (typeof nextPlanMode === \"boolean\") {\n            setPlanMode(nextPlanMode);\n          }\n\n          // If we have a message_id, create a special message to display it\n          const messageId = event.payload.message_id;\n          if (messageId) {\n            const displayMessageId = getNextMessageId(\"assistant\");\n            upsertMessage({\n              id: displayMessageId,\n              role: \"assistant\",\n              variant: \"message-id\",\n              messageId,\n            });\n          }\n\n          // Clear UI for /clear command (triggered by StatusUpdate after clear)\n          if (pendingClearRef.current) {\n            pendingClearRef.current = false;\n            setMessages((prev) => {\n              let lastUserMsgIndex = -1;\n              for (let i = prev.length - 1; i >= 0; i--) {\n                if (prev[i].role === \"user\") {\n                  lastUserMsgIndex = i;\n                  break;\n                }\n              }\n              return lastUserMsgIndex >= 0 ? prev.slice(lastUserMsgIndex) : [];\n            });\n          }\n          break;\n        }\n\n        case \"SessionNotice\": {\n          if (!isReplay) {\n            clearAwaitingFirstResponse();\n          }\n          if (event.payload.text) {\n            setMessages((prev) => [\n              ...prev,\n              {\n                id: getNextMessageId(\"assistant\"),\n                role: \"assistant\",\n                variant: \"status\",\n                content: event.payload.text,\n              },\n            ]);\n          }\n          break;\n        }\n\n        case \"StepInterrupted\": {\n          // Clear pending approval and question requests\n          pendingApprovalRequestsRef.current.clear();\n          pendingQuestionRequestsRef.current.clear();\n\n          setMessages((prev) =>\n            prev.map((msg) => {\n              let updated = msg;\n              if (msg.isStreaming) {\n                updated = { ...updated, isStreaming: false };\n              }\n              // Mark subagent as no longer running\n              if (msg.toolCall?.subagentRunning) {\n                updated = {\n                  ...updated,\n                  toolCall: {\n                    ...updated.toolCall!,\n                    subagentRunning: false,\n                  },\n                };\n              }\n              // Update pending approval tool states to denied\n              if (\n                msg.variant === \"tool\" &&\n                msg.toolCall?.state === \"approval-requested\"\n              ) {\n                return {\n                  ...updated,\n                  toolCall: {\n                    ...msg.toolCall,\n                    ...updated.toolCall,\n                    state: \"output-denied\",\n                    approval: msg.toolCall.approval\n                      ? {\n                          ...msg.toolCall.approval,\n                          submitted: true,\n                          resolved: true,\n                          approved: false,\n                          response: \"reject\",\n                        }\n                      : undefined,\n                  },\n                };\n              }\n              // Update pending question tool states to responded\n              if (\n                msg.variant === \"tool\" &&\n                msg.toolCall?.state === \"question-requested\"\n              ) {\n                return {\n                  ...updated,\n                  toolCall: {\n                    ...msg.toolCall,\n                    ...updated.toolCall,\n                    state: \"question-responded\",\n                    question: msg.toolCall.question\n                      ? {\n                          ...msg.toolCall.question,\n                          submitted: true,\n                          resolved: true,\n                        }\n                      : undefined,\n                  },\n                };\n              }\n              // Mark still-running tool calls as interrupted\n              if (\n                msg.variant === \"tool\" &&\n                (updated.toolCall?.state === \"input-streaming\" ||\n                  updated.toolCall?.state === \"input-available\")\n              ) {\n                return {\n                  ...updated,\n                  toolCall: {\n                    ...updated.toolCall,\n                    state: \"output-denied\",\n                  },\n                };\n              }\n              return updated;\n            }),\n          );\n          setAwaitingFirstResponse(false);\n          if (awaitingIdleRef.current) {\n            setStatus(\"submitted\");\n          } else {\n            setStatus(\"ready\");\n          }\n          break;\n        }\n\n        case \"CompactionBegin\": {\n          const compactionMsgId = getNextMessageId(\"assistant\");\n          compactionMessageIdRef.current = compactionMsgId;\n          setMessages((prev) => [\n            ...prev,\n            {\n              id: compactionMsgId,\n              role: \"assistant\",\n              variant: \"status\",\n              content: \"Compacting conversation history…\",\n              isStreaming: true,\n            },\n          ]);\n          break;\n        }\n\n        case \"CompactionEnd\": {\n          const compactMsgId = compactionMessageIdRef.current;\n          compactionMessageIdRef.current = null;\n          // Clear old messages after compaction, only keep the current turn\n          // Also remove the compaction indicator message\n          setMessages((prev) => {\n            let lastUserMsgIndex = -1;\n            for (let i = prev.length - 1; i >= 0; i--) {\n              if (prev[i].role === \"user\") {\n                lastUserMsgIndex = i;\n                break;\n              }\n            }\n            const kept = lastUserMsgIndex >= 0 ? prev.slice(lastUserMsgIndex) : [];\n            return compactMsgId ? kept.filter((m) => m.id !== compactMsgId) : kept;\n          });\n          break;\n        }\n\n        case \"MCPLoadingBegin\": {\n          const mcpMsgId = getNextMessageId(\"assistant\");\n          mcpLoadingMessageIdRef.current = mcpMsgId;\n          setMessages((prev) => [\n            ...prev,\n            {\n              id: mcpMsgId,\n              role: \"assistant\",\n              variant: \"status\",\n              content: \"Connecting to MCP servers…\",\n              isStreaming: true,\n            },\n          ]);\n          break;\n        }\n\n        case \"MCPLoadingEnd\": {\n          const mcpMsgId = mcpLoadingMessageIdRef.current;\n          mcpLoadingMessageIdRef.current = null;\n          if (mcpMsgId) {\n            setMessages((prev) => prev.filter((m) => m.id !== mcpMsgId));\n          }\n          break;\n        }\n\n        default:\n          break;\n      }\n    },\n    [\n      getNextMessageId,\n      setMessages,\n      resetStepState,\n      upsertMessage,\n      parseUserInput,\n      safeStringify,\n      clearAwaitingFirstResponse,\n      updateMessageById,\n      setAwaitingFirstResponse,\n      processSubagentEvent,\n    ],\n  );\n\n  // Helper to send initialize message\n  const sendInitialize = useCallback((ws: WebSocket) => {\n    const id = uuidV4();\n    initializeIdRef.current = id;\n    const message = {\n      jsonrpc: \"2.0\",\n      method: \"initialize\",\n      id,\n      params: {\n        protocol_version: \"1.5\",\n        client: {\n          name: \"kiwi\",\n          version: kimiCliVersion,\n        },\n        capabilities: {\n          supports_question: true,\n          supports_plan_mode: true,\n        },\n      },\n    };\n    ws.send(JSON.stringify(message));\n    console.log(\"[SessionStream] Sent initialize message\");\n  }, []);\n\n  // Handle incoming WebSocket message\n  const handleMessage = useCallback(\n    (data: string) => {\n      try {\n        const message: WireMessage = JSON.parse(data);\n\n        // Check for JSON-RPC error response\n        if (message.error) {\n          // Initialize failure during busy session is non-fatal - retry after delay\n          if (message.id === initializeIdRef.current) {\n            initializeRetryCountRef.current += 1;\n\n            if (initializeRetryCountRef.current > MAX_INITIALIZE_RETRIES) {\n              initializeIdRef.current = null;\n              initializeRetryCountRef.current = 0;\n              return;\n            }\n\n            initializeIdRef.current = null;\n\n            // Auto-retry initialize after 2 seconds\n            setTimeout(() => {\n              if (wsRef.current?.readyState === WebSocket.OPEN) {\n                sendInitialize(wsRef.current);\n              }\n            }, 2000);\n\n            return;\n          }\n\n          // Other errors remain fatal\n          console.error(\"[SessionStream] Received error:\", message.error);\n          const err = new Error(message.error.message || \"Unknown error\");\n          setError(err);\n          onError?.(err);\n          setStatus(\"error\");\n          setAwaitingFirstResponse(false);\n          awaitingIdleRef.current = false;\n          // Mark all streaming/subagent messages as complete\n          completeStreamingMessages();\n          return;\n        }\n\n        if (message.method === \"session_status\") {\n          if (historyCompleteTimeoutRef.current) {\n            window.clearTimeout(historyCompleteTimeoutRef.current);\n            historyCompleteTimeoutRef.current = null;\n          }\n          applySessionStatus(message.params as SessionStatusPayload);\n          return;\n        }\n\n        // Check for finished or cancelled status\n        if (\n          message.result?.status === \"finished\" ||\n          message.result?.status === \"cancelled\"\n        ) {\n          console.log(\n            `[SessionStream] Stream ${message.result.status}`,\n          );\n          setStatus(\"ready\");\n          setAwaitingFirstResponse(false);\n          awaitingIdleRef.current = false;\n          isReplayingRef.current = false;\n          setIsReplayingHistory(false);\n          completeStreamingMessages();\n          return;\n        }\n\n        // Check for replay_complete marker (custom event from server)\n        if (\n          message.method === \"event\" &&\n          (message.params as { type?: string })?.type === \"ReplayComplete\"\n        ) {\n          console.log(\"[SessionStream] Replay complete\");\n          isReplayingRef.current = false;\n          setIsReplayingHistory(false);\n          setStatus(\"ready\");\n          awaitingIdleRef.current = false;\n          return;\n        }\n\n        // Check for history_complete - history loaded but environment not ready yet\n        // This allows showing history while SSH connection is being established\n        if (message.method === \"history_complete\") {\n          console.log(\n            \"[SessionStream] History loaded, waiting for environment...\",\n          );\n          isReplayingRef.current = false;\n          // Keep status as \"submitted\" - input stays disabled until session_status\n          setStatus((current) => (current === \"ready\" ? current : \"submitted\"));\n\n          // Timeout fallback: reconnect if session_status not received within 15s\n          const currentWs = wsRef.current;\n          if (historyCompleteTimeoutRef.current) {\n            window.clearTimeout(historyCompleteTimeoutRef.current);\n          }\n          historyCompleteTimeoutRef.current = window.setTimeout(() => {\n            if (wsRef.current === currentWs) {\n              console.warn(\n                \"[SessionStream] session_status timeout after history_complete, reconnecting...\",\n              );\n              reconnectRef.current();\n            }\n          }, 15000);\n          return;\n        }\n\n        // Handle initialize response\n        if (message.id && message.id === initializeIdRef.current && message.result) {\n          initializeIdRef.current = null;\n          initializeRetryCountRef.current = 0;\n\n          const { slash_commands } = message.result;\n\n          if (slash_commands && slash_commands.length > 0) {\n            setSlashCommands(slash_commands);\n            slashCommandsLenRef.current = slash_commands.length;\n            usingCachedCommandsRef.current = false;\n          }\n          return;\n        }\n\n        // Handle approval/question requests sent as JSON-RPC requests\n        if (message.method === \"request\") {\n          const params = message.params as {\n            type?: string;\n            payload?: unknown;\n          };\n\n          if (params?.type === \"ApprovalRequest\") {\n            const approvalEvent: ApprovalRequestEvent = {\n              type: \"ApprovalRequest\",\n              payload: params.payload as ApprovalRequestEvent[\"payload\"],\n            };\n            processEvent(\n              approvalEvent,\n              isReplayingRef.current,\n              message.id ?? (approvalEvent.payload.id as string | number),\n            );\n            return;\n          }\n\n          if (params?.type === \"QuestionRequest\") {\n            const questionEvent: QuestionRequestEvent = {\n              type: \"QuestionRequest\",\n              payload: params.payload as QuestionRequestEvent[\"payload\"],\n            };\n            processEvent(\n              questionEvent,\n              isReplayingRef.current,\n              message.id ?? (questionEvent.payload.id as string | number),\n            );\n            return;\n          }\n        }\n\n        // Process event\n        const event = extractEvent(message);\n        if (event) {\n          processEvent(event, isReplayingRef.current);\n        }\n      } catch (err) {\n        console.warn(\n          \"[SessionStream] Failed to parse WebSocket message:\",\n          data,\n          err,\n        );\n      }\n    },\n    [\n      processEvent,\n      onError,\n      setAwaitingFirstResponse,\n      applySessionStatus,\n      completeStreamingMessages,\n      sendInitialize,\n    ],\n  );\n\n  // Build WebSocket URL\n  const getWebSocketUrl = useCallback(\n    (sid: string): string => {\n      const token = getAuthToken();\n      if (baseUrl) {\n        // Convert HTTP URL to WebSocket URL\n        const url = baseUrl.replace(HTTP_TO_WS_REGEX, \"ws\");\n        const wsUrl = `${url}/api/sessions/${sid}/stream`;\n        return token ? `${wsUrl}?token=${encodeURIComponent(token)}` : wsUrl;\n      }\n\n      // Use current host\n      const protocol = window.location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n      const host = window.location.host;\n      const wsUrl = `${protocol}//${host}/api/sessions/${sid}/stream`;\n      return token ? `${wsUrl}?token=${encodeURIComponent(token)}` : wsUrl;\n    },\n    [baseUrl],\n  );\n\n  // Helper to send pending message\n  const sendPendingMessage = useCallback(\n    (ws: WebSocket) => {\n      const pendingMessage = pendingMessageRef.current;\n      if (pendingMessage) {\n        pendingMessageRef.current = null;\n        const message: WireMessage = {\n          jsonrpc: \"2.0\",\n          method: \"prompt\",\n          id: uuidV4(),\n          params: {\n            user_input: pendingMessage,\n          },\n        };\n        ws.send(JSON.stringify(message));\n        setAwaitingFirstResponse(true);\n        setStatus(\"streaming\");\n        console.log(\n          \"[SessionStream] Sent pending message after connect:\",\n          pendingMessage,\n        );\n      }\n    },\n    [setAwaitingFirstResponse],\n  );\n\n  const respondToApproval = useCallback(\n    async (\n      requestId: string,\n      response: ApprovalResponseDecision,\n      reason?: string,\n    ) => {\n      const ws = wsRef.current;\n      if (!ws || ws.readyState !== WebSocket.OPEN) {\n        throw new Error(\"Not connected to session stream\");\n      }\n\n      const pending = pendingApprovalRequestsRef.current.get(requestId);\n      if (!pending) {\n        throw new Error(\"Approval request not found\");\n      }\n\n      if (pending.submitted) {\n        return;\n      }\n\n      const trimmedReason =\n        typeof reason === \"string\" && reason.trim().length > 0\n          ? reason.trim()\n          : undefined;\n\n      const isApproved = response !== \"reject\";\n      const rejectionReason = response === \"reject\" ? trimmedReason : undefined;\n      const responseMessage: JsonRpcResponse = {\n        jsonrpc: \"2.0\",\n        id: pending.rpcId ?? requestId,\n        result: {\n          request_id: pending.requestId ?? requestId,\n          response,\n        },\n      };\n\n      try {\n        ws.send(JSON.stringify(responseMessage));\n      } catch (err) {\n        throw err instanceof Error ? err : new Error(String(err));\n      }\n\n      pending.submitted = true;\n      pendingApprovalRequestsRef.current.set(requestId, pending);\n\n      const tc = currentToolCallsRef.current.get(pending.toolCallId);\n      const nextState = isApproved ? \"input-available\" : \"output-denied\";\n      const nextStreaming = isApproved;\n\n      if (tc) {\n        const existingApproval = tc.approval ?? {\n          id: requestId,\n          action: \"\",\n          description: \"\",\n          sender: \"\",\n          toolCallId: pending.toolCallId,\n        };\n\n        const updatedApproval = {\n          ...existingApproval,\n          approved: isApproved,\n          reason: isApproved\n            ? existingApproval.reason\n            : (rejectionReason ?? existingApproval.reason),\n          submitted: true,\n          resolved: isApproved ? existingApproval.resolved : true,\n          response,\n        };\n\n        tc.approval = updatedApproval;\n\n        if (tc.messageId) {\n          updateMessageById(tc.messageId, (message) => {\n            if (!message.toolCall) {\n              return message;\n            }\n\n            return {\n              ...message,\n              isStreaming: nextStreaming,\n              toolCall: {\n                ...message.toolCall,\n                state: nextState,\n                approval: updatedApproval,\n                errorText: isApproved\n                  ? message.toolCall.errorText\n                  : (rejectionReason ?? message.toolCall.errorText),\n              },\n            };\n          });\n        }\n      }\n    },\n    [updateMessageById],\n  );\n\n  const respondToQuestion = useCallback(\n    async (requestId: string, answers: Record<string, string>) => {\n      const ws = wsRef.current;\n      if (!ws || ws.readyState !== WebSocket.OPEN) {\n        throw new Error(\"Not connected to session stream\");\n      }\n\n      const pending = pendingQuestionRequestsRef.current.get(requestId);\n      if (!pending) {\n        throw new Error(\"Question request not found\");\n      }\n\n      if (pending.submitted) {\n        return;\n      }\n\n      const responseMessage: JsonRpcResponse = {\n        jsonrpc: \"2.0\",\n        id: pending.rpcId ?? requestId,\n        result: {\n          request_id: pending.requestId ?? requestId,\n          answers,\n        },\n      };\n\n      try {\n        ws.send(JSON.stringify(responseMessage));\n      } catch (err) {\n        throw err instanceof Error ? err : new Error(String(err));\n      }\n\n      pending.submitted = true;\n      pendingQuestionRequestsRef.current.set(requestId, pending);\n\n      const tc = currentToolCallsRef.current.get(pending.toolCallId);\n\n      if (tc?.messageId) {\n        updateMessageById(tc.messageId, (message) => {\n          if (!message.toolCall) {\n            return message;\n          }\n\n          return {\n            ...message,\n            isStreaming: true,\n            toolCall: {\n              ...message.toolCall,\n              state: \"question-responded\",\n              question: message.toolCall.question\n                ? {\n                    ...message.toolCall.question,\n                    submitted: true,\n                    answers,\n                  }\n                : undefined,\n            },\n          };\n        });\n      }\n    },\n    [updateMessageById],\n  );\n\n  // Connect to WebSocket\n  const connect = useCallback(() => {\n    if (!sessionId) return;\n\n    initializeRetryCountRef.current = 0; // Reset retry count for new connection\n\n    // Close existing connection\n    if (wsRef.current) {\n      console.log(\"[SessionStream] Closing existing WebSocket\");\n      wsRef.current.close();\n      wsRef.current = null;\n    }\n    if (watchdogIntervalRef.current !== null) {\n      window.clearInterval(watchdogIntervalRef.current);\n      watchdogIntervalRef.current = null;\n    }\n\n    awaitingIdleRef.current = false;\n    resetState(true);  // preserve slashCommands on reconnect\n    setMessages([]);\n    setStatus(\"submitted\");\n    setAwaitingFirstResponse(Boolean(pendingMessageRef.current));\n\n    const wsUrl = getWebSocketUrl(sessionId);\n\n    try {\n      const ws = new WebSocket(wsUrl);\n      // Mark this socket as the \"current attempt\" immediately.\n      // If the user switches sessions before `onopen`, `disconnect()` will clear `wsRef.current`,\n      // and any late callbacks from this `ws` will be ignored by the identity guard.\n      wsRef.current = ws;\n\n      ws.onopen = () => {\n        if (wsRef.current !== ws) {\n          ws.close();\n          return;\n        }\n\n        console.log(\"[SessionStream] Connected to session:\", sessionId);\n        setIsConnected(true);\n        setError(null);\n        awaitingIdleRef.current = false;\n        setStatus(\"streaming\"); // Will receive replay, then switch to ready\n        lastWsMessageTimeRef.current = Date.now();\n\n        // Start stale-connection watchdog\n        if (watchdogIntervalRef.current !== null) {\n          window.clearInterval(watchdogIntervalRef.current);\n          watchdogIntervalRef.current = null;\n        }\n        const watchdogIntervalId = window.setInterval(() => {\n          if (!wsRef.current || wsRef.current !== ws) {\n            // This ws is no longer current — stop checking this watchdog.\n            window.clearInterval(watchdogIntervalId);\n            if (watchdogIntervalRef.current === watchdogIntervalId) {\n              watchdogIntervalRef.current = null;\n            }\n            return;\n          }\n          if (wsRef.current.readyState !== WebSocket.OPEN) return;\n          const elapsed = Date.now() - lastWsMessageTimeRef.current;\n          if (elapsed > 45_000 && statusRef.current === \"streaming\") {\n            console.warn(\n              `[SessionStream] Watchdog: no messages for ${Math.round(elapsed / 1000)}s while streaming, reconnecting...`,\n            );\n            reconnectRef.current();\n          }\n        }, 10_000);\n        watchdogIntervalRef.current = watchdogIntervalId;\n\n        // Send initialize message to get slash commands\n        sendInitialize(ws);\n\n        // Send pending message immediately after connection\n        sendPendingMessage(ws);\n      };\n\n      ws.onmessage = (event) => {\n        if (wsRef.current !== ws) {\n          return;\n        }\n\n        lastWsMessageTimeRef.current = Date.now();\n        handleMessage(event.data);\n      };\n\n      ws.onerror = (event) => {\n        if (wsRef.current !== ws) {\n          return;\n        }\n\n        console.error(\"[SessionStream] WebSocket error:\", event);\n        const err = new Error(\"WebSocket connection error\");\n        setError(err);\n        onError?.(err);\n        setAwaitingFirstResponse(false);\n        awaitingIdleRef.current = false;\n        pendingMessageRef.current = null; // Clear pending message on error\n      };\n\n      ws.onclose = (event) => {\n        if (wsRef.current !== ws) {\n          return;\n        }\n\n        console.log(\"[SessionStream] Disconnected:\", event.code, event.reason);\n        setIsConnected(false);\n        wsRef.current = null;\n        pendingMessageRef.current = null; // Clear pending message on close\n        pendingApprovalRequestsRef.current.clear();\n        awaitingIdleRef.current = false;\n        setAwaitingFirstResponse(false);\n        setSessionStatus(null);\n        lastStatusSeqRef.current = null;\n        if (watchdogIntervalRef.current !== null) {\n          window.clearInterval(watchdogIntervalRef.current);\n          watchdogIntervalRef.current = null;\n        }\n\n        // Handle specific close codes\n        if (event.code === 4004) {\n          const err = new Error(\"Session not found\");\n          setError(err);\n          onError?.(err);\n        } else if (event.code === 4029) {\n          const err = new Error(\"Too many concurrent sessions\");\n          setError(err);\n          onError?.(err);\n        }\n\n        // Mark all streaming/subagent messages as complete\n        completeStreamingMessages();\n        setStatus(\"ready\");\n      };\n    } catch (err) {\n      console.error(\"[SessionStream] Failed to connect:\", err);\n      const connectionError =\n        err instanceof Error ? err : new Error(String(err));\n      setError(connectionError);\n      onError?.(connectionError);\n      awaitingIdleRef.current = false;\n      setAwaitingFirstResponse(false);\n      setStatus(\"error\");\n      pendingMessageRef.current = null; // Clear pending message on error\n    }\n  }, [\n    sessionId,\n    resetState,\n    setMessages,\n    getWebSocketUrl,\n    handleMessage,\n    onError,\n    sendInitialize,\n    sendPendingMessage,\n    setAwaitingFirstResponse,\n    completeStreamingMessages,\n  ]);\n\n  // Send cancel message to server\n  // Disconnect\n  const disconnect = useCallback(() => {\n    if (reconnectTimeoutRef.current !== null) {\n      window.clearTimeout(reconnectTimeoutRef.current);\n      reconnectTimeoutRef.current = null;\n    }\n\n    if (watchdogIntervalRef.current !== null) {\n      window.clearInterval(watchdogIntervalRef.current);\n      watchdogIntervalRef.current = null;\n    }\n\n    if (wsRef.current) {\n      wsRef.current.close();\n      wsRef.current = null;\n    }\n\n    awaitingIdleRef.current = false;\n    setAwaitingFirstResponse(false);\n    pendingMessageRef.current = null;\n    setIsConnected(false);\n    setStatus(\"ready\");\n    setSessionStatus(null);\n    lastStatusSeqRef.current = null;\n    pendingApprovalRequestsRef.current.clear();\n    pendingQuestionRequestsRef.current.clear();\n\n    // Remove lingering MCP loading indicator (e.g. MCPLoadingEnd was never received)\n    const mcpMsgId = mcpLoadingMessageIdRef.current;\n    if (mcpMsgId) {\n      mcpLoadingMessageIdRef.current = null;\n      setMessages((prev) => prev.filter((m) => m.id !== mcpMsgId));\n    }\n\n    // Mark all streaming/subagent messages as complete\n    completeStreamingMessages();\n  }, [completeStreamingMessages, setAwaitingFirstResponse, setMessages]);\n\n  // Send cancel request or disconnect if stream not ready\n  const cancel = useCallback(() => {\n    const ws = wsRef.current;\n    if (!ws || ws.readyState !== WebSocket.OPEN) {\n      console.log(\n        \"[SessionStream] Cancel requested before stream is ready, disconnecting instead\",\n      );\n      awaitingIdleRef.current = false;\n      pendingMessageRef.current = null;\n      // Clear pending approval/question requests and update message states\n      pendingApprovalRequestsRef.current.clear();\n      pendingQuestionRequestsRef.current.clear();\n      setMessages((prev) =>\n        prev.map((msg) => {\n          if (\n            msg.variant === \"tool\" &&\n            msg.toolCall?.state === \"approval-requested\"\n          ) {\n            return {\n              ...msg,\n              isStreaming: false,\n              toolCall: {\n                ...msg.toolCall,\n                state: \"output-denied\",\n                approval: msg.toolCall.approval\n                  ? {\n                      ...msg.toolCall.approval,\n                      submitted: true,\n                      resolved: true,\n                      approved: false,\n                      response: \"reject\",\n                    }\n                  : undefined,\n              },\n            };\n          }\n          if (\n            msg.variant === \"tool\" &&\n            msg.toolCall?.state === \"question-requested\"\n          ) {\n            return {\n              ...msg,\n              isStreaming: false,\n              toolCall: {\n                ...msg.toolCall,\n                state: \"question-responded\",\n                question: msg.toolCall.question\n                  ? {\n                      ...msg.toolCall.question,\n                      submitted: true,\n                      resolved: true,\n                    }\n                  : undefined,\n              },\n            };\n          }\n          // Mark still-running tool calls as interrupted\n          if (\n            msg.variant === \"tool\" &&\n            (msg.toolCall?.state === \"input-streaming\" ||\n              msg.toolCall?.state === \"input-available\")\n          ) {\n            return {\n              ...msg,\n              isStreaming: false,\n              toolCall: {\n                ...msg.toolCall,\n                state: \"output-denied\",\n              },\n            };\n          }\n          return msg;\n        }),\n      );\n      disconnect();\n      return;\n    }\n\n    // Clear all pending approval/question requests and update message states\n    pendingApprovalRequestsRef.current.clear();\n    pendingQuestionRequestsRef.current.clear();\n\n    // Always update messages (consistent with StepInterrupted handler)\n    setMessages((prev) =>\n      prev.map((msg) => {\n        if (\n          msg.variant === \"tool\" &&\n          msg.toolCall?.state === \"approval-requested\"\n        ) {\n          return {\n            ...msg,\n            isStreaming: false,\n            toolCall: {\n              ...msg.toolCall,\n              state: \"output-denied\",\n              approval: msg.toolCall.approval\n                ? {\n                    ...msg.toolCall.approval,\n                    submitted: true,\n                    resolved: true,\n                    approved: false,\n                    response: \"reject\",\n                  }\n                : undefined,\n            },\n          };\n        }\n        if (\n          msg.variant === \"tool\" &&\n          msg.toolCall?.state === \"question-requested\"\n        ) {\n          return {\n            ...msg,\n            isStreaming: false,\n            toolCall: {\n              ...msg.toolCall,\n              state: \"question-responded\",\n              question: msg.toolCall.question\n                ? {\n                    ...msg.toolCall.question,\n                    submitted: true,\n                    resolved: true,\n                  }\n                : undefined,\n            },\n          };\n        }\n        // Mark still-running tool calls as interrupted\n        if (\n          msg.variant === \"tool\" &&\n          (msg.toolCall?.state === \"input-streaming\" ||\n            msg.toolCall?.state === \"input-available\")\n        ) {\n          return {\n            ...msg,\n            isStreaming: false,\n            toolCall: {\n              ...msg.toolCall,\n              state: \"output-denied\",\n            },\n          };\n        }\n        return msg;\n      }),\n    );\n\n    const cancelMessage: JsonRpcRequest = {\n      jsonrpc: \"2.0\",\n      method: \"cancel\",\n      id: uuidV4(),\n    };\n\n    try {\n      console.log(\"[SessionStream] Sending cancel request\");\n      ws.send(JSON.stringify(cancelMessage));\n      const shouldAwaitIdle = status === \"streaming\" || status === \"submitted\";\n      awaitingIdleRef.current = shouldAwaitIdle;\n      if (status === \"streaming\") {\n        setStatus(\"submitted\");\n      }\n      setAwaitingFirstResponse(false);\n    } catch (err) {\n      console.error(\"[SessionStream] Failed to send cancel request:\", err);\n    }\n  }, [status, disconnect, setAwaitingFirstResponse, setMessages]);\n\n  // Reconnect\n  const reconnect = useCallback(() => {\n    disconnect();\n    // Small delay before reconnecting\n    reconnectTimeoutRef.current = window.setTimeout(() => {\n      connect();\n    }, 100);\n  }, [disconnect, connect]);\n\n  // Keep refs in sync so useLayoutEffect can use stable references\n  connectRef.current = connect;\n  disconnectRef.current = disconnect;\n  reconnectRef.current = reconnect;\n  resetStateRef.current = resetState;\n  statusRef.current = status;\n\n  // Send message to session (auto-connects if not connected)\n  const sendMessage = useCallback(\n    async (text: string) => {\n      if (!text.trim()) return;\n\n      const trimmedText = text.trim();\n      setAwaitingFirstResponse(true);\n\n      // If not connected, store the message and connect\n      if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {\n        if (!sessionId) {\n          throw new Error(\"No session selected\");\n        }\n\n        pendingMessageRef.current = trimmedText;\n        connect();\n        return;\n      }\n\n      // Send as JSON-RPC prompt message\n      const message: WireMessage = {\n        jsonrpc: \"2.0\",\n        method: \"prompt\",\n        id: uuidV4(),\n        params: {\n          user_input: trimmedText,\n        },\n      };\n\n      wsRef.current.send(JSON.stringify(message));\n      awaitingIdleRef.current = false;\n      setStatus(\"streaming\");\n    },\n    [sessionId, connect, setAwaitingFirstResponse],\n  );\n\n  // Clear messages\n  const clearMessages = useCallback(() => {\n    setMessages([]);\n    resetStateRef.current(true);\n  }, [setMessages]);\n\n  // Set plan mode via silent RPC (no context message)\n  const sendSetPlanMode = useCallback((enabled: boolean) => {\n    if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {\n      return;\n    }\n    const message: JsonRpcRequest = {\n      jsonrpc: \"2.0\",\n      method: \"set_plan_mode\",\n      id: uuidV4(),\n      params: { enabled },\n    };\n    wsRef.current.send(JSON.stringify(message));\n  }, []);\n\n  // Auto-connect when sessionId changes\n  useLayoutEffect(() => {\n    /**\n     * Session switches must be \"atomic\" from the UI's perspective:\n     * - stop old stream\n     * - clear per-session accumulators\n     * - optionally connect to the new session\n     *\n     * We use `useLayoutEffect` (instead of `useEffect`) so teardown happens before paint,\n     * minimizing the chance that the next screen renders while the previous socket still\n     * pushes messages.\n     *\n     * Even if a late event slips through, callback identity guards ensure it can't mutate\n     * state unless it belongs to the current `wsRef.current`.\n     *\n     * We access connect/disconnect via refs to avoid re-running this effect when their\n     * callback identity changes (which would cause disconnect→connect cycles).\n     */\n    // When sessionId changes, disconnect from previous session\n    if (wsRef.current) {\n      disconnectRef.current();\n    }\n\n    // Reset state for new session (preserve slash commands to avoid empty gap before initialize response)\n    resetStateRef.current(true);\n    setMessages([]);\n    useToolEventsStore.getState().clearTodoItems();\n\n    // Auto-connect if we have a valid sessionId\n    if (sessionId) {\n      // Small delay to ensure state is settled\n      const timeoutId = window.setTimeout(() => {\n        connectRef.current();\n      }, 50);\n      return () => {\n        window.clearTimeout(timeoutId);\n        disconnectRef.current();\n      };\n    }\n\n    setIsReplayingHistory(false);\n    return () => {\n      disconnectRef.current();\n    };\n  }, [sessionId, setMessages]);\n\n  // Cleanup on unmount\n  useEffect(\n    () => () => {\n      if (reconnectTimeoutRef.current !== null) {\n        window.clearTimeout(reconnectTimeoutRef.current);\n      }\n      if (watchdogIntervalRef.current !== null) {\n        window.clearInterval(watchdogIntervalRef.current);\n      }\n      if (wsRef.current) {\n        wsRef.current.close();\n      }\n    },\n    [],\n  );\n\n  return {\n    messages,\n    status,\n    sessionStatus,\n    isAwaitingFirstResponse,\n    contextUsage,\n    tokenUsage,\n    currentStep,\n    isConnected,\n    isReplayingHistory,\n    sendMessage,\n    respondToApproval,\n    respondToQuestion,\n    cancel,\n    disconnect,\n    reconnect,\n    connect,\n    setMessages,\n    clearMessages,\n    error,\n    planMode,\n    sendSetPlanMode,\n    slashCommands,\n  };\n}\n"
  },
  {
    "path": "web/src/hooks/useSessions.ts",
    "content": "import { useState, useCallback, useEffect, useRef } from \"react\";\nimport type {\n  Session,\n  UploadSessionFileResponse,\n  SessionStatus,\n} from \"../lib/api/models\";\nimport { SessionFromJSON } from \"../lib/api/models/Session\";\nimport { apiClient } from \"../lib/apiClient\";\nimport { getAuthHeader, getAuthToken } from \"../lib/auth\";\nimport { formatRelativeTime, getApiBaseUrl } from \"./utils\";\n\n// Regex patterns for path normalization\nconst LEADING_DOT_SLASH_REGEX = /^\\.\\/+/;\nconst LEADING_SLASH_REGEX = /^\\/+/;\nconst TRAILING_WHITESPACE_REGEX = /\\s+$/;\n\nexport type SessionFileEntry = {\n  name: string;\n  type: \"directory\" | \"file\";\n  size?: number;\n};\n\ntype UseSessionsReturn = {\n  /** List of sessions (API Session type) */\n  sessions: Session[];\n  /** List of archived sessions */\n  archivedSessions: Session[];\n  /** Currently selected session ID */\n  selectedSessionId: string;\n  /** Loading state */\n  isLoading: boolean;\n  /** Loading state for archived sessions */\n  isLoadingArchived: boolean;\n  /** Error message if any */\n  error: string | null;\n  /** Refresh sessions list from API */\n  refreshSessions: () => Promise<void>;\n  /** Refresh archived sessions list from API */\n  refreshArchivedSessions: () => Promise<void>;\n  /** Load more sessions for pagination */\n  loadMoreSessions: () => Promise<void>;\n  /** Load more archived sessions for pagination */\n  loadMoreArchivedSessions: () => Promise<void>;\n  /** Whether there are more sessions to load */\n  hasMoreSessions: boolean;\n  /** Whether there are more archived sessions to load */\n  hasMoreArchivedSessions: boolean;\n  /** Loading state for pagination */\n  isLoadingMore: boolean;\n  /** Loading state for archived pagination */\n  isLoadingMoreArchived: boolean;\n  /** Current search query */\n  searchQuery: string;\n  /** Update search query */\n  setSearchQuery: (query: string) => void;\n  /** Refresh a single session's data from API */\n  refreshSession: (sessionId: string) => Promise<Session | null>;\n  /** Create a new session */\n  createSession: (workDir?: string, createDir?: boolean) => Promise<Session>;\n  /** Delete a session by ID */\n  deleteSession: (sessionId: string) => Promise<boolean>;\n  /** Select a session */\n  selectSession: (sessionId: string) => void;\n  /** Apply a runtime session status update */\n  applySessionStatus: (status: SessionStatus) => void;\n  /** Get formatted relative time for a session */\n  getRelativeTime: (session: Session) => string;\n  /** Upload a file to a session's work_dir */\n  uploadSessionFile: (\n    sessionId: string,\n    file: File,\n  ) => Promise<UploadSessionFileResponse>;\n  /** List files in a session's work_dir path */\n  listSessionDirectory: (\n    sessionId: string,\n    path?: string,\n  ) => Promise<SessionFileEntry[]>;\n  /** Get a file from a session's work_dir */\n  getSessionFile: (sessionId: string, path: string) => Promise<Blob>;\n  /** Get the URL for a session file (for direct access/download) */\n  getSessionFileUrl: (sessionId: string, path: string) => string;\n  /** Fetch available work directories */\n  fetchWorkDirs: () => Promise<string[]>;\n  /** Fetch the startup directory */\n  fetchStartupDir: () => Promise<string>;\n  /** Rename a session */\n  renameSession: (sessionId: string, title: string) => Promise<boolean>;\n  /** Generate title using AI (backend reads messages from wire.jsonl) */\n  generateTitle: (sessionId: string) => Promise<string | null>;\n  /** Archive a session */\n  archiveSession: (sessionId: string) => Promise<boolean>;\n  /** Unarchive a session */\n  unarchiveSession: (sessionId: string) => Promise<boolean>;\n  /** Bulk archive sessions */\n  bulkArchiveSessions: (sessionIds: string[]) => Promise<number>;\n  /** Bulk unarchive sessions */\n  bulkUnarchiveSessions: (sessionIds: string[]) => Promise<number>;\n  /** Bulk delete sessions */\n  bulkDeleteSessions: (sessionIds: string[]) => Promise<number>;\n  /** Fork a session at a specific turn index */\n  forkSession: (sessionId: string, turnIndex: number) => Promise<Session>;\n};\n\nconst normalizeSessionPath = (value?: string): string => {\n  if (!value) {\n    return \".\";\n  }\n  const trimmed = value.trim();\n  if (trimmed === \"\" || trimmed === \"/\" || trimmed === \".\") {\n    return \".\";\n  }\n  const stripped = trimmed\n    .replace(LEADING_DOT_SLASH_REGEX, \"\")\n    .replace(LEADING_SLASH_REGEX, \"\")\n    .replace(TRAILING_WHITESPACE_REGEX, \"\");\n  return stripped === \"\" ? \".\" : stripped;\n};\n\nconst PAGE_SIZE = 100;\nconst AUTO_REFRESH_MS = 30_000;\n\n/**\n * Custom error class for directory not found\n */\nexport class DirectoryNotFoundError extends Error {\n  isDirectoryNotFound = true;\n  constructor(message: string) {\n    super(message);\n    this.name = \"DirectoryNotFoundError\";\n  }\n}\n\n/**\n * Hook for managing sessions with real API calls\n */\nexport function useSessions(): UseSessionsReturn {\n  // Sessions list (using API Session type)\n  const [sessions, setSessions] = useState<Session[]>([]);\n\n  // Archived sessions list\n  const [archivedSessions, setArchivedSessions] = useState<Session[]>([]);\n\n  // Currently selected session\n  const [selectedSessionId, setSelectedSessionId] = useState<string>(\"\");\n\n  // Loading and error states\n  const [isLoading, setIsLoading] = useState(false);\n  const [isLoadingMore, setIsLoadingMore] = useState(false);\n  const [isLoadingArchived, setIsLoadingArchived] = useState(false);\n  const [isLoadingMoreArchived, setIsLoadingMoreArchived] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const [hasMoreSessions, setHasMoreSessions] = useState(true);\n  const [hasMoreArchivedSessions, setHasMoreArchivedSessions] = useState(true);\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const lastRefreshRef = useRef(0);\n\n  /**\n   * Refresh sessions list from API\n   */\n  const refreshSessions = useCallback(async () => {\n    setIsLoading(true);\n    setError(null);\n\n    try {\n      const sessionsList =\n        await apiClient.sessions.listSessionsApiSessionsGet({\n          limit: PAGE_SIZE,\n          offset: 0,\n          q: searchQuery.trim() || undefined,\n        });\n\n      // Update sessions list\n      setSessions(sessionsList);\n      setHasMoreSessions(sessionsList.length === PAGE_SIZE);\n      lastRefreshRef.current = Date.now();\n\n      // Don't auto-select first session - user can click on one or create a new one\n    } catch (err) {\n      const message =\n        err instanceof Error ? err.message : \"Failed to load sessions\";\n      setError(message);\n      console.error(\"Failed to refresh sessions:\", err);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [searchQuery]);\n\n  const loadMoreSessions = useCallback(async () => {\n    if (isLoadingMore || isLoading || !hasMoreSessions) {\n      return;\n    }\n    setIsLoadingMore(true);\n    setError(null);\n    try {\n      const offset = sessions.length;\n      const moreSessions =\n        await apiClient.sessions.listSessionsApiSessionsGet({\n          limit: PAGE_SIZE,\n          offset,\n          q: searchQuery.trim() || undefined,\n        });\n      setSessions((current) => [...current, ...moreSessions]);\n      setHasMoreSessions(moreSessions.length === PAGE_SIZE);\n      lastRefreshRef.current = Date.now();\n    } catch (err) {\n      const message =\n        err instanceof Error ? err.message : \"Failed to load more sessions\";\n      setError(message);\n      console.error(\"Failed to load more sessions:\", err);\n    } finally {\n      setIsLoadingMore(false);\n    }\n  }, [hasMoreSessions, isLoading, isLoadingMore, searchQuery, sessions.length]);\n\n  const applySessionStatus = useCallback((status: SessionStatus) => {\n    setSessions((current) =>\n      current.map((session) =>\n        session.sessionId === status.sessionId\n          ? { ...session, status }\n          : session,\n      ),\n    );\n  }, []);\n\n  /**\n   * Refresh archived sessions list from API\n   */\n  const refreshArchivedSessions = useCallback(async () => {\n    setIsLoadingArchived(true);\n    try {\n      const basePath = getApiBaseUrl();\n      const response = await fetch(\n        `${basePath}/api/sessions/?archived=true&limit=${PAGE_SIZE}`,\n        {\n          headers: getAuthHeader(),\n        },\n      );\n      if (!response.ok) {\n        throw new Error(\"Failed to load archived sessions\");\n      }\n      const data = await response.json();\n      // Convert snake_case to camelCase\n      const archivedList: Session[] = data.map(\n        (item: Record<string, unknown>) => ({\n          sessionId: item.session_id,\n          title: item.title,\n          lastUpdated: new Date(item.last_updated as string),\n          isRunning: item.is_running,\n          status: item.status,\n          workDir: item.work_dir,\n          sessionDir: item.session_dir,\n          archived: item.archived,\n        }),\n      );\n      setArchivedSessions(archivedList);\n      setHasMoreArchivedSessions(archivedList.length === PAGE_SIZE);\n    } catch (err) {\n      console.error(\"Failed to refresh archived sessions:\", err);\n    } finally {\n      setIsLoadingArchived(false);\n    }\n  }, []);\n\n  /**\n   * Load more archived sessions for pagination\n   */\n  const loadMoreArchivedSessions = useCallback(async () => {\n    if (isLoadingMoreArchived || isLoadingArchived || !hasMoreArchivedSessions) {\n      return;\n    }\n    setIsLoadingMoreArchived(true);\n    try {\n      const basePath = getApiBaseUrl();\n      const offset = archivedSessions.length;\n      const response = await fetch(\n        `${basePath}/api/sessions/?archived=true&limit=${PAGE_SIZE}&offset=${offset}`,\n        {\n          headers: getAuthHeader(),\n        },\n      );\n      if (!response.ok) {\n        throw new Error(\"Failed to load more archived sessions\");\n      }\n      const data = await response.json();\n      const moreArchived: Session[] = data.map(\n        (item: Record<string, unknown>) => ({\n          sessionId: item.session_id,\n          title: item.title,\n          lastUpdated: new Date(item.last_updated as string),\n          isRunning: item.is_running,\n          status: item.status,\n          workDir: item.work_dir,\n          sessionDir: item.session_dir,\n          archived: item.archived,\n        }),\n      );\n      setArchivedSessions((current) => [...current, ...moreArchived]);\n      setHasMoreArchivedSessions(moreArchived.length === PAGE_SIZE);\n    } catch (err) {\n      console.error(\"Failed to load more archived sessions:\", err);\n    } finally {\n      setIsLoadingMoreArchived(false);\n    }\n  }, [\n    archivedSessions.length,\n    hasMoreArchivedSessions,\n    isLoadingArchived,\n    isLoadingMoreArchived,\n  ]);\n\n  // Refresh sessions list when search changes\n  useEffect(() => {\n    refreshSessions();\n  }, [refreshSessions]);\n\n  // Load archived sessions on initial mount (for showing the count)\n  useEffect(() => {\n    refreshArchivedSessions();\n  }, [refreshArchivedSessions]);\n\n  // Refresh when returning to the tab (throttled)\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState !== \"visible\") {\n        return;\n      }\n      const now = Date.now();\n      if (now - lastRefreshRef.current < 60_000) {\n        return;\n      }\n      refreshSessions();\n    };\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n  }, [refreshSessions]);\n\n  // Periodic refresh to catch sessions created outside the web UI\n  useEffect(() => {\n    if (searchQuery.trim()) {\n      return;\n    }\n    const interval = window.setInterval(() => {\n      if (document.visibilityState !== \"visible\") {\n        return;\n      }\n      if (isLoading || isLoadingMore) {\n        return;\n      }\n      refreshSessions();\n    }, AUTO_REFRESH_MS);\n    return () => window.clearInterval(interval);\n  }, [isLoading, isLoadingMore, refreshSessions, searchQuery]);\n\n  /**\n   * Refresh a single session's data from API\n   * Returns: Session (API type) or null if not found\n   * @param sessionId - The session ID to refresh\n   */\n  const refreshSession = useCallback(\n    async (sessionId: string): Promise<Session | null> => {\n      try {\n        const session =\n          await apiClient.sessions.getSessionApiSessionsSessionIdGet({\n            sessionId,\n          });\n\n        const isArchived = Boolean(session.archived);\n\n        if (isArchived) {\n          // Update archived list and ensure it doesn't appear in active list\n          setArchivedSessions((current) => {\n            const exists = current.some((s) => s.sessionId === sessionId);\n            if (!exists) {\n              return [session, ...current];\n            }\n            return current.map((s) =>\n              s.sessionId === sessionId ? session : s,\n            );\n          });\n          setSessions((current) =>\n            current.filter((s) => s.sessionId !== sessionId),\n          );\n        } else {\n          // Update active list and ensure it doesn't appear in archived list\n          setSessions((current) => {\n            const exists = current.some((s) => s.sessionId === sessionId);\n            if (!exists) {\n              return [session, ...current];\n            }\n            return current.map((s) =>\n              s.sessionId === sessionId ? session : s,\n            );\n          });\n          setArchivedSessions((current) =>\n            current.filter((s) => s.sessionId !== sessionId),\n          );\n        }\n\n        return session;\n      } catch (err) {\n        console.error(\"Failed to refresh session:\", sessionId, err);\n        return null;\n      }\n    },\n    [],\n  );\n\n  /**\n   * Create a new session\n   * Returns: Session (API type)\n   * @param workDir - Optional working directory for the session\n   * @param createDir - Whether to auto-create directory if it doesn't exist\n   */\n  const createSession = useCallback(\n    async (workDir?: string, createDir?: boolean): Promise<Session> => {\n      setIsLoading(true);\n      setError(null);\n      try {\n        // Use fetch directly to support the work_dir parameter\n        const basePath = getApiBaseUrl();\n        const body: { work_dir?: string; create_dir?: boolean } = {};\n        if (workDir) {\n          body.work_dir = workDir;\n        }\n        if (createDir) {\n          body.create_dir = createDir;\n        }\n        const response = await fetch(`${basePath}/api/sessions/`, {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n            ...getAuthHeader(),\n          },\n          body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,\n        });\n\n        if (!response.ok) {\n          const data = await response.json();\n          // Check for 404 with \"Directory does not exist\" message\n          if (\n            response.status === 404 &&\n            typeof data.detail === \"string\" &&\n            data.detail.includes(\"Directory does not exist\")\n          ) {\n            throw new DirectoryNotFoundError(data.detail);\n          }\n          throw new Error(data.detail || \"Failed to create session\");\n        }\n\n        const sessionData = await response.json();\n        const session = SessionFromJSON(sessionData);\n\n        // Update sessions list (add to beginning)\n        setSessions((current) => [session, ...current]);\n\n        // Select the new session\n        setSelectedSessionId(session.sessionId);\n\n        return session;\n      } catch (err) {\n        // Re-throw DirectoryNotFoundError without setting global error\n        // Use property check instead of instanceof for reliability\n        if (\n          err instanceof Error &&\n          \"isDirectoryNotFound\" in err &&\n          (err as DirectoryNotFoundError).isDirectoryNotFound\n        ) {\n          throw err;\n        }\n        const message =\n          err instanceof Error ? err.message : \"Failed to create session\";\n        setError(message);\n        throw err;\n      } finally {\n        setIsLoading(false);\n      }\n    },\n    [],\n  );\n\n  /**\n   * Delete a session\n   */\n  const deleteSession = useCallback(\n    async (sessionId: string): Promise<boolean> => {\n      setIsLoading(true);\n      setError(null);\n\n      try {\n        await apiClient.sessions.deleteSessionApiSessionsSessionIdDelete({\n          sessionId,\n        });\n\n        // Update sessions list\n        setSessions((current) => {\n          const next = current.filter((s) => s.sessionId !== sessionId);\n\n          // If we deleted the selected session, select the first remaining one\n          if (sessionId === selectedSessionId && next.length > 0) {\n            setSelectedSessionId(next[0].sessionId);\n          } else if (next.length === 0) {\n            setSelectedSessionId(\"\");\n          }\n\n          return next;\n        });\n\n        return true;\n      } catch (err) {\n        const message =\n          err instanceof Error ? err.message : \"Failed to delete session\";\n        setError(message);\n        return false;\n      } finally {\n        setIsLoading(false);\n      }\n    },\n    [selectedSessionId],\n  );\n\n  /**\n   * Select a session\n   */\n  const selectSession = useCallback(\n    (sessionId: string) => {\n      console.log(\"[useSessions] Selecting session:\", sessionId);\n      setSelectedSessionId(sessionId);\n      if (!sessionId) {\n        return;\n      }\n      if (!sessions.some((s) => s.sessionId === sessionId)) {\n        refreshSession(sessionId);\n      }\n    },\n    [refreshSession, sessions],\n  );\n\n  /**\n   * Get formatted relative time for a session\n   */\n  const getRelativeTime = useCallback(\n    (session: Session): string => formatRelativeTime(session.lastUpdated),\n    [],\n  );\n\n  /**\n   * Upload a file to a session's work_dir\n   * Returns: UploadSessionFileResponse with path, filename, and size\n   */\n  const uploadSessionFile = useCallback(\n    async (\n      sessionId: string,\n      file: File,\n    ): Promise<UploadSessionFileResponse> => {\n      try {\n        const response =\n          await apiClient.sessions.uploadSessionFileApiSessionsSessionIdFilesPost(\n            {\n              sessionId,\n              file,\n            },\n          );\n        return response;\n      } catch (err) {\n        const message =\n          err instanceof Error ? err.message : \"Failed to upload file\";\n        setError(message);\n        throw err;\n      }\n    },\n    [],\n  );\n\n  /**\n   * List files/directories under a path within the session work_dir\n   */\n  const listSessionDirectory = useCallback(\n    async (sessionId: string, path?: string): Promise<SessionFileEntry[]> => {\n      // Note: We don't set global error here since file listing failures\n      // are handled locally by the session-files-panel component\n      const response =\n        await apiClient.sessions.getSessionFileApiSessionsSessionIdFilesPathGetRaw(\n          {\n            sessionId,\n            path: normalizeSessionPath(path),\n          },\n        );\n      const contentType =\n        response.raw.headers.get(\"content-type\") ?? \"application/octet-stream\";\n      if (!contentType.includes(\"application/json\")) {\n        throw new Error(\"Requested path is not a directory\");\n      }\n      const entries = (await response.value()) as SessionFileEntry[];\n      return entries;\n    },\n    [],\n  );\n\n  /**\n   * Get a file from a session's work_dir\n   * Returns: Blob of the file content\n   */\n  const getSessionFile = useCallback(\n    async (sessionId: string, path: string): Promise<Blob> => {\n      setError(null);\n      try {\n        const response =\n          await apiClient.sessions.getSessionFileApiSessionsSessionIdFilesPathGetRaw(\n            {\n              sessionId,\n              path: normalizeSessionPath(path),\n            },\n          );\n        const contentType =\n          response.raw.headers.get(\"content-type\") ??\n          \"application/octet-stream\";\n        if (contentType.includes(\"application/json\")) {\n          throw new Error(\"Requested path is a directory, not a file\");\n        }\n        return await response.raw.blob();\n      } catch (err) {\n        const message =\n          err instanceof Error ? err.message : \"Failed to get file\";\n        setError(message);\n        throw err;\n      }\n    },\n    [],\n  );\n\n  /**\n   * Get the URL for a session file (for direct access/download)\n   */\n  const getSessionFileUrl = useCallback(\n    (sessionId: string, path: string): string => {\n      const basePath = getApiBaseUrl();\n      const token = getAuthToken();\n      const tokenParam = token ? `?token=${encodeURIComponent(token)}` : \"\";\n      return `${basePath}/api/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(path)}${tokenParam}`;\n    },\n    [],\n  );\n\n  /**\n   * Fetch available work directories from the backend\n   */\n  const fetchWorkDirs = useCallback(async (): Promise<string[]> => {\n    const basePath = getApiBaseUrl();\n    const response = await fetch(`${basePath}/api/work-dirs/`, {\n      headers: getAuthHeader(),\n    });\n\n    if (!response.ok) {\n      throw new Error(\"Failed to fetch work directories\");\n    }\n\n    return response.json();\n  }, []);\n\n  /**\n   * Fetch the startup directory from the backend\n   */\n  const fetchStartupDir = useCallback(async (): Promise<string> => {\n    const basePath = getApiBaseUrl();\n    const response = await fetch(`${basePath}/api/work-dirs/startup`, {\n      headers: getAuthHeader(),\n    });\n\n    if (!response.ok) {\n      throw new Error(\"Failed to fetch startup directory\");\n    }\n\n    return response.json();\n  }, []);\n\n  /**\n   * Rename a session\n   */\n  const renameSession = useCallback(\n    async (sessionId: string, title: string): Promise<boolean> => {\n      try {\n        const basePath = getApiBaseUrl();\n        const response = await fetch(\n          `${basePath}/api/sessions/${encodeURIComponent(sessionId)}`,\n          {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              ...getAuthHeader(),\n            },\n            body: JSON.stringify({ title }),\n          },\n        );\n\n        if (!response.ok) {\n          const data = await response.json();\n          throw new Error(data.detail || \"Failed to rename session\");\n        }\n\n        // Refresh the session to get updated data\n        await refreshSession(sessionId);\n        return true;\n      } catch (err) {\n        console.error(\"Failed to rename session:\", err);\n        return false;\n      }\n    },\n    [refreshSession],\n  );\n\n  /**\n   * Generate title using AI\n   * Backend reads messages from wire.jsonl automatically\n   */\n  const generateTitle = useCallback(\n    async (sessionId: string): Promise<string | null> => {\n      try {\n        const basePath = getApiBaseUrl();\n        const response = await fetch(\n          `${basePath}/api/sessions/${encodeURIComponent(sessionId)}/generate-title`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              ...getAuthHeader(),\n            },\n            body: JSON.stringify({}),\n          },\n        );\n\n        if (!response.ok) {\n          const data = await response.json();\n          throw new Error(data.detail || \"Failed to generate title\");\n        }\n\n        const result = await response.json();\n        // Refresh the session to get updated data\n        await refreshSession(sessionId);\n        return result.title;\n      } catch (err) {\n        console.error(\"Failed to generate title:\", err);\n        return null;\n      }\n    },\n    [refreshSession],\n  );\n\n  /**\n   * Archive a session\n   */\n  const archiveSession = useCallback(\n    async (sessionId: string): Promise<boolean> => {\n      try {\n        const basePath = getApiBaseUrl();\n        const response = await fetch(\n          `${basePath}/api/sessions/${encodeURIComponent(sessionId)}`,\n          {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              ...getAuthHeader(),\n            },\n            body: JSON.stringify({ archived: true }),\n          },\n        );\n\n        if (!response.ok) {\n          const data = await response.json();\n          throw new Error(data.detail || \"Failed to archive session\");\n        }\n\n        // Move session from active to archived list\n        setSessions((current) => {\n          const next = current.filter((s) => s.sessionId !== sessionId);\n          // If we archived the selected session, select another one\n          if (sessionId === selectedSessionId) {\n            if (next.length > 0) {\n              setSelectedSessionId(next[0].sessionId);\n            } else {\n              setSelectedSessionId(\"\");\n            }\n          }\n          return next;\n        });\n\n        // Refresh archived sessions to get the updated list\n        await refreshArchivedSessions();\n\n        return true;\n      } catch (err) {\n        console.error(\"Failed to archive session:\", err);\n        return false;\n      }\n    },\n    [refreshArchivedSessions, selectedSessionId],\n  );\n\n  /**\n   * Unarchive a session\n   */\n  const unarchiveSession = useCallback(\n    async (sessionId: string): Promise<boolean> => {\n      try {\n        const basePath = getApiBaseUrl();\n        const response = await fetch(\n          `${basePath}/api/sessions/${encodeURIComponent(sessionId)}`,\n          {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              ...getAuthHeader(),\n            },\n            body: JSON.stringify({ archived: false }),\n          },\n        );\n\n        if (!response.ok) {\n          const data = await response.json();\n          throw new Error(data.detail || \"Failed to unarchive session\");\n        }\n\n        // Remove from archived list\n        setArchivedSessions((current) =>\n          current.filter((s) => s.sessionId !== sessionId),\n        );\n\n        // Refresh active sessions to get the updated list\n        await refreshSessions();\n\n        return true;\n      } catch (err) {\n        console.error(\"Failed to unarchive session:\", err);\n        return false;\n      }\n    },\n    [refreshSessions],\n  );\n\n  /**\n   * Bulk archive sessions\n   * Returns the number of successfully archived sessions\n   */\n  const bulkArchiveSessions = useCallback(\n    async (sessionIds: string[]): Promise<number> => {\n      const basePath = getApiBaseUrl();\n      let successCount = 0;\n\n      // Process in parallel with Promise.allSettled\n      const results = await Promise.allSettled(\n        sessionIds.map(async (sessionId) => {\n          const response = await fetch(\n            `${basePath}/api/sessions/${encodeURIComponent(sessionId)}`,\n            {\n              method: \"PATCH\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n                ...getAuthHeader(),\n              },\n              body: JSON.stringify({ archived: true }),\n            },\n          );\n          if (!response.ok) {\n            throw new Error(\"Failed to archive\");\n          }\n          return sessionId;\n        }),\n      );\n\n      const successfulIds: string[] = [];\n      for (const result of results) {\n        if (result.status === \"fulfilled\") {\n          successCount++;\n          successfulIds.push(result.value);\n        }\n      }\n\n      // Update state\n      if (successfulIds.length > 0) {\n        setSessions((current) => {\n          const next = current.filter(\n            (s) => !successfulIds.includes(s.sessionId),\n          );\n          // If we archived the selected session, select another one\n          if (successfulIds.includes(selectedSessionId)) {\n            if (next.length > 0) {\n              setSelectedSessionId(next[0].sessionId);\n            } else {\n              setSelectedSessionId(\"\");\n            }\n          }\n          return next;\n        });\n        await refreshArchivedSessions();\n      }\n\n      return successCount;\n    },\n    [refreshArchivedSessions, selectedSessionId],\n  );\n\n  /**\n   * Bulk unarchive sessions\n   * Returns the number of successfully unarchived sessions\n   */\n  const bulkUnarchiveSessions = useCallback(\n    async (sessionIds: string[]): Promise<number> => {\n      const basePath = getApiBaseUrl();\n      let successCount = 0;\n\n      const results = await Promise.allSettled(\n        sessionIds.map(async (sessionId) => {\n          const response = await fetch(\n            `${basePath}/api/sessions/${encodeURIComponent(sessionId)}`,\n            {\n              method: \"PATCH\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n                ...getAuthHeader(),\n              },\n              body: JSON.stringify({ archived: false }),\n            },\n          );\n          if (!response.ok) {\n            throw new Error(\"Failed to unarchive\");\n          }\n          return sessionId;\n        }),\n      );\n\n      const successfulIds: string[] = [];\n      for (const result of results) {\n        if (result.status === \"fulfilled\") {\n          successCount++;\n          successfulIds.push(result.value);\n        }\n      }\n\n      if (successfulIds.length > 0) {\n        setArchivedSessions((current) =>\n          current.filter((s) => !successfulIds.includes(s.sessionId)),\n        );\n        await refreshSessions();\n      }\n\n      return successCount;\n    },\n    [refreshSessions],\n  );\n\n  /**\n   * Bulk delete sessions\n   * Returns the number of successfully deleted sessions\n   */\n  const bulkDeleteSessions = useCallback(\n    async (sessionIds: string[]): Promise<number> => {\n      const basePath = getApiBaseUrl();\n      let successCount = 0;\n\n      const results = await Promise.allSettled(\n        sessionIds.map(async (sessionId) => {\n          const response = await fetch(\n            `${basePath}/api/sessions/${encodeURIComponent(sessionId)}`,\n            {\n              method: \"DELETE\",\n              headers: getAuthHeader(),\n            },\n          );\n          if (!response.ok) {\n            throw new Error(\"Failed to delete\");\n          }\n          return sessionId;\n        }),\n      );\n\n      const successfulIds: string[] = [];\n      for (const result of results) {\n        if (result.status === \"fulfilled\") {\n          successCount++;\n          successfulIds.push(result.value);\n        }\n      }\n\n      if (successfulIds.length > 0) {\n        setSessions((current) => {\n          const next = current.filter(\n            (s) => !successfulIds.includes(s.sessionId),\n          );\n          if (successfulIds.includes(selectedSessionId)) {\n            if (next.length > 0) {\n              setSelectedSessionId(next[0].sessionId);\n            } else {\n              setSelectedSessionId(\"\");\n            }\n          }\n          return next;\n        });\n        setArchivedSessions((current) =>\n          current.filter((s) => !successfulIds.includes(s.sessionId)),\n        );\n      }\n\n      return successCount;\n    },\n    [selectedSessionId],\n  );\n\n  /**\n   * Fork a session at a specific turn index\n   * Creates a new session with history up to the specified turn\n   */\n  const forkSession = useCallback(\n    async (sessionId: string, turnIndex: number): Promise<Session> => {\n      try {\n        const basePath = getApiBaseUrl();\n        const response = await fetch(\n          `${basePath}/api/sessions/${encodeURIComponent(sessionId)}/fork`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              ...getAuthHeader(),\n            },\n            body: JSON.stringify({ turn_index: turnIndex }),\n          },\n        );\n\n        if (!response.ok) {\n          const data = await response.json();\n          throw new Error(data.detail || \"Failed to fork session\");\n        }\n\n        const sessionData = await response.json();\n        const session = SessionFromJSON(sessionData);\n\n        // Add to sessions list\n        setSessions((current) => [session, ...current]);\n\n        // Auto-select the new session\n        setSelectedSessionId(session.sessionId);\n\n        return session;\n      } catch (err) {\n        const message =\n          err instanceof Error ? err.message : \"Failed to fork session\";\n        setError(message);\n        throw err;\n      }\n    },\n    [],\n  );\n\n  return {\n    sessions,\n    archivedSessions,\n    selectedSessionId,\n    isLoading,\n    isLoadingArchived,\n    error,\n    refreshSessions,\n    refreshArchivedSessions,\n    loadMoreSessions,\n    loadMoreArchivedSessions,\n    hasMoreSessions,\n    hasMoreArchivedSessions,\n    isLoadingMore,\n    isLoadingMoreArchived,\n    searchQuery,\n    setSearchQuery,\n    refreshSession,\n    createSession,\n    deleteSession,\n    selectSession,\n    applySessionStatus,\n    getRelativeTime,\n    uploadSessionFile,\n    listSessionDirectory,\n    getSessionFile,\n    getSessionFileUrl,\n    fetchWorkDirs,\n    fetchStartupDir,\n    renameSession,\n    generateTitle,\n    archiveSession,\n    unarchiveSession,\n    bulkArchiveSessions,\n    bulkUnarchiveSessions,\n    bulkDeleteSessions,\n    forkSession,\n  };\n}\n"
  },
  {
    "path": "web/src/hooks/useVideoThumbnail.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nexport function useVideoThumbnail(url?: string): string | null {\n  const [poster, setPoster] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (!url) {\n      setPoster(null);\n      return;\n    }\n\n    let cancelled = false;\n    setPoster(null);\n\n    const video = document.createElement(\"video\");\n    video.muted = true;\n    video.playsInline = true;\n    video.preload = \"metadata\";\n    video.crossOrigin = \"anonymous\";\n\n    const capture = () => {\n      if (cancelled) {\n        return;\n      }\n      if (!(video.videoWidth && video.videoHeight)) {\n        return;\n      }\n      const canvas = document.createElement(\"canvas\");\n      canvas.width = video.videoWidth;\n      canvas.height = video.videoHeight;\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) {\n        return;\n      }\n      try {\n        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);\n        const dataUrl = canvas.toDataURL(\"image/jpeg\", 0.7);\n        if (!cancelled) {\n          setPoster(dataUrl);\n        }\n      } catch {\n        // Ignore CORS/tainted canvas errors and fall back to native video preview.\n      }\n    };\n\n    const handleLoadedMetadata = () => {\n      const duration = video.duration;\n      if (Number.isFinite(duration) && duration > 0) {\n        const targetTime = Math.min(0.1, duration / 2);\n        try {\n          video.currentTime = targetTime;\n        } catch {\n          // Some browsers may reject the seek; rely on loadeddata instead.\n        }\n      }\n    };\n\n    const handleLoadedData = () => {\n      capture();\n    };\n\n    const handleSeeked = () => {\n      capture();\n    };\n\n    video.addEventListener(\"loadedmetadata\", handleLoadedMetadata);\n    video.addEventListener(\"loadeddata\", handleLoadedData);\n    video.addEventListener(\"seeked\", handleSeeked);\n    video.src = url;\n    video.load();\n\n    return () => {\n      cancelled = true;\n      video.removeEventListener(\"loadedmetadata\", handleLoadedMetadata);\n      video.removeEventListener(\"loadeddata\", handleLoadedData);\n      video.removeEventListener(\"seeked\", handleSeeked);\n      video.removeAttribute(\"src\");\n      video.load();\n    };\n  }, [url]);\n\n  return poster;\n}\n"
  },
  {
    "path": "web/src/hooks/utils.ts",
    "content": "import { v4 as uuidV4 } from \"uuid\";\n\n/**\n * Check if running on macOS.\n */\nexport function isMacOS(): boolean {\n  if (typeof navigator === \"undefined\") {\n    return false;\n  }\n  return navigator.platform.toLowerCase().includes(\"mac\");\n}\n\nconst _isMac =\n  typeof navigator !== \"undefined\" &&\n  navigator.platform.toLowerCase().includes(\"mac\");\n\n/**\n * Check if the platform-specific modifier key is pressed (Cmd on macOS, Ctrl elsewhere).\n */\nexport function hasPlatformModifier(\n  e: Pick<KeyboardEvent | MouseEvent, \"metaKey\" | \"ctrlKey\">,\n): boolean {\n  return _isMac ? e.metaKey : e.ctrlKey;\n}\n\n/**\n * Get the API base URL for connecting to the Kimi backend.\n * - Vite dev: uses Vite proxy, so empty string (relative URLs like /api/...)\n * - Production web: same-origin, so empty string\n */\nexport function getApiBaseUrl(): string {\n  return \"\";\n}\n\n/**\n * Generate a unique message ID\n * Uses crypto.randomUUID for true uniqueness to avoid key collisions\n * when switching sessions or reconnecting WebSocket\n */\nexport const createMessageId = (prefix: \"user\" | \"assistant\"): string => {\n  // Fallback for older browsers\n  return `${prefix}-${uuidV4()}`;\n};\n\n/**\n * Format relative time for session display\n */\nexport const formatRelativeTime = (date: Date): string => {\n  const now = new Date();\n  const diff = now.getTime() - date.getTime();\n  const minutes = Math.floor(diff / 60000);\n\n  if (minutes < 1) {\n    return \"Just now\";\n  } else if (minutes < 60) {\n    return `${minutes}m ago`;\n  } else {\n    const hours = Math.floor(minutes / 60);\n    if (hours < 24) {\n      return `${hours}h ago`;\n    } else {\n      const days = Math.floor(hours / 24);\n      return `${days}d ago`;\n    }\n  }\n};\n"
  },
  {
    "path": "web/src/hooks/wireTypes.ts",
    "content": "/**\n * Wire protocol types for Kimi CLI communication\n * Based on the JSON-RPC 2.0 event stream format from stdio.jsonl\n */\n\n// Base JSON-RPC 2.0 message types\nexport type JsonRpcRequest = {\n  jsonrpc: \"2.0\";\n  method: string;\n  id?: string | number;\n  params?: unknown;\n};\n\nexport type JsonRpcResponse = {\n  jsonrpc: \"2.0\";\n  id: string | number;\n  result?: unknown;\n  error?: {\n    code: number;\n    message: string;\n    data?: unknown;\n  };\n};\n\nexport type SessionState = \"stopped\" | \"idle\" | \"busy\" | \"restarting\" | \"error\";\n\nexport type SessionStatusPayload = {\n  session_id: string;\n  state: SessionState;\n  seq: number;\n  worker_id?: string | null;\n  reason?: string | null;\n  detail?: string | null;\n  updated_at: string;\n};\n\n// Event types from the wire protocol\nexport type TurnBeginEvent = {\n  type: \"TurnBegin\";\n  payload: {\n    user_input: string | ContentPart[];\n  };\n};\n\nexport type StepBeginEvent = {\n  type: \"StepBegin\";\n  payload: {\n    n: number;\n  };\n};\n\nexport type StepInterruptedEvent = {\n  type: \"StepInterrupted\";\n  payload?: Record<string, never>;\n};\n\nexport type ContentPartEvent = {\n  type: \"ContentPart\";\n  payload: {\n    type: \"think\" | \"text\" | \"image_url\" | \"audio_url\" | \"video_url\";\n    think?: string;\n    text?: string;\n    image_url?: { url: string; id?: string | null };\n    audio_url?: { url: string; id?: string | null };\n    video_url?: { url: string; id?: string | null };\n    encrypted?: string | null;\n  };\n};\n\nexport type ToolCallEvent = {\n  type: \"ToolCall\";\n  payload: {\n    type: \"function\";\n    id: string;\n    function: {\n      name: string;\n      arguments: string;\n    };\n    extras?: unknown;\n  };\n};\n\nexport type ToolCallPartEvent = {\n  type: \"ToolCallPart\";\n  payload: {\n    arguments_part: string;\n  };\n};\n\n/**\n * Tool result event from backend\n * @see kosong.tooling.ToolReturnValue for the source type\n */\n/** Content part in tool output (for model consumption) */\nexport type ToolOutputPart = {\n  type: string;\n  text?: string;\n  [key: string]: unknown;\n};\n\nexport type ToolResultEvent = {\n  type: \"ToolResult\";\n  payload: {\n    tool_call_id: string;\n    return_value: {\n      /** Whether the tool call resulted in an error */\n      is_error: boolean;\n      /** The output content returned by the tool (for model) */\n      output: ToolOutputPart[] | string;\n      /** An explanatory message to be given to the model (system reminder) */\n      message: string;\n      /** Content blocks to be displayed to the user */\n      display: Array<{ type: string; data: unknown }>;\n      /** Extra debugging/testing data */\n      extras?: Record<string, unknown>;\n    };\n  };\n};\n\nexport type TokenUsage = {\n  input_other: number;\n  output: number;\n  input_cache_read: number;\n  input_cache_creation: number;\n};\n\nexport type StatusUpdateEvent = {\n  type: \"StatusUpdate\";\n  payload: {\n    context_usage: number | null;\n    token_usage?: TokenUsage | null;\n    message_id?: string;\n    plan_mode?: boolean | null;\n  };\n};\n\nexport type SessionNoticeEvent = {\n  type: \"SessionNotice\";\n  payload: {\n    text: string;\n    kind: \"restart\";\n    reason?: string | null;\n    restart_ms?: number | null;\n  };\n};\n\nexport type CompactionBeginEvent = {\n  type: \"CompactionBegin\";\n  payload?: Record<string, never>;\n};\n\nexport type CompactionEndEvent = {\n  type: \"CompactionEnd\";\n  payload?: Record<string, never>;\n};\n\nexport type MCPLoadingBeginEvent = {\n  type: \"MCPLoadingBegin\";\n  payload?: Record<string, never>;\n};\n\nexport type MCPLoadingEndEvent = {\n  type: \"MCPLoadingEnd\";\n  payload?: Record<string, never>;\n};\n\nexport type ApprovalRequestEvent = {\n  type: \"ApprovalRequest\";\n  payload: {\n    id: string;\n    action: string;\n    description: string;\n    sender: string;\n    tool_call_id: string;\n  };\n};\n\nexport type ApprovalRequestResolvedEvent = {\n  type: \"ApprovalRequestResolved\";\n  payload: {\n    request_id: string;\n    response: unknown;\n  };\n};\n\nexport type ApprovalResponseDecision =\n  | \"approve\"\n  | \"approve_for_session\"\n  | \"reject\";\n\nexport type QuestionOption = {\n  label: string;\n  description: string;\n};\n\nexport type QuestionItem = {\n  question: string;\n  header: string;\n  options: QuestionOption[];\n  multi_select: boolean;\n  body?: string;\n  other_label?: string;\n  other_description?: string;\n};\n\nexport type QuestionRequestEvent = {\n  type: \"QuestionRequest\";\n  payload: {\n    id: string;\n    tool_call_id: string;\n    questions: QuestionItem[];\n  };\n};\n\n/**\n * A SubagentEvent wraps an inner event produced by a subagent (Task tool).\n * The inner `event` field is a {type, payload} envelope that may itself be\n * a SubagentEvent (for nested subagents).\n */\nexport type SubagentEventWire = {\n  type: \"SubagentEvent\";\n  payload: {\n    task_tool_call_id: string;\n    event: { type: string; payload: unknown };\n  };\n};\n\nexport type SteerInputEvent = {\n  type: \"SteerInput\";\n  payload: {\n    user_input: string | ContentPart[];\n  };\n};\n\n// Union of all event types\nexport type WireEvent =\n  | TurnBeginEvent\n  | StepBeginEvent\n  | StepInterruptedEvent\n  | ContentPartEvent\n  | ToolCallEvent\n  | ToolCallPartEvent\n  | ToolResultEvent\n  | StatusUpdateEvent\n  | SessionNoticeEvent\n  | CompactionBeginEvent\n  | CompactionEndEvent\n  | MCPLoadingBeginEvent\n  | MCPLoadingEndEvent\n  | ApprovalRequestEvent\n  | ApprovalRequestResolvedEvent\n  | QuestionRequestEvent\n  | SubagentEventWire\n  | SteerInputEvent;\n\n// Parsed wire message\nexport type WireMessage = {\n  jsonrpc: \"2.0\";\n  method?:\n    | \"event\"\n    | \"prompt\"\n    | \"history_complete\"\n    | \"request\"\n    | \"response\"\n    | \"session_status\";\n  id?: string | number;\n  params?:\n    | {\n        type?: string;\n        payload?: unknown;\n        user_input?: string;\n      }\n    | SessionStatusPayload;\n  result?: {\n    status?: string;\n    slash_commands?: Array<{\n      name: string;\n      description: string;\n      aliases: string[];\n    }>;\n    [key: string]: unknown;\n  };\n  error?: {\n    code: number;\n    message: string;\n    data?: unknown;\n  };\n};\n\n// Parsed tool call state for tracking\nexport type ToolCallState = {\n  id: string;\n  name: string;\n  arguments: string;\n  argumentsComplete: boolean;\n  messageId?: string;\n  approval?: ToolApprovalState;\n  result?: {\n    isError: boolean;\n    output?: string;\n    message?: string;\n  };\n};\n\nexport type ToolApprovalState = {\n  id: string;\n  action: string;\n  description: string;\n  sender: string;\n  toolCallId: string;\n  rpcMessageId?: string | number;\n  submitted?: boolean;\n  resolved?: boolean;\n  approved?: boolean;\n  reason?: string;\n  response?: unknown;\n};\n\n// Content part for accumulated content\nexport type ContentPart =\n  | {\n      type: \"text\" | \"input_text\";\n      text: string;\n      content?: string;\n    }\n  | {\n      type: \"think\";\n      think: string;\n      content?: string;\n    }\n  | {\n      type: \"image_url\";\n      image_url: { url: string; id?: string | null };\n    }\n  | {\n      type: \"audio_url\";\n      audio_url: { url: string; id?: string | null };\n    }\n  | {\n      type: \"video_url\";\n      video_url: { url: string; id?: string | null };\n    }\n  | {\n      type: \"image\" | \"input_image\";\n      image_url?: string;\n      url?: string;\n      mime_type?: string;\n      alt?: string;\n      data?: unknown;\n    }\n  | {\n      type: \"audio\" | \"input_audio\";\n      audio_url?: string;\n      transcript?: string;\n      data?: unknown;\n    }\n  | {\n      type: \"video\" | \"input_video\";\n      video_url?: string;\n      data?: unknown;\n    };\n\n// Parsed turn state for tracking conversation\nexport type TurnState = {\n  userInput: string;\n  steps: StepState[];\n  currentStep: number;\n  contextUsage: number;\n  isComplete: boolean;\n};\n\nexport type StepState = {\n  n: number;\n  thinkingContent: string;\n  textContent: string;\n  toolCalls: ToolCallState[];\n  isStreaming: boolean;\n};\n\n/**\n * Parse a JSONL file content into wire messages\n */\nexport function parseWireMessages(jsonlContent: string): WireMessage[] {\n  const lines = jsonlContent.trim().split(\"\\n\");\n  const messages: WireMessage[] = [];\n\n  for (const line of lines) {\n    if (!line.trim()) continue;\n    try {\n      const parsed = JSON.parse(line) as WireMessage;\n      if (parsed.jsonrpc === \"2.0\") {\n        messages.push(parsed);\n      }\n    } catch {\n      console.warn(\"Failed to parse wire message:\", line);\n    }\n  }\n\n  return messages;\n}\n\n/**\n * Normalize wire event type names that differ between server and client.\n * The Python backend uses class names (e.g. \"ApprovalResponse\") while\n * the client expects legacy names (e.g. \"ApprovalRequestResolved\").\n */\nconst EVENT_TYPE_ALIASES: Record<string, string> = {\n  ApprovalResponse: \"ApprovalRequestResolved\",\n};\n\n/**\n * Extract event from wire message\n */\nexport function extractEvent(message: WireMessage): WireEvent | null {\n  if (message.method !== \"event\" || !message.params) {\n    return null;\n  }\n\n  const params = message.params as { type: string; payload: unknown };\n  const type = EVENT_TYPE_ALIASES[params.type] ?? params.type;\n  return {\n    type,\n    payload: params.payload,\n  } as WireEvent;\n}\n"
  },
  {
    "path": "web/src/index.css",
    "content": "/* ==========================================================================\n * Kimi Code CLI Web UI - Design System\n *\n *\n * Structure:\n *  1) Tailwind & plugins\n *  2) Custom variant (dark mode)\n *  3) Design Tokens (CSS variables)\n *  4) @theme inline: Token → Tailwind mapping\n *  5) @layer base: Semantic HTML defaults\n *  6) @layer components: Reusable UI patterns\n * ========================================================================== */\n\n/* 1. Tailwind & Plugins */\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n@import \"shadcn/tailwind.css\";\n@import \"@fontsource-variable/inter\";\n\n/* 2. Custom variant: dark mode */\n@custom-variant dark (&:is(.dark *));\n\n/* 3. Design Tokens*/\n:root {\n  --radius: 0.5rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.141 0.005 285.823);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.141 0.005 285.823);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.141 0.005 285.823);\n  --primary: oklch(0.21 0.006 285.885);\n  --primary-foreground: oklch(1 0 0);\n  --secondary: oklch(0.967 0.001 286.375);\n  --secondary-foreground: oklch(0.21 0.006 285.885);\n  --muted: oklch(0.967 0.001 286.375);\n  --muted-foreground: oklch(0.552 0.016 285.938);\n  --accent: oklch(0.967 0.001 286.375);\n  --accent-foreground: oklch(0.21 0.006 285.885);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.92 0.004 286.32);\n  --input: oklch(0.92 0.004 286.32);\n  --ring: oklch(0.552 0.016 285.938);\n\n  /* Semantic colors */\n  --success: oklch(0.6 0.2 145);\n  --success-foreground: oklch(1 0 0);\n  --warning: oklch(0.7 0.15 75);\n  --warning-foreground: oklch(0.2 0 0);\n  --info: oklch(0.6 0.15 250);\n  --info-foreground: oklch(1 0 0);\n  --destructive-foreground: oklch(1 0 0);\n\n  /* Chart colors */\n  --chart-1: oklch(0.6 0.15 250);\n  --chart-2: oklch(0.6 0.2 145);\n  --chart-3: oklch(0.7 0.15 75);\n  --chart-4: oklch(0.577 0.245 27.325);\n  --chart-5: oklch(0.6 0.2 300);\n\n  /* Shadows */\n  --shadow-pane: 0 1px 3px rgba(0, 0, 0, 0.08);\n  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06);\n  --shadow-input: 0 1px 2px rgba(0, 0, 0, 0.04);\n  --shadow-toolbar: 0 1px 2px rgba(0, 0, 0, 0.04);\n\n  /* Sidebar tokens */\n  --sidebar: oklch(0.98 0 0);\n  --sidebar-foreground: oklch(0.141 0.005 285.823);\n  --sidebar-primary: oklch(0.967 0.001 286.375);\n  --sidebar-primary-foreground: oklch(0.141 0.005 285.823);\n  --sidebar-accent: oklch(0.6 0.15 250);\n  --sidebar-accent-foreground: oklch(1 0 0);\n  --sidebar-border: oklch(0.92 0.004 286.32);\n  --sidebar-ring: oklch(0.6 0.15 250);\n\n  /* Inline code */\n  --code-background: oklch(0.967 0.001 286.375);\n  --code-foreground: oklch(0.141 0.005 285.823);\n\n  /* Safe area insets (iOS) */\n  --safe-top: env(safe-area-inset-top, 0px);\n  --safe-right: env(safe-area-inset-right, 0px);\n  --safe-bottom: env(safe-area-inset-bottom, 0px);\n  --safe-left: env(safe-area-inset-left, 0px);\n}\n\n/* Dark mode tokens */\n.dark {\n  --background: oklch(0.141 0.005 285.823);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.18 0.005 285.823);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.18 0.005 285.823);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.75 0.02 285.885);\n  --primary-foreground: oklch(0.141 0.005 285.823);\n  --secondary: oklch(0.274 0.006 286.033);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.274 0.006 286.033);\n  --muted-foreground: oklch(0.705 0.015 286.067);\n  --accent: oklch(0.274 0.006 286.033);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(0.274 0.006 286.033);\n  --input: oklch(0.274 0.006 286.033);\n  --ring: oklch(0.552 0.016 285.938);\n\n  /* Semantic colors - dark */\n  --success: oklch(0.7 0.2 145);\n  --warning: oklch(0.75 0.15 75);\n  --info: oklch(0.7 0.15 250);\n  --destructive-foreground: oklch(0.141 0.005 285.823);\n\n  /* Chart colors - dark */\n  --chart-1: oklch(0.7 0.15 250);\n  --chart-2: oklch(0.7 0.2 145);\n  --chart-3: oklch(0.75 0.15 75);\n  --chart-4: oklch(0.704 0.191 22.216);\n  --chart-5: oklch(0.7 0.2 300);\n\n  /* Shadows - dark */\n  --shadow-pane: 0 4px 12px rgba(0, 0, 0, 0.5);\n  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.4);\n  --shadow-input: 0 2px 6px rgba(0, 0, 0, 0.3);\n  --shadow-toolbar: 0 2px 6px rgba(0, 0, 0, 0.3);\n\n  /* Sidebar tokens - dark */\n  --sidebar: oklch(0.18 0.005 285.823);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.274 0.006 286.033);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.7 0.15 250);\n  --sidebar-accent-foreground: oklch(0.141 0.005 285.823);\n  --sidebar-border: oklch(0.274 0.006 286.033);\n  --sidebar-ring: oklch(0.7 0.15 250);\n\n  /* Inline code - dark */\n  --code-background: oklch(0.274 0.006 286.033);\n  --code-foreground: oklch(0.985 0 0);\n}\n\n* {\n  border-color: var(--border);\n}\n\nhtml,\nbody {\n  margin: 0;\n  padding: 0;\n  height: 100%;\n  overflow: hidden;\n}\n\nbody {\n  font-family:\n    \"Inter Variable\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n    sans-serif;\n  font-size: 14px;\n  background-color: var(--background);\n  color: var(--foreground);\n}\n\n#root {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n/* 4. Tailwind @theme inline */\n@theme inline {\n  --font-sans:\n    \"Inter Variable\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n    sans-serif;\n  --font-mono:\n    ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\",\n    monospace;\n\n  /* Color mapping */\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-success: var(--success);\n  --color-success-foreground: var(--success-foreground);\n  --color-warning: var(--warning);\n  --color-warning-foreground: var(--warning-foreground);\n  --color-info: var(--info);\n  --color-info-foreground: var(--info-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n\n  /* Radius */\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n\n  /* Sidebar */\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n\n  /* Shadows */\n  --shadow-pane: var(--shadow-pane);\n  --shadow-card: var(--shadow-card);\n  --shadow-input: var(--shadow-input);\n  --shadow-toolbar: var(--shadow-toolbar);\n\n  --text-xs: 0.795rem;\n}\n\n/* 5. @layer base */\n@layer base {\n  *,\n  *::before,\n  *::after {\n    box-sizing: border-box;\n    @apply border-border outline-ring/50;\n  }\n\n  /* Typography */\n  h1 {\n    @apply text-2xl font-semibold leading-tight;\n  }\n  h2 {\n    @apply text-xl font-semibold leading-tight;\n  }\n  h3 {\n    @apply text-lg font-semibold leading-snug;\n  }\n  h4 {\n    @apply text-xs font-semibold uppercase tracking-wide text-muted-foreground;\n  }\n  p {\n    @apply text-sm leading-relaxed;\n  }\n  small {\n    @apply text-xs leading-snug text-muted-foreground;\n  }\n\n  /* Inline code */\n  :not(pre) > code {\n    background-color: var(--code-background) !important;\n    color: var(--code-foreground) !important;\n    padding: 0.125rem 0.375rem;\n    border-radius: 0.25rem;\n    font-size: 0.875em;\n    font-weight: 500;\n  }\n}\n\n/* Scrollbar styling */\n::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\n::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--muted-foreground);\n  border-radius: 3px;\n  opacity: 0.5;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  opacity: 0.8;\n}\n\n/* 6. @layer components */\n@layer components {\n  /* ===== CodeMirror ===== */\n  .cm-editor {\n    background-color: var(--card);\n    color: var(--foreground);\n    border: 1px solid var(--border);\n    border-radius: 0.5rem;\n    box-shadow: var(--shadow-card);\n  }\n\n  .cm-scroller {\n    font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco);\n    background-color: var(--card);\n    color: var(--foreground);\n  }\n\n  .cm-gutters {\n    background-color: var(--card);\n    color: var(--muted-foreground);\n    border-right: 1px solid var(--border);\n  }\n\n  .dark .cm-gutters {\n    background-color: var(--background) !important;\n  }\n\n  .cm-lineNumbers,\n  .cm-gutterElement {\n    color: var(--muted-foreground) !important;\n  }\n\n  .cm-activeLine {\n    background-color: color-mix(in srgb, var(--accent) 8%, transparent);\n  }\n\n  .cm-activeLineGutter {\n    background-color: color-mix(in srgb, var(--accent) 8%, transparent);\n    color: var(--foreground);\n  }\n\n  .cm-selectionBackground,\n  .cm-content ::selection {\n    background-color: color-mix(in srgb, var(--primary) 15%, transparent);\n  }\n\n  .dark .cm-selectionBackground,\n  .dark .cm-content ::selection {\n    background-color: color-mix(in srgb, var(--primary) 25%, transparent);\n    color: var(--primary-foreground);\n  }\n\n  /* Streamdown code block */\n  [data-streamdown=\"code-block\"] {\n    border-radius: 0.5rem !important;\n    border-color: var(--border) !important;\n  }\n\n  [data-streamdown=\"code-block\"] [data-streamdown=\"code-block-header\"],\n  [data-streamdown=\"code-block-header\"] {\n    padding: 0.35rem 0.65rem !important;\n    font-size: 0.75rem !important;\n    line-height: 1.25 !important;\n    background-color: transparent !important;\n    color: var(--muted-foreground);\n  }\n\n  [data-streamdown=\"code-block-header\"] .font-mono {\n    font-size: 0.75rem;\n  }\n\n  /* ===== Animations ===== */\n  @keyframes glow-pulse {\n    0%,\n    100% {\n      box-shadow: 0 0 4px rgba(59, 130, 246, 0.4);\n    }\n    50% {\n      box-shadow: 0 0 12px rgba(59, 130, 246, 0.8);\n    }\n  }\n}\n\n/* View Transitions: Theme Toggle Animation */\n::view-transition-old(root),\n::view-transition-new(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\n\n:root[data-theme-switching=\"true\"] * {\n  transition: none !important;\n}\n\n::view-transition-old(root) {\n  z-index: 1;\n}\n\n::view-transition-new(root) {\n  z-index: 9999;\n}\n\n.dark::view-transition-old(root) {\n  z-index: 9999;\n}\n\n.dark::view-transition-new(root) {\n  z-index: 1;\n}\n\n/* Slide-in animation */\n@keyframes slide-in-from-bottom {\n  from {\n    opacity: 0;\n    transform: translateY(8px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.animate-in {\n  animation-duration: 200ms;\n  animation-fill-mode: both;\n}\n\n.fade-in {\n  animation-name: slide-in-from-bottom;\n}\n\n.slide-in-from-bottom-2 {\n  --tw-enter-translate-y: 0.5rem;\n}\n\n/* Touch devices: reveal hover-only controls */\n@media (hover: none) {\n  .hover-reveal {\n    opacity: 1 !important;\n  }\n}\n"
  },
  {
    "path": "web/src/lib/api/.openapi-generator/FILES",
    "content": ".openapi-generator-ignore\napis/ConfigApi.ts\napis/DefaultApi.ts\napis/OpenInApi.ts\napis/SessionsApi.ts\napis/WorkDirsApi.ts\napis/index.ts\ndocs/ConfigApi.md\ndocs/ConfigModel.md\ndocs/ConfigToml.md\ndocs/CreateSessionRequest.md\ndocs/DefaultApi.md\ndocs/GenerateTitleRequest.md\ndocs/GenerateTitleResponse.md\ndocs/GitDiffStats.md\ndocs/GitFileDiff.md\ndocs/GlobalConfig.md\ndocs/HTTPValidationError.md\ndocs/ModelCapability.md\ndocs/OpenInApi.md\ndocs/OpenInRequest.md\ndocs/OpenInResponse.md\ndocs/ProviderType.md\ndocs/Session.md\ndocs/SessionStatus.md\ndocs/SessionsApi.md\ndocs/UpdateConfigTomlRequest.md\ndocs/UpdateConfigTomlResponse.md\ndocs/UpdateGlobalConfigRequest.md\ndocs/UpdateGlobalConfigResponse.md\ndocs/UpdateSessionRequest.md\ndocs/UploadSessionFileResponse.md\ndocs/ValidationError.md\ndocs/ValidationErrorLocInner.md\ndocs/WorkDirsApi.md\nindex.ts\nmodels/ConfigModel.ts\nmodels/ConfigToml.ts\nmodels/CreateSessionRequest.ts\nmodels/GenerateTitleRequest.ts\nmodels/GenerateTitleResponse.ts\nmodels/GitDiffStats.ts\nmodels/GitFileDiff.ts\nmodels/GlobalConfig.ts\nmodels/HTTPValidationError.ts\nmodels/ModelCapability.ts\nmodels/OpenInRequest.ts\nmodels/OpenInResponse.ts\nmodels/ProviderType.ts\nmodels/Session.ts\nmodels/SessionStatus.ts\nmodels/UpdateConfigTomlRequest.ts\nmodels/UpdateConfigTomlResponse.ts\nmodels/UpdateGlobalConfigRequest.ts\nmodels/UpdateGlobalConfigResponse.ts\nmodels/UpdateSessionRequest.ts\nmodels/UploadSessionFileResponse.ts\nmodels/ValidationError.ts\nmodels/ValidationErrorLocInner.ts\nmodels/index.ts\nruntime.ts\n"
  },
  {
    "path": "web/src/lib/api/.openapi-generator/VERSION",
    "content": "7.17.0\n"
  },
  {
    "path": "web/src/lib/api/.openapi-generator-ignore",
    "content": "# OpenAPI Generator Ignore\n# Generated by openapi-generator https://github.com/openapitools/openapi-generator\n\n# Use this file to prevent files from being overwritten by the generator.\n# The patterns follow closely to .gitignore or .dockerignore.\n\n# As an example, the C# client generator defines ApiClient.cs.\n# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:\n#ApiClient.cs\n\n# You can match any string of characters against a directory, file or extension with a single asterisk (*):\n#foo/*/qux\n# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux\n\n# You can recursively match patterns against a directory, file or extension with a double asterisk (**):\n#foo/**/qux\n# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux\n\n# You can also negate patterns with an exclamation (!).\n# For example, you can ignore all files in a docs folder with the file extension .md:\n#docs/*.md\n# Then explicitly reverse the ignore rule for a single file:\n#!docs/README.md\n"
  },
  {
    "path": "web/src/lib/api/apis/ConfigApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\nimport type {\n  ConfigToml,\n  GlobalConfig,\n  HTTPValidationError,\n  UpdateConfigTomlRequest,\n  UpdateConfigTomlResponse,\n  UpdateGlobalConfigRequest,\n  UpdateGlobalConfigResponse,\n} from '../models/index';\nimport {\n    ConfigTomlFromJSON,\n    ConfigTomlToJSON,\n    GlobalConfigFromJSON,\n    GlobalConfigToJSON,\n    HTTPValidationErrorFromJSON,\n    HTTPValidationErrorToJSON,\n    UpdateConfigTomlRequestFromJSON,\n    UpdateConfigTomlRequestToJSON,\n    UpdateConfigTomlResponseFromJSON,\n    UpdateConfigTomlResponseToJSON,\n    UpdateGlobalConfigRequestFromJSON,\n    UpdateGlobalConfigRequestToJSON,\n    UpdateGlobalConfigResponseFromJSON,\n    UpdateGlobalConfigResponseToJSON,\n} from '../models/index';\n\nexport interface UpdateConfigTomlApiConfigTomlPutRequest {\n    updateConfigTomlRequest: UpdateConfigTomlRequest;\n}\n\nexport interface UpdateGlobalConfigApiConfigPatchRequest {\n    updateGlobalConfigRequest: UpdateGlobalConfigRequest;\n}\n\n/**\n * \n */\nexport class ConfigApi extends runtime.BaseAPI {\n\n    /**\n     * Get kimi-cli config.toml.\n     * Get kimi-cli config.toml\n     */\n    async getConfigTomlApiConfigTomlGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ConfigToml>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/config/toml`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ConfigTomlFromJSON(jsonValue));\n    }\n\n    /**\n     * Get kimi-cli config.toml.\n     * Get kimi-cli config.toml\n     */\n    async getConfigTomlApiConfigTomlGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ConfigToml> {\n        const response = await this.getConfigTomlApiConfigTomlGetRaw(initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Get global (kimi-cli) config snapshot.\n     * Get global (kimi-cli) config snapshot\n     */\n    async getGlobalConfigApiConfigGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<GlobalConfig>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/config/`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => GlobalConfigFromJSON(jsonValue));\n    }\n\n    /**\n     * Get global (kimi-cli) config snapshot.\n     * Get global (kimi-cli) config snapshot\n     */\n    async getGlobalConfigApiConfigGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<GlobalConfig> {\n        const response = await this.getGlobalConfigApiConfigGetRaw(initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Update kimi-cli config.toml.\n     * Update kimi-cli config.toml\n     */\n    async updateConfigTomlApiConfigTomlPutRaw(requestParameters: UpdateConfigTomlApiConfigTomlPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<UpdateConfigTomlResponse>> {\n        if (requestParameters['updateConfigTomlRequest'] == null) {\n            throw new runtime.RequiredError(\n                'updateConfigTomlRequest',\n                'Required parameter \"updateConfigTomlRequest\" was null or undefined when calling updateConfigTomlApiConfigTomlPut().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n\n        let urlPath = `/api/config/toml`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'PUT',\n            headers: headerParameters,\n            query: queryParameters,\n            body: UpdateConfigTomlRequestToJSON(requestParameters['updateConfigTomlRequest']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => UpdateConfigTomlResponseFromJSON(jsonValue));\n    }\n\n    /**\n     * Update kimi-cli config.toml.\n     * Update kimi-cli config.toml\n     */\n    async updateConfigTomlApiConfigTomlPut(requestParameters: UpdateConfigTomlApiConfigTomlPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<UpdateConfigTomlResponse> {\n        const response = await this.updateConfigTomlApiConfigTomlPutRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Update global (kimi-cli) default model/thinking.\n     * Update global (kimi-cli) default model/thinking\n     */\n    async updateGlobalConfigApiConfigPatchRaw(requestParameters: UpdateGlobalConfigApiConfigPatchRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<UpdateGlobalConfigResponse>> {\n        if (requestParameters['updateGlobalConfigRequest'] == null) {\n            throw new runtime.RequiredError(\n                'updateGlobalConfigRequest',\n                'Required parameter \"updateGlobalConfigRequest\" was null or undefined when calling updateGlobalConfigApiConfigPatch().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n\n        let urlPath = `/api/config/`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'PATCH',\n            headers: headerParameters,\n            query: queryParameters,\n            body: UpdateGlobalConfigRequestToJSON(requestParameters['updateGlobalConfigRequest']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => UpdateGlobalConfigResponseFromJSON(jsonValue));\n    }\n\n    /**\n     * Update global (kimi-cli) default model/thinking.\n     * Update global (kimi-cli) default model/thinking\n     */\n    async updateGlobalConfigApiConfigPatch(requestParameters: UpdateGlobalConfigApiConfigPatchRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<UpdateGlobalConfigResponse> {\n        const response = await this.updateGlobalConfigApiConfigPatchRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "web/src/lib/api/apis/DefaultApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\n\n/**\n * \n */\nexport class DefaultApi extends runtime.BaseAPI {\n\n    /**\n     * Health check endpoint.\n     * Health Probe\n     */\n    async healthProbeHealthzGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<{ [key: string]: any; }>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/healthz`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse<any>(response);\n    }\n\n    /**\n     * Health check endpoint.\n     * Health Probe\n     */\n    async healthProbeHealthzGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{ [key: string]: any; }> {\n        const response = await this.healthProbeHealthzGetRaw(initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "web/src/lib/api/apis/OpenInApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\nimport type {\n  HTTPValidationError,\n  OpenInRequest,\n  OpenInResponse,\n} from '../models/index';\nimport {\n    HTTPValidationErrorFromJSON,\n    HTTPValidationErrorToJSON,\n    OpenInRequestFromJSON,\n    OpenInRequestToJSON,\n    OpenInResponseFromJSON,\n    OpenInResponseToJSON,\n} from '../models/index';\n\nexport interface OpenInApiOpenInPostRequest {\n    openInRequest: OpenInRequest;\n}\n\n/**\n * \n */\nexport class OpenInApi extends runtime.BaseAPI {\n\n    /**\n     * Open a path in a local application\n     */\n    async openInApiOpenInPostRaw(requestParameters: OpenInApiOpenInPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<OpenInResponse>> {\n        if (requestParameters['openInRequest'] == null) {\n            throw new runtime.RequiredError(\n                'openInRequest',\n                'Required parameter \"openInRequest\" was null or undefined when calling openInApiOpenInPost().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n\n        let urlPath = `/api/open-in`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n            body: OpenInRequestToJSON(requestParameters['openInRequest']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => OpenInResponseFromJSON(jsonValue));\n    }\n\n    /**\n     * Open a path in a local application\n     */\n    async openInApiOpenInPost(requestParameters: OpenInApiOpenInPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<OpenInResponse> {\n        const response = await this.openInApiOpenInPostRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "web/src/lib/api/apis/SessionsApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\nimport type {\n  CreateSessionRequest,\n  GenerateTitleRequest,\n  GenerateTitleResponse,\n  GitDiffStats,\n  HTTPValidationError,\n  Session,\n  UpdateSessionRequest,\n  UploadSessionFileResponse,\n} from '../models/index';\nimport {\n    CreateSessionRequestFromJSON,\n    CreateSessionRequestToJSON,\n    GenerateTitleRequestFromJSON,\n    GenerateTitleRequestToJSON,\n    GenerateTitleResponseFromJSON,\n    GenerateTitleResponseToJSON,\n    GitDiffStatsFromJSON,\n    GitDiffStatsToJSON,\n    HTTPValidationErrorFromJSON,\n    HTTPValidationErrorToJSON,\n    SessionFromJSON,\n    SessionToJSON,\n    UpdateSessionRequestFromJSON,\n    UpdateSessionRequestToJSON,\n    UploadSessionFileResponseFromJSON,\n    UploadSessionFileResponseToJSON,\n} from '../models/index';\n\nexport interface CreateSessionApiSessionsPostRequest {\n    createSessionRequest?: CreateSessionRequest;\n}\n\nexport interface DeleteSessionApiSessionsSessionIdDeleteRequest {\n    sessionId: string;\n}\n\nexport interface GenerateSessionTitleApiSessionsSessionIdGenerateTitlePostRequest {\n    sessionId: string;\n    generateTitleRequest?: GenerateTitleRequest;\n}\n\nexport interface GetSessionApiSessionsSessionIdGetRequest {\n    sessionId: string;\n}\n\nexport interface GetSessionFileApiSessionsSessionIdFilesPathGetRequest {\n    sessionId: string;\n    path: string;\n}\n\nexport interface GetSessionGitDiffApiSessionsSessionIdGitDiffGetRequest {\n    sessionId: string;\n}\n\nexport interface GetSessionUploadFileApiSessionsSessionIdUploadsPathGetRequest {\n    sessionId: string;\n    path: string;\n}\n\nexport interface ListSessionsApiSessionsGetRequest {\n    limit?: number;\n    offset?: number;\n    q?: string | null;\n    archived?: boolean | null;\n}\n\nexport interface UpdateSessionApiSessionsSessionIdPatchRequest {\n    sessionId: string;\n    updateSessionRequest: UpdateSessionRequest;\n}\n\nexport interface UploadSessionFileApiSessionsSessionIdFilesPostRequest {\n    sessionId: string;\n    file: Blob;\n}\n\n/**\n * \n */\nexport class SessionsApi extends runtime.BaseAPI {\n\n    /**\n     * Create a new session.\n     * Create a new session\n     */\n    async createSessionApiSessionsPostRaw(requestParameters: CreateSessionApiSessionsPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Session>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n\n        let urlPath = `/api/sessions/`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n            body: CreateSessionRequestToJSON(requestParameters['createSessionRequest']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => SessionFromJSON(jsonValue));\n    }\n\n    /**\n     * Create a new session.\n     * Create a new session\n     */\n    async createSessionApiSessionsPost(requestParameters: CreateSessionApiSessionsPostRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Session> {\n        const response = await this.createSessionApiSessionsPostRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Delete a session.\n     * Delete a session\n     */\n    async deleteSessionApiSessionsSessionIdDeleteRaw(requestParameters: DeleteSessionApiSessionsSessionIdDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<any>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling deleteSessionApiSessionsSessionIdDelete().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/sessions/{session_id}`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'DELETE',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        if (this.isJsonMime(response.headers.get('content-type'))) {\n            return new runtime.JSONApiResponse<any>(response);\n        } else {\n            return new runtime.TextApiResponse(response) as any;\n        }\n    }\n\n    /**\n     * Delete a session.\n     * Delete a session\n     */\n    async deleteSessionApiSessionsSessionIdDelete(requestParameters: DeleteSessionApiSessionsSessionIdDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<any> {\n        const response = await this.deleteSessionApiSessionsSessionIdDeleteRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Generate a concise session title using AI based on the first conversation turn.  If request body is empty or parameters are missing, the backend will automatically read the first turn from wire.jsonl.\n     * Generate session title using AI\n     */\n    async generateSessionTitleApiSessionsSessionIdGenerateTitlePostRaw(requestParameters: GenerateSessionTitleApiSessionsSessionIdGenerateTitlePostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<GenerateTitleResponse>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling generateSessionTitleApiSessionsSessionIdGenerateTitlePost().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n\n        let urlPath = `/api/sessions/{session_id}/generate-title`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n            body: GenerateTitleRequestToJSON(requestParameters['generateTitleRequest']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => GenerateTitleResponseFromJSON(jsonValue));\n    }\n\n    /**\n     * Generate a concise session title using AI based on the first conversation turn.  If request body is empty or parameters are missing, the backend will automatically read the first turn from wire.jsonl.\n     * Generate session title using AI\n     */\n    async generateSessionTitleApiSessionsSessionIdGenerateTitlePost(requestParameters: GenerateSessionTitleApiSessionsSessionIdGenerateTitlePostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<GenerateTitleResponse> {\n        const response = await this.generateSessionTitleApiSessionsSessionIdGenerateTitlePostRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Get a session by ID.\n     * Get session\n     */\n    async getSessionApiSessionsSessionIdGetRaw(requestParameters: GetSessionApiSessionsSessionIdGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Session>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling getSessionApiSessionsSessionIdGet().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/sessions/{session_id}`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => SessionFromJSON(jsonValue));\n    }\n\n    /**\n     * Get a session by ID.\n     * Get session\n     */\n    async getSessionApiSessionsSessionIdGet(requestParameters: GetSessionApiSessionsSessionIdGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Session> {\n        const response = await this.getSessionApiSessionsSessionIdGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Get a file or list directory from session work directory.\n     * Get file or list directory from session work_dir\n     */\n    async getSessionFileApiSessionsSessionIdFilesPathGetRaw(requestParameters: GetSessionFileApiSessionsSessionIdFilesPathGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<any>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling getSessionFileApiSessionsSessionIdFilesPathGet().'\n            );\n        }\n\n        if (requestParameters['path'] == null) {\n            throw new runtime.RequiredError(\n                'path',\n                'Required parameter \"path\" was null or undefined when calling getSessionFileApiSessionsSessionIdFilesPathGet().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/sessions/{session_id}/files/{path}`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n        urlPath = urlPath.replace(`{${\"path\"}}`, encodeURIComponent(String(requestParameters['path'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        if (this.isJsonMime(response.headers.get('content-type'))) {\n            return new runtime.JSONApiResponse<any>(response);\n        } else {\n            return new runtime.TextApiResponse(response) as any;\n        }\n    }\n\n    /**\n     * Get a file or list directory from session work directory.\n     * Get file or list directory from session work_dir\n     */\n    async getSessionFileApiSessionsSessionIdFilesPathGet(requestParameters: GetSessionFileApiSessionsSessionIdFilesPathGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<any> {\n        const response = await this.getSessionFileApiSessionsSessionIdFilesPathGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * get git diff stats for the session\\'s work directory\n     * Get git diff stats\n     */\n    async getSessionGitDiffApiSessionsSessionIdGitDiffGetRaw(requestParameters: GetSessionGitDiffApiSessionsSessionIdGitDiffGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<GitDiffStats>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling getSessionGitDiffApiSessionsSessionIdGitDiffGet().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/sessions/{session_id}/git-diff`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => GitDiffStatsFromJSON(jsonValue));\n    }\n\n    /**\n     * get git diff stats for the session\\'s work directory\n     * Get git diff stats\n     */\n    async getSessionGitDiffApiSessionsSessionIdGitDiffGet(requestParameters: GetSessionGitDiffApiSessionsSessionIdGitDiffGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<GitDiffStats> {\n        const response = await this.getSessionGitDiffApiSessionsSessionIdGitDiffGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Get a file from a session\\'s uploads directory.\n     * Get uploaded file from session uploads\n     */\n    async getSessionUploadFileApiSessionsSessionIdUploadsPathGetRaw(requestParameters: GetSessionUploadFileApiSessionsSessionIdUploadsPathGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<any>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling getSessionUploadFileApiSessionsSessionIdUploadsPathGet().'\n            );\n        }\n\n        if (requestParameters['path'] == null) {\n            throw new runtime.RequiredError(\n                'path',\n                'Required parameter \"path\" was null or undefined when calling getSessionUploadFileApiSessionsSessionIdUploadsPathGet().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/sessions/{session_id}/uploads/{path}`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n        urlPath = urlPath.replace(`{${\"path\"}}`, encodeURIComponent(String(requestParameters['path'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        if (this.isJsonMime(response.headers.get('content-type'))) {\n            return new runtime.JSONApiResponse<any>(response);\n        } else {\n            return new runtime.TextApiResponse(response) as any;\n        }\n    }\n\n    /**\n     * Get a file from a session\\'s uploads directory.\n     * Get uploaded file from session uploads\n     */\n    async getSessionUploadFileApiSessionsSessionIdUploadsPathGet(requestParameters: GetSessionUploadFileApiSessionsSessionIdUploadsPathGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<any> {\n        const response = await this.getSessionUploadFileApiSessionsSessionIdUploadsPathGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * List sessions with optional pagination and search.  Args:     limit: Maximum number of sessions to return (default 100, max 500).     offset: Number of sessions to skip (default 0).     q: Optional search query to filter by title or work_dir.     archived: Filter by archived status.         - None (default): Only return non-archived sessions.         - True: Only return archived sessions.\n     * List all sessions\n     */\n    async listSessionsApiSessionsGetRaw(requestParameters: ListSessionsApiSessionsGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Array<Session>>> {\n        const queryParameters: any = {};\n\n        if (requestParameters['limit'] != null) {\n            queryParameters['limit'] = requestParameters['limit'];\n        }\n\n        if (requestParameters['offset'] != null) {\n            queryParameters['offset'] = requestParameters['offset'];\n        }\n\n        if (requestParameters['q'] != null) {\n            queryParameters['q'] = requestParameters['q'];\n        }\n\n        if (requestParameters['archived'] != null) {\n            queryParameters['archived'] = requestParameters['archived'];\n        }\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/sessions/`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(SessionFromJSON));\n    }\n\n    /**\n     * List sessions with optional pagination and search.  Args:     limit: Maximum number of sessions to return (default 100, max 500).     offset: Number of sessions to skip (default 0).     q: Optional search query to filter by title or work_dir.     archived: Filter by archived status.         - None (default): Only return non-archived sessions.         - True: Only return archived sessions.\n     * List all sessions\n     */\n    async listSessionsApiSessionsGet(requestParameters: ListSessionsApiSessionsGetRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<Session>> {\n        const response = await this.listSessionsApiSessionsGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Update a session (e.g., rename title or archive/unarchive).\n     * Update session\n     */\n    async updateSessionApiSessionsSessionIdPatchRaw(requestParameters: UpdateSessionApiSessionsSessionIdPatchRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Session>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling updateSessionApiSessionsSessionIdPatch().'\n            );\n        }\n\n        if (requestParameters['updateSessionRequest'] == null) {\n            throw new runtime.RequiredError(\n                'updateSessionRequest',\n                'Required parameter \"updateSessionRequest\" was null or undefined when calling updateSessionApiSessionsSessionIdPatch().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n\n        let urlPath = `/api/sessions/{session_id}`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'PATCH',\n            headers: headerParameters,\n            query: queryParameters,\n            body: UpdateSessionRequestToJSON(requestParameters['updateSessionRequest']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => SessionFromJSON(jsonValue));\n    }\n\n    /**\n     * Update a session (e.g., rename title or archive/unarchive).\n     * Update session\n     */\n    async updateSessionApiSessionsSessionIdPatch(requestParameters: UpdateSessionApiSessionsSessionIdPatchRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Session> {\n        const response = await this.updateSessionApiSessionsSessionIdPatchRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Upload a file to a session.\n     * Upload file to session\n     */\n    async uploadSessionFileApiSessionsSessionIdFilesPostRaw(requestParameters: UploadSessionFileApiSessionsSessionIdFilesPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<UploadSessionFileResponse>> {\n        if (requestParameters['sessionId'] == null) {\n            throw new runtime.RequiredError(\n                'sessionId',\n                'Required parameter \"sessionId\" was null or undefined when calling uploadSessionFileApiSessionsSessionIdFilesPost().'\n            );\n        }\n\n        if (requestParameters['file'] == null) {\n            throw new runtime.RequiredError(\n                'file',\n                'Required parameter \"file\" was null or undefined when calling uploadSessionFileApiSessionsSessionIdFilesPost().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const consumes: runtime.Consume[] = [\n            { contentType: 'multipart/form-data' },\n        ];\n        // @ts-ignore: canConsumeForm may be unused\n        const canConsumeForm = runtime.canConsumeForm(consumes);\n\n        let formParams: { append(param: string, value: any): any };\n        let useForm = false;\n        // use FormData to transmit files using content-type \"multipart/form-data\"\n        useForm = canConsumeForm;\n        if (useForm) {\n            formParams = new FormData();\n        } else {\n            formParams = new URLSearchParams();\n        }\n\n        if (requestParameters['file'] != null) {\n            formParams.append('file', requestParameters['file'] as any);\n        }\n\n\n        let urlPath = `/api/sessions/{session_id}/files`;\n        urlPath = urlPath.replace(`{${\"session_id\"}}`, encodeURIComponent(String(requestParameters['sessionId'])));\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n            body: formParams,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => UploadSessionFileResponseFromJSON(jsonValue));\n    }\n\n    /**\n     * Upload a file to a session.\n     * Upload file to session\n     */\n    async uploadSessionFileApiSessionsSessionIdFilesPost(requestParameters: UploadSessionFileApiSessionsSessionIdFilesPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<UploadSessionFileResponse> {\n        const response = await this.uploadSessionFileApiSessionsSessionIdFilesPostRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "web/src/lib/api/apis/WorkDirsApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\n\n/**\n * \n */\nexport class WorkDirsApi extends runtime.BaseAPI {\n\n    /**\n     * Get the directory where kimi web was started.\n     * Get the startup directory\n     */\n    async getStartupDirApiWorkDirsStartupGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<string>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/work-dirs/startup`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        if (this.isJsonMime(response.headers.get('content-type'))) {\n            return new runtime.JSONApiResponse<string>(response);\n        } else {\n            return new runtime.TextApiResponse(response) as any;\n        }\n    }\n\n    /**\n     * Get the directory where kimi web was started.\n     * Get the startup directory\n     */\n    async getStartupDirApiWorkDirsStartupGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<string> {\n        const response = await this.getStartupDirApiWorkDirsStartupGetRaw(initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Get a list of available work directories from metadata.\n     * List available work directories\n     */\n    async getWorkDirsApiWorkDirsGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Array<string | null>>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n\n        let urlPath = `/api/work-dirs/`;\n\n        const response = await this.request({\n            path: urlPath,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse<any>(response);\n    }\n\n    /**\n     * Get a list of available work directories from metadata.\n     * List available work directories\n     */\n    async getWorkDirsApiWorkDirsGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<string | null>> {\n        const response = await this.getWorkDirsApiWorkDirsGetRaw(initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "web/src/lib/api/apis/index.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport * from './ConfigApi';\nexport * from './DefaultApi';\nexport * from './OpenInApi';\nexport * from './SessionsApi';\nexport * from './WorkDirsApi';\n"
  },
  {
    "path": "web/src/lib/api/docs/ConfigApi.md",
    "content": "# ConfigApi\n\nAll URIs are relative to *http://localhost*\n\n| Method | HTTP request | Description |\n|------------- | ------------- | -------------|\n| [**getConfigTomlApiConfigTomlGet**](ConfigApi.md#getconfigtomlapiconfigtomlget) | **GET** /api/config/toml | Get kimi-cli config.toml |\n| [**getGlobalConfigApiConfigGet**](ConfigApi.md#getglobalconfigapiconfigget) | **GET** /api/config/ | Get global (kimi-cli) config snapshot |\n| [**updateConfigTomlApiConfigTomlPut**](ConfigApi.md#updateconfigtomlapiconfigtomlput) | **PUT** /api/config/toml | Update kimi-cli config.toml |\n| [**updateGlobalConfigApiConfigPatch**](ConfigApi.md#updateglobalconfigapiconfigpatch) | **PATCH** /api/config/ | Update global (kimi-cli) default model/thinking |\n\n\n\n## getConfigTomlApiConfigTomlGet\n\n> ConfigToml getConfigTomlApiConfigTomlGet()\n\nGet kimi-cli config.toml\n\nGet kimi-cli config.toml.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  ConfigApi,\n} from '';\nimport type { GetConfigTomlApiConfigTomlGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new ConfigApi();\n\n  try {\n    const data = await api.getConfigTomlApiConfigTomlGet();\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\nThis endpoint does not need any parameter.\n\n### Return type\n\n[**ConfigToml**](ConfigToml.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## getGlobalConfigApiConfigGet\n\n> GlobalConfig getGlobalConfigApiConfigGet()\n\nGet global (kimi-cli) config snapshot\n\nGet global (kimi-cli) config snapshot.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  ConfigApi,\n} from '';\nimport type { GetGlobalConfigApiConfigGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new ConfigApi();\n\n  try {\n    const data = await api.getGlobalConfigApiConfigGet();\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\nThis endpoint does not need any parameter.\n\n### Return type\n\n[**GlobalConfig**](GlobalConfig.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## updateConfigTomlApiConfigTomlPut\n\n> UpdateConfigTomlResponse updateConfigTomlApiConfigTomlPut(updateConfigTomlRequest)\n\nUpdate kimi-cli config.toml\n\nUpdate kimi-cli config.toml.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  ConfigApi,\n} from '';\nimport type { UpdateConfigTomlApiConfigTomlPutRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new ConfigApi();\n\n  const body = {\n    // UpdateConfigTomlRequest\n    updateConfigTomlRequest: ...,\n  } satisfies UpdateConfigTomlApiConfigTomlPutRequest;\n\n  try {\n    const data = await api.updateConfigTomlApiConfigTomlPut(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **updateConfigTomlRequest** | [UpdateConfigTomlRequest](UpdateConfigTomlRequest.md) |  | |\n\n### Return type\n\n[**UpdateConfigTomlResponse**](UpdateConfigTomlResponse.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: `application/json`\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## updateGlobalConfigApiConfigPatch\n\n> UpdateGlobalConfigResponse updateGlobalConfigApiConfigPatch(updateGlobalConfigRequest)\n\nUpdate global (kimi-cli) default model/thinking\n\nUpdate global (kimi-cli) default model/thinking.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  ConfigApi,\n} from '';\nimport type { UpdateGlobalConfigApiConfigPatchRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new ConfigApi();\n\n  const body = {\n    // UpdateGlobalConfigRequest\n    updateGlobalConfigRequest: ...,\n  } satisfies UpdateGlobalConfigApiConfigPatchRequest;\n\n  try {\n    const data = await api.updateGlobalConfigApiConfigPatch(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **updateGlobalConfigRequest** | [UpdateGlobalConfigRequest](UpdateGlobalConfigRequest.md) |  | |\n\n### Return type\n\n[**UpdateGlobalConfigResponse**](UpdateGlobalConfigResponse.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: `application/json`\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n"
  },
  {
    "path": "web/src/lib/api/docs/ConfigModel.md",
    "content": "\n# ConfigModel\n\nModel configuration for frontend.\n\n## Properties\n\nName | Type\n------------ | -------------\n`provider` | string\n`model` | string\n`maxContextSize` | number\n`capabilities` | [Set&lt;ModelCapability&gt;](ModelCapability.md)\n`name` | string\n`providerType` | [ProviderType](ProviderType.md)\n\n## Example\n\n```typescript\nimport type { ConfigModel } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"provider\": null,\n  \"model\": null,\n  \"maxContextSize\": null,\n  \"capabilities\": null,\n  \"name\": null,\n  \"providerType\": null,\n} satisfies ConfigModel\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as ConfigModel\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/ConfigToml.md",
    "content": "\n# ConfigToml\n\nRaw config.toml content.\n\n## Properties\n\nName | Type\n------------ | -------------\n`content` | string\n`path` | string\n\n## Example\n\n```typescript\nimport type { ConfigToml } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"content\": null,\n  \"path\": null,\n} satisfies ConfigToml\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as ConfigToml\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/CreateSessionRequest.md",
    "content": "\n# CreateSessionRequest\n\nCreate session request.\n\n## Properties\n\nName | Type\n------------ | -------------\n`workDir` | string\n`createDir` | boolean\n\n## Example\n\n```typescript\nimport type { CreateSessionRequest } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"workDir\": null,\n  \"createDir\": null,\n} satisfies CreateSessionRequest\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as CreateSessionRequest\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/DefaultApi.md",
    "content": "# DefaultApi\n\nAll URIs are relative to *http://localhost*\n\n| Method | HTTP request | Description |\n|------------- | ------------- | -------------|\n| [**healthProbeHealthzGet**](DefaultApi.md#healthprobehealthzget) | **GET** /healthz | Health Probe |\n\n\n\n## healthProbeHealthzGet\n\n> { [key: string]: any; } healthProbeHealthzGet()\n\nHealth Probe\n\nHealth check endpoint.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  DefaultApi,\n} from '';\nimport type { HealthProbeHealthzGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new DefaultApi();\n\n  try {\n    const data = await api.healthProbeHealthzGet();\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\nThis endpoint does not need any parameter.\n\n### Return type\n\n**{ [key: string]: any; }**\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n"
  },
  {
    "path": "web/src/lib/api/docs/GenerateTitleRequest.md",
    "content": "\n# GenerateTitleRequest\n\nGenerate title request.  Parameters are optional - if not provided, the backend will read from wire.jsonl automatically.\n\n## Properties\n\nName | Type\n------------ | -------------\n`userMessage` | string\n`assistantResponse` | string\n\n## Example\n\n```typescript\nimport type { GenerateTitleRequest } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"userMessage\": null,\n  \"assistantResponse\": null,\n} satisfies GenerateTitleRequest\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as GenerateTitleRequest\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/GenerateTitleResponse.md",
    "content": "\n# GenerateTitleResponse\n\nGenerate title response.\n\n## Properties\n\nName | Type\n------------ | -------------\n`title` | string\n\n## Example\n\n```typescript\nimport type { GenerateTitleResponse } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"title\": null,\n} satisfies GenerateTitleResponse\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as GenerateTitleResponse\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/GitDiffStats.md",
    "content": "\n# GitDiffStats\n\nGit diff statistics for a work directory.\n\n## Properties\n\nName | Type\n------------ | -------------\n`isGitRepo` | boolean\n`hasChanges` | boolean\n`totalAdditions` | number\n`totalDeletions` | number\n`files` | [Array&lt;GitFileDiff&gt;](GitFileDiff.md)\n`error` | string\n\n## Example\n\n```typescript\nimport type { GitDiffStats } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"isGitRepo\": null,\n  \"hasChanges\": null,\n  \"totalAdditions\": null,\n  \"totalDeletions\": null,\n  \"files\": null,\n  \"error\": null,\n} satisfies GitDiffStats\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as GitDiffStats\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/GitFileDiff.md",
    "content": "\n# GitFileDiff\n\nSingle file git diff statistics\n\n## Properties\n\nName | Type\n------------ | -------------\n`path` | string\n`additions` | number\n`deletions` | number\n`status` | string\n\n## Example\n\n```typescript\nimport type { GitFileDiff } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"path\": null,\n  \"additions\": null,\n  \"deletions\": null,\n  \"status\": null,\n} satisfies GitFileDiff\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as GitFileDiff\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/GlobalConfig.md",
    "content": "\n# GlobalConfig\n\nGlobal configuration snapshot for frontend.\n\n## Properties\n\nName | Type\n------------ | -------------\n`defaultModel` | string\n`defaultThinking` | boolean\n`models` | [Array&lt;ConfigModel&gt;](ConfigModel.md)\n\n## Example\n\n```typescript\nimport type { GlobalConfig } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"defaultModel\": null,\n  \"defaultThinking\": null,\n  \"models\": null,\n} satisfies GlobalConfig\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as GlobalConfig\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/HTTPValidationError.md",
    "content": "\n# HTTPValidationError\n\n\n## Properties\n\nName | Type\n------------ | -------------\n`detail` | [Array&lt;ValidationError&gt;](ValidationError.md)\n\n## Example\n\n```typescript\nimport type { HTTPValidationError } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"detail\": null,\n} satisfies HTTPValidationError\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as HTTPValidationError\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/ModelCapability.md",
    "content": "\n# ModelCapability\n\n\n## Properties\n\nName | Type\n------------ | -------------\n\n## Example\n\n```typescript\nimport type { ModelCapability } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n} satisfies ModelCapability\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as ModelCapability\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/OpenInApi.md",
    "content": "# OpenInApi\n\nAll URIs are relative to *http://localhost*\n\n| Method | HTTP request | Description |\n|------------- | ------------- | -------------|\n| [**openInApiOpenInPost**](OpenInApi.md#openinapiopeninpost) | **POST** /api/open-in | Open a path in a local application |\n\n\n\n## openInApiOpenInPost\n\n> OpenInResponse openInApiOpenInPost(openInRequest)\n\nOpen a path in a local application\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  OpenInApi,\n} from '';\nimport type { OpenInApiOpenInPostRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new OpenInApi();\n\n  const body = {\n    // OpenInRequest\n    openInRequest: ...,\n  } satisfies OpenInApiOpenInPostRequest;\n\n  try {\n    const data = await api.openInApiOpenInPost(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **openInRequest** | [OpenInRequest](OpenInRequest.md) |  | |\n\n### Return type\n\n[**OpenInResponse**](OpenInResponse.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: `application/json`\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n"
  },
  {
    "path": "web/src/lib/api/docs/OpenInRequest.md",
    "content": "\n# OpenInRequest\n\nOpen path in a local app.\n\n## Properties\n\nName | Type\n------------ | -------------\n`app` | string\n`path` | string\n\n## Example\n\n```typescript\nimport type { OpenInRequest } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"app\": null,\n  \"path\": null,\n} satisfies OpenInRequest\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as OpenInRequest\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/OpenInResponse.md",
    "content": "\n# OpenInResponse\n\nOpen path response.\n\n## Properties\n\nName | Type\n------------ | -------------\n`ok` | boolean\n`detail` | string\n\n## Example\n\n```typescript\nimport type { OpenInResponse } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"ok\": null,\n  \"detail\": null,\n} satisfies OpenInResponse\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as OpenInResponse\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/ProviderType.md",
    "content": "\n# ProviderType\n\n\n## Properties\n\nName | Type\n------------ | -------------\n\n## Example\n\n```typescript\nimport type { ProviderType } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n} satisfies ProviderType\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as ProviderType\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/Session.md",
    "content": "\n# Session\n\nWeb UI session metadata.\n\n## Properties\n\nName | Type\n------------ | -------------\n`sessionId` | string\n`title` | string\n`lastUpdated` | Date\n`isRunning` | boolean\n`status` | [SessionStatus](SessionStatus.md)\n`workDir` | string\n`sessionDir` | string\n`archived` | boolean\n\n## Example\n\n```typescript\nimport type { Session } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"sessionId\": null,\n  \"title\": null,\n  \"lastUpdated\": null,\n  \"isRunning\": null,\n  \"status\": null,\n  \"workDir\": null,\n  \"sessionDir\": null,\n  \"archived\": null,\n} satisfies Session\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as Session\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/SessionStatus.md",
    "content": "\n# SessionStatus\n\nRuntime status of a web session.\n\n## Properties\n\nName | Type\n------------ | -------------\n`sessionId` | string\n`state` | string\n`seq` | number\n`workerId` | string\n`reason` | string\n`detail` | string\n`updatedAt` | Date\n\n## Example\n\n```typescript\nimport type { SessionStatus } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"sessionId\": null,\n  \"state\": null,\n  \"seq\": null,\n  \"workerId\": null,\n  \"reason\": null,\n  \"detail\": null,\n  \"updatedAt\": null,\n} satisfies SessionStatus\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as SessionStatus\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/SessionsApi.md",
    "content": "# SessionsApi\n\nAll URIs are relative to *http://localhost*\n\n| Method | HTTP request | Description |\n|------------- | ------------- | -------------|\n| [**createSessionApiSessionsPost**](SessionsApi.md#createsessionapisessionspost) | **POST** /api/sessions/ | Create a new session |\n| [**deleteSessionApiSessionsSessionIdDelete**](SessionsApi.md#deletesessionapisessionssessioniddelete) | **DELETE** /api/sessions/{session_id} | Delete a session |\n| [**generateSessionTitleApiSessionsSessionIdGenerateTitlePost**](SessionsApi.md#generatesessiontitleapisessionssessionidgeneratetitlepost) | **POST** /api/sessions/{session_id}/generate-title | Generate session title using AI |\n| [**getSessionApiSessionsSessionIdGet**](SessionsApi.md#getsessionapisessionssessionidget) | **GET** /api/sessions/{session_id} | Get session |\n| [**getSessionFileApiSessionsSessionIdFilesPathGet**](SessionsApi.md#getsessionfileapisessionssessionidfilespathget) | **GET** /api/sessions/{session_id}/files/{path} | Get file or list directory from session work_dir |\n| [**getSessionGitDiffApiSessionsSessionIdGitDiffGet**](SessionsApi.md#getsessiongitdiffapisessionssessionidgitdiffget) | **GET** /api/sessions/{session_id}/git-diff | Get git diff stats |\n| [**getSessionUploadFileApiSessionsSessionIdUploadsPathGet**](SessionsApi.md#getsessionuploadfileapisessionssessioniduploadspathget) | **GET** /api/sessions/{session_id}/uploads/{path} | Get uploaded file from session uploads |\n| [**listSessionsApiSessionsGet**](SessionsApi.md#listsessionsapisessionsget) | **GET** /api/sessions/ | List all sessions |\n| [**updateSessionApiSessionsSessionIdPatch**](SessionsApi.md#updatesessionapisessionssessionidpatch) | **PATCH** /api/sessions/{session_id} | Update session |\n| [**uploadSessionFileApiSessionsSessionIdFilesPost**](SessionsApi.md#uploadsessionfileapisessionssessionidfilespost) | **POST** /api/sessions/{session_id}/files | Upload file to session |\n\n\n\n## createSessionApiSessionsPost\n\n> Session createSessionApiSessionsPost(createSessionRequest)\n\nCreate a new session\n\nCreate a new session.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { CreateSessionApiSessionsPostRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // CreateSessionRequest (optional)\n    createSessionRequest: ...,\n  } satisfies CreateSessionApiSessionsPostRequest;\n\n  try {\n    const data = await api.createSessionApiSessionsPost(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **createSessionRequest** | [CreateSessionRequest](CreateSessionRequest.md) |  | [Optional] |\n\n### Return type\n\n[**Session**](Session.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: `application/json`\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## deleteSessionApiSessionsSessionIdDelete\n\n> any deleteSessionApiSessionsSessionIdDelete(sessionId)\n\nDelete a session\n\nDelete a session.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { DeleteSessionApiSessionsSessionIdDeleteRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n  } satisfies DeleteSessionApiSessionsSessionIdDeleteRequest;\n\n  try {\n    const data = await api.deleteSessionApiSessionsSessionIdDelete(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n\n### Return type\n\n**any**\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## generateSessionTitleApiSessionsSessionIdGenerateTitlePost\n\n> GenerateTitleResponse generateSessionTitleApiSessionsSessionIdGenerateTitlePost(sessionId, generateTitleRequest)\n\nGenerate session title using AI\n\nGenerate a concise session title using AI based on the first conversation turn.  If request body is empty or parameters are missing, the backend will automatically read the first turn from wire.jsonl.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { GenerateSessionTitleApiSessionsSessionIdGenerateTitlePostRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n    // GenerateTitleRequest (optional)\n    generateTitleRequest: ...,\n  } satisfies GenerateSessionTitleApiSessionsSessionIdGenerateTitlePostRequest;\n\n  try {\n    const data = await api.generateSessionTitleApiSessionsSessionIdGenerateTitlePost(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n| **generateTitleRequest** | [GenerateTitleRequest](GenerateTitleRequest.md) |  | [Optional] |\n\n### Return type\n\n[**GenerateTitleResponse**](GenerateTitleResponse.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: `application/json`\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## getSessionApiSessionsSessionIdGet\n\n> Session getSessionApiSessionsSessionIdGet(sessionId)\n\nGet session\n\nGet a session by ID.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { GetSessionApiSessionsSessionIdGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n  } satisfies GetSessionApiSessionsSessionIdGetRequest;\n\n  try {\n    const data = await api.getSessionApiSessionsSessionIdGet(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n\n### Return type\n\n[**Session**](Session.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## getSessionFileApiSessionsSessionIdFilesPathGet\n\n> any getSessionFileApiSessionsSessionIdFilesPathGet(sessionId, path)\n\nGet file or list directory from session work_dir\n\nGet a file or list directory from session work directory.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { GetSessionFileApiSessionsSessionIdFilesPathGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n    // string\n    path: path_example,\n  } satisfies GetSessionFileApiSessionsSessionIdFilesPathGetRequest;\n\n  try {\n    const data = await api.getSessionFileApiSessionsSessionIdFilesPathGet(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n| **path** | `string` |  | [Defaults to `undefined`] |\n\n### Return type\n\n**any**\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## getSessionGitDiffApiSessionsSessionIdGitDiffGet\n\n> GitDiffStats getSessionGitDiffApiSessionsSessionIdGitDiffGet(sessionId)\n\nGet git diff stats\n\nget git diff stats for the session\\&#39;s work directory\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { GetSessionGitDiffApiSessionsSessionIdGitDiffGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n  } satisfies GetSessionGitDiffApiSessionsSessionIdGitDiffGetRequest;\n\n  try {\n    const data = await api.getSessionGitDiffApiSessionsSessionIdGitDiffGet(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n\n### Return type\n\n[**GitDiffStats**](GitDiffStats.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## getSessionUploadFileApiSessionsSessionIdUploadsPathGet\n\n> any getSessionUploadFileApiSessionsSessionIdUploadsPathGet(sessionId, path)\n\nGet uploaded file from session uploads\n\nGet a file from a session\\&#39;s uploads directory.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { GetSessionUploadFileApiSessionsSessionIdUploadsPathGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n    // string\n    path: path_example,\n  } satisfies GetSessionUploadFileApiSessionsSessionIdUploadsPathGetRequest;\n\n  try {\n    const data = await api.getSessionUploadFileApiSessionsSessionIdUploadsPathGet(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n| **path** | `string` |  | [Defaults to `undefined`] |\n\n### Return type\n\n**any**\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## listSessionsApiSessionsGet\n\n> Array&lt;Session&gt; listSessionsApiSessionsGet(limit, offset, q, archived)\n\nList all sessions\n\nList sessions with optional pagination and search.  Args:     limit: Maximum number of sessions to return (default 100, max 500).     offset: Number of sessions to skip (default 0).     q: Optional search query to filter by title or work_dir.     archived: Filter by archived status.         - None (default): Only return non-archived sessions.         - True: Only return archived sessions.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { ListSessionsApiSessionsGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // number (optional)\n    limit: 56,\n    // number (optional)\n    offset: 56,\n    // string (optional)\n    q: q_example,\n    // boolean (optional)\n    archived: true,\n  } satisfies ListSessionsApiSessionsGetRequest;\n\n  try {\n    const data = await api.listSessionsApiSessionsGet(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **limit** | `number` |  | [Optional] [Defaults to `100`] |\n| **offset** | `number` |  | [Optional] [Defaults to `0`] |\n| **q** | `string` |  | [Optional] [Defaults to `undefined`] |\n| **archived** | `boolean` |  | [Optional] [Defaults to `undefined`] |\n\n### Return type\n\n[**Array&lt;Session&gt;**](Session.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## updateSessionApiSessionsSessionIdPatch\n\n> Session updateSessionApiSessionsSessionIdPatch(sessionId, updateSessionRequest)\n\nUpdate session\n\nUpdate a session (e.g., rename title or archive/unarchive).\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { UpdateSessionApiSessionsSessionIdPatchRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n    // UpdateSessionRequest\n    updateSessionRequest: ...,\n  } satisfies UpdateSessionApiSessionsSessionIdPatchRequest;\n\n  try {\n    const data = await api.updateSessionApiSessionsSessionIdPatch(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n| **updateSessionRequest** | [UpdateSessionRequest](UpdateSessionRequest.md) |  | |\n\n### Return type\n\n[**Session**](Session.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: `application/json`\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## uploadSessionFileApiSessionsSessionIdFilesPost\n\n> UploadSessionFileResponse uploadSessionFileApiSessionsSessionIdFilesPost(sessionId, file)\n\nUpload file to session\n\nUpload a file to a session.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  SessionsApi,\n} from '';\nimport type { UploadSessionFileApiSessionsSessionIdFilesPostRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new SessionsApi();\n\n  const body = {\n    // string\n    sessionId: 38400000-8cf0-11bd-b23e-10b96e4ef00d,\n    // Blob\n    file: BINARY_DATA_HERE,\n  } satisfies UploadSessionFileApiSessionsSessionIdFilesPostRequest;\n\n  try {\n    const data = await api.uploadSessionFileApiSessionsSessionIdFilesPost(body);\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\n\n| Name | Type | Description  | Notes |\n|------------- | ------------- | ------------- | -------------|\n| **sessionId** | `string` |  | [Defaults to `undefined`] |\n| **file** | `Blob` |  | [Defaults to `undefined`] |\n\n### Return type\n\n[**UploadSessionFileResponse**](UploadSessionFileResponse.md)\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: `multipart/form-data`\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n| **422** | Validation Error |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n"
  },
  {
    "path": "web/src/lib/api/docs/UpdateConfigTomlRequest.md",
    "content": "\n# UpdateConfigTomlRequest\n\nRequest to update config.toml.\n\n## Properties\n\nName | Type\n------------ | -------------\n`content` | string\n\n## Example\n\n```typescript\nimport type { UpdateConfigTomlRequest } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"content\": null,\n} satisfies UpdateConfigTomlRequest\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as UpdateConfigTomlRequest\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/UpdateConfigTomlResponse.md",
    "content": "\n# UpdateConfigTomlResponse\n\nResponse after updating config.toml.\n\n## Properties\n\nName | Type\n------------ | -------------\n`success` | boolean\n`error` | string\n\n## Example\n\n```typescript\nimport type { UpdateConfigTomlResponse } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"success\": null,\n  \"error\": null,\n} satisfies UpdateConfigTomlResponse\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as UpdateConfigTomlResponse\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/UpdateGlobalConfigRequest.md",
    "content": "\n# UpdateGlobalConfigRequest\n\nRequest to update global config.\n\n## Properties\n\nName | Type\n------------ | -------------\n`defaultModel` | string\n`defaultThinking` | boolean\n`restartRunningSessions` | boolean\n`forceRestartBusySessions` | boolean\n\n## Example\n\n```typescript\nimport type { UpdateGlobalConfigRequest } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"defaultModel\": null,\n  \"defaultThinking\": null,\n  \"restartRunningSessions\": null,\n  \"forceRestartBusySessions\": null,\n} satisfies UpdateGlobalConfigRequest\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as UpdateGlobalConfigRequest\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/UpdateGlobalConfigResponse.md",
    "content": "\n# UpdateGlobalConfigResponse\n\nResponse after updating global config.\n\n## Properties\n\nName | Type\n------------ | -------------\n`config` | [GlobalConfig](GlobalConfig.md)\n`restartedSessionIds` | Array&lt;string&gt;\n`skippedBusySessionIds` | Array&lt;string&gt;\n\n## Example\n\n```typescript\nimport type { UpdateGlobalConfigResponse } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"config\": null,\n  \"restartedSessionIds\": null,\n  \"skippedBusySessionIds\": null,\n} satisfies UpdateGlobalConfigResponse\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as UpdateGlobalConfigResponse\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/UpdateSessionRequest.md",
    "content": "\n# UpdateSessionRequest\n\nUpdate session request.\n\n## Properties\n\nName | Type\n------------ | -------------\n`title` | string\n`archived` | boolean\n\n## Example\n\n```typescript\nimport type { UpdateSessionRequest } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"title\": null,\n  \"archived\": null,\n} satisfies UpdateSessionRequest\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as UpdateSessionRequest\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/UploadSessionFileResponse.md",
    "content": "\n# UploadSessionFileResponse\n\nUpload file response.\n\n## Properties\n\nName | Type\n------------ | -------------\n`path` | string\n`filename` | string\n`size` | number\n\n## Example\n\n```typescript\nimport type { UploadSessionFileResponse } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"path\": null,\n  \"filename\": null,\n  \"size\": null,\n} satisfies UploadSessionFileResponse\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as UploadSessionFileResponse\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/ValidationError.md",
    "content": "\n# ValidationError\n\n\n## Properties\n\nName | Type\n------------ | -------------\n`loc` | [Array&lt;ValidationErrorLocInner&gt;](ValidationErrorLocInner.md)\n`msg` | string\n`type` | string\n\n## Example\n\n```typescript\nimport type { ValidationError } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n  \"loc\": null,\n  \"msg\": null,\n  \"type\": null,\n} satisfies ValidationError\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as ValidationError\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/ValidationErrorLocInner.md",
    "content": "\n# ValidationErrorLocInner\n\n\n## Properties\n\nName | Type\n------------ | -------------\n\n## Example\n\n```typescript\nimport type { ValidationErrorLocInner } from ''\n\n// TODO: Update the object below with actual values\nconst example = {\n} satisfies ValidationErrorLocInner\n\nconsole.log(example)\n\n// Convert the instance to a JSON string\nconst exampleJSON: string = JSON.stringify(example)\nconsole.log(exampleJSON)\n\n// Parse the JSON string back to an object\nconst exampleParsed = JSON.parse(exampleJSON) as ValidationErrorLocInner\nconsole.log(exampleParsed)\n```\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n"
  },
  {
    "path": "web/src/lib/api/docs/WorkDirsApi.md",
    "content": "# WorkDirsApi\n\nAll URIs are relative to *http://localhost*\n\n| Method | HTTP request | Description |\n|------------- | ------------- | -------------|\n| [**getStartupDirApiWorkDirsStartupGet**](WorkDirsApi.md#getstartupdirapiworkdirsstartupget) | **GET** /api/work-dirs/startup | Get the startup directory |\n| [**getWorkDirsApiWorkDirsGet**](WorkDirsApi.md#getworkdirsapiworkdirsget) | **GET** /api/work-dirs/ | List available work directories |\n\n\n\n## getStartupDirApiWorkDirsStartupGet\n\n> string getStartupDirApiWorkDirsStartupGet()\n\nGet the startup directory\n\nGet the directory where kimi web was started.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  WorkDirsApi,\n} from '';\nimport type { GetStartupDirApiWorkDirsStartupGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new WorkDirsApi();\n\n  try {\n    const data = await api.getStartupDirApiWorkDirsStartupGet();\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\nThis endpoint does not need any parameter.\n\n### Return type\n\n**string**\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n\n## getWorkDirsApiWorkDirsGet\n\n> Array&lt;string | null&gt; getWorkDirsApiWorkDirsGet()\n\nList available work directories\n\nGet a list of available work directories from metadata.\n\n### Example\n\n```ts\nimport {\n  Configuration,\n  WorkDirsApi,\n} from '';\nimport type { GetWorkDirsApiWorkDirsGetRequest } from '';\n\nasync function example() {\n  console.log(\"🚀 Testing  SDK...\");\n  const api = new WorkDirsApi();\n\n  try {\n    const data = await api.getWorkDirsApiWorkDirsGet();\n    console.log(data);\n  } catch (error) {\n    console.error(error);\n  }\n}\n\n// Run the test\nexample().catch(console.error);\n```\n\n### Parameters\n\nThis endpoint does not need any parameter.\n\n### Return type\n\n**Array<string | null>**\n\n### Authorization\n\nNo authorization required\n\n### HTTP request headers\n\n- **Content-Type**: Not defined\n- **Accept**: `application/json`\n\n\n### HTTP response details\n| Status code | Description | Response headers |\n|-------------|-------------|------------------|\n| **200** | Successful Response |  -  |\n\n[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)\n\n"
  },
  {
    "path": "web/src/lib/api/index.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport * from './runtime';\nexport * from './apis/index';\nexport * from './models/index';\n"
  },
  {
    "path": "web/src/lib/api/models/ConfigModel.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ModelCapability } from './ModelCapability';\nimport {\n    ModelCapabilityFromJSON,\n    ModelCapabilityFromJSONTyped,\n    ModelCapabilityToJSON,\n    ModelCapabilityToJSONTyped,\n} from './ModelCapability';\nimport type { ProviderType } from './ProviderType';\nimport {\n    ProviderTypeFromJSON,\n    ProviderTypeFromJSONTyped,\n    ProviderTypeToJSON,\n    ProviderTypeToJSONTyped,\n} from './ProviderType';\n\n/**\n * Model configuration for frontend.\n * @export\n * @interface ConfigModel\n */\nexport interface ConfigModel {\n    /**\n     * \n     * @type {string}\n     * @memberof ConfigModel\n     */\n    provider: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ConfigModel\n     */\n    model: string;\n    /**\n     * \n     * @type {number}\n     * @memberof ConfigModel\n     */\n    maxContextSize: number;\n    /**\n     * \n     * @type {Set<ModelCapability>}\n     * @memberof ConfigModel\n     */\n    capabilities?: Set<ModelCapability> | null;\n    /**\n     * Model key in kimi-cli config (Config.models)\n     * @type {string}\n     * @memberof ConfigModel\n     */\n    name: string;\n    /**\n     * Provider type (LLMProvider.type)\n     * @type {ProviderType}\n     * @memberof ConfigModel\n     */\n    providerType: ProviderType;\n}\n\n\n\n/**\n * Check if a given object implements the ConfigModel interface.\n */\nexport function instanceOfConfigModel(value: object): value is ConfigModel {\n    if (!('provider' in value) || value['provider'] === undefined) return false;\n    if (!('model' in value) || value['model'] === undefined) return false;\n    if (!('maxContextSize' in value) || value['maxContextSize'] === undefined) return false;\n    if (!('name' in value) || value['name'] === undefined) return false;\n    if (!('providerType' in value) || value['providerType'] === undefined) return false;\n    return true;\n}\n\nexport function ConfigModelFromJSON(json: any): ConfigModel {\n    return ConfigModelFromJSONTyped(json, false);\n}\n\nexport function ConfigModelFromJSONTyped(json: any, ignoreDiscriminator: boolean): ConfigModel {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'provider': json['provider'],\n        'model': json['model'],\n        'maxContextSize': json['max_context_size'],\n        'capabilities': json['capabilities'] == null ? undefined : (new Set((json['capabilities'] as Array<any>).map(ModelCapabilityFromJSON))),\n        'name': json['name'],\n        'providerType': ProviderTypeFromJSON(json['provider_type']),\n    };\n}\n\nexport function ConfigModelToJSON(json: any): ConfigModel {\n    return ConfigModelToJSONTyped(json, false);\n}\n\nexport function ConfigModelToJSONTyped(value?: ConfigModel | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'provider': value['provider'],\n        'model': value['model'],\n        'max_context_size': value['maxContextSize'],\n        'capabilities': value['capabilities'] == null ? undefined : (Array.from(value['capabilities'] as Set<any>).map(ModelCapabilityToJSON)),\n        'name': value['name'],\n        'provider_type': ProviderTypeToJSON(value['providerType']),\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/ConfigToml.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Raw config.toml content.\n * @export\n * @interface ConfigToml\n */\nexport interface ConfigToml {\n    /**\n     * Raw TOML content\n     * @type {string}\n     * @memberof ConfigToml\n     */\n    content: string;\n    /**\n     * Path to config file\n     * @type {string}\n     * @memberof ConfigToml\n     */\n    path: string;\n}\n\n/**\n * Check if a given object implements the ConfigToml interface.\n */\nexport function instanceOfConfigToml(value: object): value is ConfigToml {\n    if (!('content' in value) || value['content'] === undefined) return false;\n    if (!('path' in value) || value['path'] === undefined) return false;\n    return true;\n}\n\nexport function ConfigTomlFromJSON(json: any): ConfigToml {\n    return ConfigTomlFromJSONTyped(json, false);\n}\n\nexport function ConfigTomlFromJSONTyped(json: any, ignoreDiscriminator: boolean): ConfigToml {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'content': json['content'],\n        'path': json['path'],\n    };\n}\n\nexport function ConfigTomlToJSON(json: any): ConfigToml {\n    return ConfigTomlToJSONTyped(json, false);\n}\n\nexport function ConfigTomlToJSONTyped(value?: ConfigToml | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'content': value['content'],\n        'path': value['path'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/CreateSessionRequest.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Create session request.\n * @export\n * @interface CreateSessionRequest\n */\nexport interface CreateSessionRequest {\n    /**\n     * \n     * @type {string}\n     * @memberof CreateSessionRequest\n     */\n    workDir?: string | null;\n    /**\n     * \n     * @type {boolean}\n     * @memberof CreateSessionRequest\n     */\n    createDir?: boolean;\n}\n\n/**\n * Check if a given object implements the CreateSessionRequest interface.\n */\nexport function instanceOfCreateSessionRequest(value: object): value is CreateSessionRequest {\n    return true;\n}\n\nexport function CreateSessionRequestFromJSON(json: any): CreateSessionRequest {\n    return CreateSessionRequestFromJSONTyped(json, false);\n}\n\nexport function CreateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): CreateSessionRequest {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'workDir': json['work_dir'] == null ? undefined : json['work_dir'],\n        'createDir': json['create_dir'] == null ? undefined : json['create_dir'],\n    };\n}\n\nexport function CreateSessionRequestToJSON(json: any): CreateSessionRequest {\n    return CreateSessionRequestToJSONTyped(json, false);\n}\n\nexport function CreateSessionRequestToJSONTyped(value?: CreateSessionRequest | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'work_dir': value['workDir'],\n        'create_dir': value['createDir'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/GenerateTitleRequest.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Generate title request.\n * \n * Parameters are optional - if not provided, the backend will read\n * from wire.jsonl automatically.\n * @export\n * @interface GenerateTitleRequest\n */\nexport interface GenerateTitleRequest {\n    /**\n     * \n     * @type {string}\n     * @memberof GenerateTitleRequest\n     */\n    userMessage?: string | null;\n    /**\n     * \n     * @type {string}\n     * @memberof GenerateTitleRequest\n     */\n    assistantResponse?: string | null;\n}\n\n/**\n * Check if a given object implements the GenerateTitleRequest interface.\n */\nexport function instanceOfGenerateTitleRequest(value: object): value is GenerateTitleRequest {\n    return true;\n}\n\nexport function GenerateTitleRequestFromJSON(json: any): GenerateTitleRequest {\n    return GenerateTitleRequestFromJSONTyped(json, false);\n}\n\nexport function GenerateTitleRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): GenerateTitleRequest {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'userMessage': json['user_message'] == null ? undefined : json['user_message'],\n        'assistantResponse': json['assistant_response'] == null ? undefined : json['assistant_response'],\n    };\n}\n\nexport function GenerateTitleRequestToJSON(json: any): GenerateTitleRequest {\n    return GenerateTitleRequestToJSONTyped(json, false);\n}\n\nexport function GenerateTitleRequestToJSONTyped(value?: GenerateTitleRequest | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'user_message': value['userMessage'],\n        'assistant_response': value['assistantResponse'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/GenerateTitleResponse.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Generate title response.\n * @export\n * @interface GenerateTitleResponse\n */\nexport interface GenerateTitleResponse {\n    /**\n     * \n     * @type {string}\n     * @memberof GenerateTitleResponse\n     */\n    title: string;\n}\n\n/**\n * Check if a given object implements the GenerateTitleResponse interface.\n */\nexport function instanceOfGenerateTitleResponse(value: object): value is GenerateTitleResponse {\n    if (!('title' in value) || value['title'] === undefined) return false;\n    return true;\n}\n\nexport function GenerateTitleResponseFromJSON(json: any): GenerateTitleResponse {\n    return GenerateTitleResponseFromJSONTyped(json, false);\n}\n\nexport function GenerateTitleResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): GenerateTitleResponse {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'title': json['title'],\n    };\n}\n\nexport function GenerateTitleResponseToJSON(json: any): GenerateTitleResponse {\n    return GenerateTitleResponseToJSONTyped(json, false);\n}\n\nexport function GenerateTitleResponseToJSONTyped(value?: GenerateTitleResponse | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'title': value['title'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/GitDiffStats.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { GitFileDiff } from './GitFileDiff';\nimport {\n    GitFileDiffFromJSON,\n    GitFileDiffFromJSONTyped,\n    GitFileDiffToJSON,\n    GitFileDiffToJSONTyped,\n} from './GitFileDiff';\n\n/**\n * Git diff statistics for a work directory.\n * @export\n * @interface GitDiffStats\n */\nexport interface GitDiffStats {\n    /**\n     * Whether the directory is a git repo\n     * @type {boolean}\n     * @memberof GitDiffStats\n     */\n    isGitRepo: boolean;\n    /**\n     * Whether there are uncommitted changes\n     * @type {boolean}\n     * @memberof GitDiffStats\n     */\n    hasChanges?: boolean;\n    /**\n     * Total added lines\n     * @type {number}\n     * @memberof GitDiffStats\n     */\n    totalAdditions?: number;\n    /**\n     * Total deleted lines\n     * @type {number}\n     * @memberof GitDiffStats\n     */\n    totalDeletions?: number;\n    /**\n     * Per-file diff stats\n     * @type {Array<GitFileDiff>}\n     * @memberof GitDiffStats\n     */\n    files?: Array<GitFileDiff>;\n    /**\n     * \n     * @type {string}\n     * @memberof GitDiffStats\n     */\n    error?: string | null;\n}\n\n/**\n * Check if a given object implements the GitDiffStats interface.\n */\nexport function instanceOfGitDiffStats(value: object): value is GitDiffStats {\n    if (!('isGitRepo' in value) || value['isGitRepo'] === undefined) return false;\n    return true;\n}\n\nexport function GitDiffStatsFromJSON(json: any): GitDiffStats {\n    return GitDiffStatsFromJSONTyped(json, false);\n}\n\nexport function GitDiffStatsFromJSONTyped(json: any, ignoreDiscriminator: boolean): GitDiffStats {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'isGitRepo': json['is_git_repo'],\n        'hasChanges': json['has_changes'] == null ? undefined : json['has_changes'],\n        'totalAdditions': json['total_additions'] == null ? undefined : json['total_additions'],\n        'totalDeletions': json['total_deletions'] == null ? undefined : json['total_deletions'],\n        'files': json['files'] == null ? undefined : ((json['files'] as Array<any>).map(GitFileDiffFromJSON)),\n        'error': json['error'] == null ? undefined : json['error'],\n    };\n}\n\nexport function GitDiffStatsToJSON(json: any): GitDiffStats {\n    return GitDiffStatsToJSONTyped(json, false);\n}\n\nexport function GitDiffStatsToJSONTyped(value?: GitDiffStats | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'is_git_repo': value['isGitRepo'],\n        'has_changes': value['hasChanges'],\n        'total_additions': value['totalAdditions'],\n        'total_deletions': value['totalDeletions'],\n        'files': value['files'] == null ? undefined : ((value['files'] as Array<any>).map(GitFileDiffToJSON)),\n        'error': value['error'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/GitFileDiff.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Single file git diff statistics\n * @export\n * @interface GitFileDiff\n */\nexport interface GitFileDiff {\n    /**\n     * File path\n     * @type {string}\n     * @memberof GitFileDiff\n     */\n    path: string;\n    /**\n     * Number of added lines\n     * @type {number}\n     * @memberof GitFileDiff\n     */\n    additions: number;\n    /**\n     * Number of deleted lines\n     * @type {number}\n     * @memberof GitFileDiff\n     */\n    deletions: number;\n    /**\n     * File change status\n     * @type {string}\n     * @memberof GitFileDiff\n     */\n    status: GitFileDiffStatusEnum;\n}\n\n\n/**\n * @export\n */\nexport const GitFileDiffStatusEnum = {\n    Added: 'added',\n    Modified: 'modified',\n    Deleted: 'deleted',\n    Renamed: 'renamed'\n} as const;\nexport type GitFileDiffStatusEnum = typeof GitFileDiffStatusEnum[keyof typeof GitFileDiffStatusEnum];\n\n\n/**\n * Check if a given object implements the GitFileDiff interface.\n */\nexport function instanceOfGitFileDiff(value: object): value is GitFileDiff {\n    if (!('path' in value) || value['path'] === undefined) return false;\n    if (!('additions' in value) || value['additions'] === undefined) return false;\n    if (!('deletions' in value) || value['deletions'] === undefined) return false;\n    if (!('status' in value) || value['status'] === undefined) return false;\n    return true;\n}\n\nexport function GitFileDiffFromJSON(json: any): GitFileDiff {\n    return GitFileDiffFromJSONTyped(json, false);\n}\n\nexport function GitFileDiffFromJSONTyped(json: any, ignoreDiscriminator: boolean): GitFileDiff {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'path': json['path'],\n        'additions': json['additions'],\n        'deletions': json['deletions'],\n        'status': json['status'],\n    };\n}\n\nexport function GitFileDiffToJSON(json: any): GitFileDiff {\n    return GitFileDiffToJSONTyped(json, false);\n}\n\nexport function GitFileDiffToJSONTyped(value?: GitFileDiff | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'path': value['path'],\n        'additions': value['additions'],\n        'deletions': value['deletions'],\n        'status': value['status'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/GlobalConfig.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ConfigModel } from './ConfigModel';\nimport {\n    ConfigModelFromJSON,\n    ConfigModelFromJSONTyped,\n    ConfigModelToJSON,\n    ConfigModelToJSONTyped,\n} from './ConfigModel';\n\n/**\n * Global configuration snapshot for frontend.\n * @export\n * @interface GlobalConfig\n */\nexport interface GlobalConfig {\n    /**\n     * Current default model key\n     * @type {string}\n     * @memberof GlobalConfig\n     */\n    defaultModel: string;\n    /**\n     * Current default thinking mode\n     * @type {boolean}\n     * @memberof GlobalConfig\n     */\n    defaultThinking: boolean;\n    /**\n     * All configured models\n     * @type {Array<ConfigModel>}\n     * @memberof GlobalConfig\n     */\n    models: Array<ConfigModel>;\n}\n\n/**\n * Check if a given object implements the GlobalConfig interface.\n */\nexport function instanceOfGlobalConfig(value: object): value is GlobalConfig {\n    if (!('defaultModel' in value) || value['defaultModel'] === undefined) return false;\n    if (!('defaultThinking' in value) || value['defaultThinking'] === undefined) return false;\n    if (!('models' in value) || value['models'] === undefined) return false;\n    return true;\n}\n\nexport function GlobalConfigFromJSON(json: any): GlobalConfig {\n    return GlobalConfigFromJSONTyped(json, false);\n}\n\nexport function GlobalConfigFromJSONTyped(json: any, ignoreDiscriminator: boolean): GlobalConfig {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'defaultModel': json['default_model'],\n        'defaultThinking': json['default_thinking'],\n        'models': ((json['models'] as Array<any>).map(ConfigModelFromJSON)),\n    };\n}\n\nexport function GlobalConfigToJSON(json: any): GlobalConfig {\n    return GlobalConfigToJSONTyped(json, false);\n}\n\nexport function GlobalConfigToJSONTyped(value?: GlobalConfig | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'default_model': value['defaultModel'],\n        'default_thinking': value['defaultThinking'],\n        'models': ((value['models'] as Array<any>).map(ConfigModelToJSON)),\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/HTTPValidationError.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ValidationError } from './ValidationError';\nimport {\n    ValidationErrorFromJSON,\n    ValidationErrorFromJSONTyped,\n    ValidationErrorToJSON,\n    ValidationErrorToJSONTyped,\n} from './ValidationError';\n\n/**\n * \n * @export\n * @interface HTTPValidationError\n */\nexport interface HTTPValidationError {\n    /**\n     * \n     * @type {Array<ValidationError>}\n     * @memberof HTTPValidationError\n     */\n    detail?: Array<ValidationError>;\n}\n\n/**\n * Check if a given object implements the HTTPValidationError interface.\n */\nexport function instanceOfHTTPValidationError(value: object): value is HTTPValidationError {\n    return true;\n}\n\nexport function HTTPValidationErrorFromJSON(json: any): HTTPValidationError {\n    return HTTPValidationErrorFromJSONTyped(json, false);\n}\n\nexport function HTTPValidationErrorFromJSONTyped(json: any, ignoreDiscriminator: boolean): HTTPValidationError {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'detail': json['detail'] == null ? undefined : ((json['detail'] as Array<any>).map(ValidationErrorFromJSON)),\n    };\n}\n\nexport function HTTPValidationErrorToJSON(json: any): HTTPValidationError {\n    return HTTPValidationErrorToJSONTyped(json, false);\n}\n\nexport function HTTPValidationErrorToJSONTyped(value?: HTTPValidationError | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'detail': value['detail'] == null ? undefined : ((value['detail'] as Array<any>).map(ValidationErrorToJSON)),\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/ModelCapability.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\n/**\n * \n * @export\n */\nexport const ModelCapability = {\n    ImageIn: 'image_in',\n    VideoIn: 'video_in',\n    Thinking: 'thinking',\n    AlwaysThinking: 'always_thinking'\n} as const;\nexport type ModelCapability = typeof ModelCapability[keyof typeof ModelCapability];\n\n\nexport function instanceOfModelCapability(value: any): boolean {\n    for (const key in ModelCapability) {\n        if (Object.prototype.hasOwnProperty.call(ModelCapability, key)) {\n            if (ModelCapability[key as keyof typeof ModelCapability] === value) {\n                return true;\n            }\n        }\n    }\n    return false;\n}\n\nexport function ModelCapabilityFromJSON(json: any): ModelCapability {\n    return ModelCapabilityFromJSONTyped(json, false);\n}\n\nexport function ModelCapabilityFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelCapability {\n    return json as ModelCapability;\n}\n\nexport function ModelCapabilityToJSON(value?: ModelCapability | null): any {\n    return value as any;\n}\n\nexport function ModelCapabilityToJSONTyped(value: any, ignoreDiscriminator: boolean): ModelCapability {\n    return value as ModelCapability;\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/OpenInRequest.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Open path in a local app.\n * @export\n * @interface OpenInRequest\n */\nexport interface OpenInRequest {\n    /**\n     * \n     * @type {string}\n     * @memberof OpenInRequest\n     */\n    app: OpenInRequestAppEnum;\n    /**\n     * \n     * @type {string}\n     * @memberof OpenInRequest\n     */\n    path: string;\n}\n\n\n/**\n * @export\n */\nexport const OpenInRequestAppEnum = {\n    Finder: 'finder',\n    Cursor: 'cursor',\n    Vscode: 'vscode',\n    Iterm: 'iterm',\n    Terminal: 'terminal',\n    Antigravity: 'antigravity'\n} as const;\nexport type OpenInRequestAppEnum = typeof OpenInRequestAppEnum[keyof typeof OpenInRequestAppEnum];\n\n\n/**\n * Check if a given object implements the OpenInRequest interface.\n */\nexport function instanceOfOpenInRequest(value: object): value is OpenInRequest {\n    if (!('app' in value) || value['app'] === undefined) return false;\n    if (!('path' in value) || value['path'] === undefined) return false;\n    return true;\n}\n\nexport function OpenInRequestFromJSON(json: any): OpenInRequest {\n    return OpenInRequestFromJSONTyped(json, false);\n}\n\nexport function OpenInRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): OpenInRequest {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'app': json['app'],\n        'path': json['path'],\n    };\n}\n\nexport function OpenInRequestToJSON(json: any): OpenInRequest {\n    return OpenInRequestToJSONTyped(json, false);\n}\n\nexport function OpenInRequestToJSONTyped(value?: OpenInRequest | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'app': value['app'],\n        'path': value['path'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/OpenInResponse.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Open path response.\n * @export\n * @interface OpenInResponse\n */\nexport interface OpenInResponse {\n    /**\n     * \n     * @type {boolean}\n     * @memberof OpenInResponse\n     */\n    ok: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof OpenInResponse\n     */\n    detail?: string | null;\n}\n\n/**\n * Check if a given object implements the OpenInResponse interface.\n */\nexport function instanceOfOpenInResponse(value: object): value is OpenInResponse {\n    if (!('ok' in value) || value['ok'] === undefined) return false;\n    return true;\n}\n\nexport function OpenInResponseFromJSON(json: any): OpenInResponse {\n    return OpenInResponseFromJSONTyped(json, false);\n}\n\nexport function OpenInResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): OpenInResponse {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'ok': json['ok'],\n        'detail': json['detail'] == null ? undefined : json['detail'],\n    };\n}\n\nexport function OpenInResponseToJSON(json: any): OpenInResponse {\n    return OpenInResponseToJSONTyped(json, false);\n}\n\nexport function OpenInResponseToJSONTyped(value?: OpenInResponse | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'ok': value['ok'],\n        'detail': value['detail'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/ProviderType.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\n/**\n * \n * @export\n */\nexport const ProviderType = {\n    Kimi: 'kimi',\n    OpenaiLegacy: 'openai_legacy',\n    OpenaiResponses: 'openai_responses',\n    Anthropic: 'anthropic',\n    GoogleGenai: 'google_genai',\n    Gemini: 'gemini',\n    Vertexai: 'vertexai',\n    Echo: '_echo',\n    ScriptedEcho: '_scripted_echo',\n    Chaos: '_chaos'\n} as const;\nexport type ProviderType = typeof ProviderType[keyof typeof ProviderType];\n\n\nexport function instanceOfProviderType(value: any): boolean {\n    for (const key in ProviderType) {\n        if (Object.prototype.hasOwnProperty.call(ProviderType, key)) {\n            if (ProviderType[key as keyof typeof ProviderType] === value) {\n                return true;\n            }\n        }\n    }\n    return false;\n}\n\nexport function ProviderTypeFromJSON(json: any): ProviderType {\n    return ProviderTypeFromJSONTyped(json, false);\n}\n\nexport function ProviderTypeFromJSONTyped(json: any, ignoreDiscriminator: boolean): ProviderType {\n    return json as ProviderType;\n}\n\nexport function ProviderTypeToJSON(value?: ProviderType | null): any {\n    return value as any;\n}\n\nexport function ProviderTypeToJSONTyped(value: any, ignoreDiscriminator: boolean): ProviderType {\n    return value as ProviderType;\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/Session.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { SessionStatus } from './SessionStatus';\nimport {\n    SessionStatusFromJSON,\n    SessionStatusFromJSONTyped,\n    SessionStatusToJSON,\n    SessionStatusToJSONTyped,\n} from './SessionStatus';\n\n/**\n * Web UI session metadata.\n * @export\n * @interface Session\n */\nexport interface Session {\n    /**\n     * Session unique ID\n     * @type {string}\n     * @memberof Session\n     */\n    sessionId: string;\n    /**\n     * Session title derived from kimi-cli history\n     * @type {string}\n     * @memberof Session\n     */\n    title: string;\n    /**\n     * Last updated timestamp\n     * @type {Date}\n     * @memberof Session\n     */\n    lastUpdated: Date;\n    /**\n     * Whether the session is running\n     * @type {boolean}\n     * @memberof Session\n     */\n    isRunning?: boolean;\n    /**\n     * \n     * @type {SessionStatus}\n     * @memberof Session\n     */\n    status?: SessionStatus | null;\n    /**\n     * \n     * @type {string}\n     * @memberof Session\n     */\n    workDir?: string | null;\n    /**\n     * \n     * @type {string}\n     * @memberof Session\n     */\n    sessionDir?: string | null;\n    /**\n     * Whether the session is archived\n     * @type {boolean}\n     * @memberof Session\n     */\n    archived?: boolean;\n}\n\n/**\n * Check if a given object implements the Session interface.\n */\nexport function instanceOfSession(value: object): value is Session {\n    if (!('sessionId' in value) || value['sessionId'] === undefined) return false;\n    if (!('title' in value) || value['title'] === undefined) return false;\n    if (!('lastUpdated' in value) || value['lastUpdated'] === undefined) return false;\n    return true;\n}\n\nexport function SessionFromJSON(json: any): Session {\n    return SessionFromJSONTyped(json, false);\n}\n\nexport function SessionFromJSONTyped(json: any, ignoreDiscriminator: boolean): Session {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'sessionId': json['session_id'],\n        'title': json['title'],\n        'lastUpdated': (new Date(json['last_updated'])),\n        'isRunning': json['is_running'] == null ? undefined : json['is_running'],\n        'status': json['status'] == null ? undefined : SessionStatusFromJSON(json['status']),\n        'workDir': json['work_dir'] == null ? undefined : json['work_dir'],\n        'sessionDir': json['session_dir'] == null ? undefined : json['session_dir'],\n        'archived': json['archived'] == null ? undefined : json['archived'],\n    };\n}\n\nexport function SessionToJSON(json: any): Session {\n    return SessionToJSONTyped(json, false);\n}\n\nexport function SessionToJSONTyped(value?: Session | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'session_id': value['sessionId'],\n        'title': value['title'],\n        'last_updated': value['lastUpdated'].toISOString(),\n        'is_running': value['isRunning'],\n        'status': SessionStatusToJSON(value['status']),\n        'work_dir': value['workDir'],\n        'session_dir': value['sessionDir'],\n        'archived': value['archived'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/SessionStatus.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Runtime status of a web session.\n * @export\n * @interface SessionStatus\n */\nexport interface SessionStatus {\n    /**\n     * Session unique ID\n     * @type {string}\n     * @memberof SessionStatus\n     */\n    sessionId: string;\n    /**\n     * Current session state\n     * @type {string}\n     * @memberof SessionStatus\n     */\n    state: SessionStatusStateEnum;\n    /**\n     * Monotonic sequence number\n     * @type {number}\n     * @memberof SessionStatus\n     */\n    seq: number;\n    /**\n     * \n     * @type {string}\n     * @memberof SessionStatus\n     */\n    workerId?: string | null;\n    /**\n     * \n     * @type {string}\n     * @memberof SessionStatus\n     */\n    reason?: string | null;\n    /**\n     * \n     * @type {string}\n     * @memberof SessionStatus\n     */\n    detail?: string | null;\n    /**\n     * Timestamp for this state\n     * @type {Date}\n     * @memberof SessionStatus\n     */\n    updatedAt: Date;\n}\n\n\n/**\n * @export\n */\nexport const SessionStatusStateEnum = {\n    Stopped: 'stopped',\n    Idle: 'idle',\n    Busy: 'busy',\n    Restarting: 'restarting',\n    Error: 'error'\n} as const;\nexport type SessionStatusStateEnum = typeof SessionStatusStateEnum[keyof typeof SessionStatusStateEnum];\n\n\n/**\n * Check if a given object implements the SessionStatus interface.\n */\nexport function instanceOfSessionStatus(value: object): value is SessionStatus {\n    if (!('sessionId' in value) || value['sessionId'] === undefined) return false;\n    if (!('state' in value) || value['state'] === undefined) return false;\n    if (!('seq' in value) || value['seq'] === undefined) return false;\n    if (!('updatedAt' in value) || value['updatedAt'] === undefined) return false;\n    return true;\n}\n\nexport function SessionStatusFromJSON(json: any): SessionStatus {\n    return SessionStatusFromJSONTyped(json, false);\n}\n\nexport function SessionStatusFromJSONTyped(json: any, ignoreDiscriminator: boolean): SessionStatus {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'sessionId': json['session_id'],\n        'state': json['state'],\n        'seq': json['seq'],\n        'workerId': json['worker_id'] == null ? undefined : json['worker_id'],\n        'reason': json['reason'] == null ? undefined : json['reason'],\n        'detail': json['detail'] == null ? undefined : json['detail'],\n        'updatedAt': (new Date(json['updated_at'])),\n    };\n}\n\nexport function SessionStatusToJSON(json: any): SessionStatus {\n    return SessionStatusToJSONTyped(json, false);\n}\n\nexport function SessionStatusToJSONTyped(value?: SessionStatus | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'session_id': value['sessionId'],\n        'state': value['state'],\n        'seq': value['seq'],\n        'worker_id': value['workerId'],\n        'reason': value['reason'],\n        'detail': value['detail'],\n        'updated_at': value['updatedAt'].toISOString(),\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/UpdateConfigTomlRequest.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Request to update config.toml.\n * @export\n * @interface UpdateConfigTomlRequest\n */\nexport interface UpdateConfigTomlRequest {\n    /**\n     * New TOML content\n     * @type {string}\n     * @memberof UpdateConfigTomlRequest\n     */\n    content: string;\n}\n\n/**\n * Check if a given object implements the UpdateConfigTomlRequest interface.\n */\nexport function instanceOfUpdateConfigTomlRequest(value: object): value is UpdateConfigTomlRequest {\n    if (!('content' in value) || value['content'] === undefined) return false;\n    return true;\n}\n\nexport function UpdateConfigTomlRequestFromJSON(json: any): UpdateConfigTomlRequest {\n    return UpdateConfigTomlRequestFromJSONTyped(json, false);\n}\n\nexport function UpdateConfigTomlRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): UpdateConfigTomlRequest {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'content': json['content'],\n    };\n}\n\nexport function UpdateConfigTomlRequestToJSON(json: any): UpdateConfigTomlRequest {\n    return UpdateConfigTomlRequestToJSONTyped(json, false);\n}\n\nexport function UpdateConfigTomlRequestToJSONTyped(value?: UpdateConfigTomlRequest | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'content': value['content'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/UpdateConfigTomlResponse.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Response after updating config.toml.\n * @export\n * @interface UpdateConfigTomlResponse\n */\nexport interface UpdateConfigTomlResponse {\n    /**\n     * Whether the update was successful\n     * @type {boolean}\n     * @memberof UpdateConfigTomlResponse\n     */\n    success: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof UpdateConfigTomlResponse\n     */\n    error?: string | null;\n}\n\n/**\n * Check if a given object implements the UpdateConfigTomlResponse interface.\n */\nexport function instanceOfUpdateConfigTomlResponse(value: object): value is UpdateConfigTomlResponse {\n    if (!('success' in value) || value['success'] === undefined) return false;\n    return true;\n}\n\nexport function UpdateConfigTomlResponseFromJSON(json: any): UpdateConfigTomlResponse {\n    return UpdateConfigTomlResponseFromJSONTyped(json, false);\n}\n\nexport function UpdateConfigTomlResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): UpdateConfigTomlResponse {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'success': json['success'],\n        'error': json['error'] == null ? undefined : json['error'],\n    };\n}\n\nexport function UpdateConfigTomlResponseToJSON(json: any): UpdateConfigTomlResponse {\n    return UpdateConfigTomlResponseToJSONTyped(json, false);\n}\n\nexport function UpdateConfigTomlResponseToJSONTyped(value?: UpdateConfigTomlResponse | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'success': value['success'],\n        'error': value['error'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/UpdateGlobalConfigRequest.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Request to update global config.\n * @export\n * @interface UpdateGlobalConfigRequest\n */\nexport interface UpdateGlobalConfigRequest {\n    /**\n     * \n     * @type {string}\n     * @memberof UpdateGlobalConfigRequest\n     */\n    defaultModel?: string | null;\n    /**\n     * \n     * @type {boolean}\n     * @memberof UpdateGlobalConfigRequest\n     */\n    defaultThinking?: boolean | null;\n    /**\n     * \n     * @type {boolean}\n     * @memberof UpdateGlobalConfigRequest\n     */\n    restartRunningSessions?: boolean | null;\n    /**\n     * \n     * @type {boolean}\n     * @memberof UpdateGlobalConfigRequest\n     */\n    forceRestartBusySessions?: boolean | null;\n}\n\n/**\n * Check if a given object implements the UpdateGlobalConfigRequest interface.\n */\nexport function instanceOfUpdateGlobalConfigRequest(value: object): value is UpdateGlobalConfigRequest {\n    return true;\n}\n\nexport function UpdateGlobalConfigRequestFromJSON(json: any): UpdateGlobalConfigRequest {\n    return UpdateGlobalConfigRequestFromJSONTyped(json, false);\n}\n\nexport function UpdateGlobalConfigRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): UpdateGlobalConfigRequest {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'defaultModel': json['default_model'] == null ? undefined : json['default_model'],\n        'defaultThinking': json['default_thinking'] == null ? undefined : json['default_thinking'],\n        'restartRunningSessions': json['restart_running_sessions'] == null ? undefined : json['restart_running_sessions'],\n        'forceRestartBusySessions': json['force_restart_busy_sessions'] == null ? undefined : json['force_restart_busy_sessions'],\n    };\n}\n\nexport function UpdateGlobalConfigRequestToJSON(json: any): UpdateGlobalConfigRequest {\n    return UpdateGlobalConfigRequestToJSONTyped(json, false);\n}\n\nexport function UpdateGlobalConfigRequestToJSONTyped(value?: UpdateGlobalConfigRequest | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'default_model': value['defaultModel'],\n        'default_thinking': value['defaultThinking'],\n        'restart_running_sessions': value['restartRunningSessions'],\n        'force_restart_busy_sessions': value['forceRestartBusySessions'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/UpdateGlobalConfigResponse.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { GlobalConfig } from './GlobalConfig';\nimport {\n    GlobalConfigFromJSON,\n    GlobalConfigFromJSONTyped,\n    GlobalConfigToJSON,\n    GlobalConfigToJSONTyped,\n} from './GlobalConfig';\n\n/**\n * Response after updating global config.\n * @export\n * @interface UpdateGlobalConfigResponse\n */\nexport interface UpdateGlobalConfigResponse {\n    /**\n     * Updated config snapshot\n     * @type {GlobalConfig}\n     * @memberof UpdateGlobalConfigResponse\n     */\n    config: GlobalConfig;\n    /**\n     * \n     * @type {Array<string>}\n     * @memberof UpdateGlobalConfigResponse\n     */\n    restartedSessionIds?: Array<string> | null;\n    /**\n     * \n     * @type {Array<string>}\n     * @memberof UpdateGlobalConfigResponse\n     */\n    skippedBusySessionIds?: Array<string> | null;\n}\n\n/**\n * Check if a given object implements the UpdateGlobalConfigResponse interface.\n */\nexport function instanceOfUpdateGlobalConfigResponse(value: object): value is UpdateGlobalConfigResponse {\n    if (!('config' in value) || value['config'] === undefined) return false;\n    return true;\n}\n\nexport function UpdateGlobalConfigResponseFromJSON(json: any): UpdateGlobalConfigResponse {\n    return UpdateGlobalConfigResponseFromJSONTyped(json, false);\n}\n\nexport function UpdateGlobalConfigResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): UpdateGlobalConfigResponse {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'config': GlobalConfigFromJSON(json['config']),\n        'restartedSessionIds': json['restarted_session_ids'] == null ? undefined : json['restarted_session_ids'],\n        'skippedBusySessionIds': json['skipped_busy_session_ids'] == null ? undefined : json['skipped_busy_session_ids'],\n    };\n}\n\nexport function UpdateGlobalConfigResponseToJSON(json: any): UpdateGlobalConfigResponse {\n    return UpdateGlobalConfigResponseToJSONTyped(json, false);\n}\n\nexport function UpdateGlobalConfigResponseToJSONTyped(value?: UpdateGlobalConfigResponse | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'config': GlobalConfigToJSON(value['config']),\n        'restarted_session_ids': value['restartedSessionIds'],\n        'skipped_busy_session_ids': value['skippedBusySessionIds'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/UpdateSessionRequest.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Update session request.\n * @export\n * @interface UpdateSessionRequest\n */\nexport interface UpdateSessionRequest {\n    /**\n     * \n     * @type {string}\n     * @memberof UpdateSessionRequest\n     */\n    title?: string | null;\n    /**\n     * \n     * @type {boolean}\n     * @memberof UpdateSessionRequest\n     */\n    archived?: boolean | null;\n}\n\n/**\n * Check if a given object implements the UpdateSessionRequest interface.\n */\nexport function instanceOfUpdateSessionRequest(value: object): value is UpdateSessionRequest {\n    return true;\n}\n\nexport function UpdateSessionRequestFromJSON(json: any): UpdateSessionRequest {\n    return UpdateSessionRequestFromJSONTyped(json, false);\n}\n\nexport function UpdateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): UpdateSessionRequest {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'title': json['title'] == null ? undefined : json['title'],\n        'archived': json['archived'] == null ? undefined : json['archived'],\n    };\n}\n\nexport function UpdateSessionRequestToJSON(json: any): UpdateSessionRequest {\n    return UpdateSessionRequestToJSONTyped(json, false);\n}\n\nexport function UpdateSessionRequestToJSONTyped(value?: UpdateSessionRequest | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'title': value['title'],\n        'archived': value['archived'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/UploadSessionFileResponse.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * Upload file response.\n * @export\n * @interface UploadSessionFileResponse\n */\nexport interface UploadSessionFileResponse {\n    /**\n     * \n     * @type {string}\n     * @memberof UploadSessionFileResponse\n     */\n    path: string;\n    /**\n     * \n     * @type {string}\n     * @memberof UploadSessionFileResponse\n     */\n    filename: string;\n    /**\n     * \n     * @type {number}\n     * @memberof UploadSessionFileResponse\n     */\n    size: number;\n}\n\n/**\n * Check if a given object implements the UploadSessionFileResponse interface.\n */\nexport function instanceOfUploadSessionFileResponse(value: object): value is UploadSessionFileResponse {\n    if (!('path' in value) || value['path'] === undefined) return false;\n    if (!('filename' in value) || value['filename'] === undefined) return false;\n    if (!('size' in value) || value['size'] === undefined) return false;\n    return true;\n}\n\nexport function UploadSessionFileResponseFromJSON(json: any): UploadSessionFileResponse {\n    return UploadSessionFileResponseFromJSONTyped(json, false);\n}\n\nexport function UploadSessionFileResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): UploadSessionFileResponse {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'path': json['path'],\n        'filename': json['filename'],\n        'size': json['size'],\n    };\n}\n\nexport function UploadSessionFileResponseToJSON(json: any): UploadSessionFileResponse {\n    return UploadSessionFileResponseToJSONTyped(json, false);\n}\n\nexport function UploadSessionFileResponseToJSONTyped(value?: UploadSessionFileResponse | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'path': value['path'],\n        'filename': value['filename'],\n        'size': value['size'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/ValidationError.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ValidationErrorLocInner } from './ValidationErrorLocInner';\nimport {\n    ValidationErrorLocInnerFromJSON,\n    ValidationErrorLocInnerFromJSONTyped,\n    ValidationErrorLocInnerToJSON,\n    ValidationErrorLocInnerToJSONTyped,\n} from './ValidationErrorLocInner';\n\n/**\n * \n * @export\n * @interface ValidationError\n */\nexport interface ValidationError {\n    /**\n     * \n     * @type {Array<ValidationErrorLocInner>}\n     * @memberof ValidationError\n     */\n    loc: Array<ValidationErrorLocInner>;\n    /**\n     * \n     * @type {string}\n     * @memberof ValidationError\n     */\n    msg: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ValidationError\n     */\n    type: string;\n}\n\n/**\n * Check if a given object implements the ValidationError interface.\n */\nexport function instanceOfValidationError(value: object): value is ValidationError {\n    if (!('loc' in value) || value['loc'] === undefined) return false;\n    if (!('msg' in value) || value['msg'] === undefined) return false;\n    if (!('type' in value) || value['type'] === undefined) return false;\n    return true;\n}\n\nexport function ValidationErrorFromJSON(json: any): ValidationError {\n    return ValidationErrorFromJSONTyped(json, false);\n}\n\nexport function ValidationErrorFromJSONTyped(json: any, ignoreDiscriminator: boolean): ValidationError {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'loc': ((json['loc'] as Array<any>).map(ValidationErrorLocInnerFromJSON)),\n        'msg': json['msg'],\n        'type': json['type'],\n    };\n}\n\nexport function ValidationErrorToJSON(json: any): ValidationError {\n    return ValidationErrorToJSONTyped(json, false);\n}\n\nexport function ValidationErrorToJSONTyped(value?: ValidationError | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'loc': ((value['loc'] as Array<any>).map(ValidationErrorLocInnerToJSON)),\n        'msg': value['msg'],\n        'type': value['type'],\n    };\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/ValidationErrorLocInner.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ValidationErrorLocInner\n */\nexport interface ValidationErrorLocInner {\n}\n\n/**\n * Check if a given object implements the ValidationErrorLocInner interface.\n */\nexport function instanceOfValidationErrorLocInner(value: object): value is ValidationErrorLocInner {\n    return true;\n}\n\nexport function ValidationErrorLocInnerFromJSON(json: any): ValidationErrorLocInner {\n    return ValidationErrorLocInnerFromJSONTyped(json, false);\n}\n\nexport function ValidationErrorLocInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): ValidationErrorLocInner {\n    return json;\n}\n\nexport function ValidationErrorLocInnerToJSON(json: any): ValidationErrorLocInner {\n    return ValidationErrorLocInnerToJSONTyped(json, false);\n}\n\nexport function ValidationErrorLocInnerToJSONTyped(value?: ValidationErrorLocInner | null, ignoreDiscriminator: boolean = false): any {\n    return value;\n}\n\n"
  },
  {
    "path": "web/src/lib/api/models/index.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport * from './ConfigModel';\nexport * from './ConfigToml';\nexport * from './CreateSessionRequest';\nexport * from './GenerateTitleRequest';\nexport * from './GenerateTitleResponse';\nexport * from './GitDiffStats';\nexport * from './GitFileDiff';\nexport * from './GlobalConfig';\nexport * from './HTTPValidationError';\nexport * from './ModelCapability';\nexport * from './OpenInRequest';\nexport * from './OpenInResponse';\nexport * from './ProviderType';\nexport * from './Session';\nexport * from './SessionStatus';\nexport * from './UpdateConfigTomlRequest';\nexport * from './UpdateConfigTomlResponse';\nexport * from './UpdateGlobalConfigRequest';\nexport * from './UpdateGlobalConfigResponse';\nexport * from './UpdateSessionRequest';\nexport * from './UploadSessionFileResponse';\nexport * from './ValidationError';\nexport * from './ValidationErrorLocInner';\n"
  },
  {
    "path": "web/src/lib/api/runtime.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Kimi Code CLI Web Interface\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 0.1.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nexport const BASE_PATH = \"http://localhost\".replace(/\\/+$/, \"\");\n\nexport interface ConfigurationParameters {\n    basePath?: string; // override base path\n    fetchApi?: FetchAPI; // override for fetch implementation\n    middleware?: Middleware[]; // middleware to apply before/after fetch requests\n    queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings\n    username?: string; // parameter for basic security\n    password?: string; // parameter for basic security\n    apiKey?: string | Promise<string> | ((name: string) => string | Promise<string>); // parameter for apiKey security\n    accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string | Promise<string>); // parameter for oauth2 security\n    headers?: HTTPHeaders; //header params we want to use on every request\n    credentials?: RequestCredentials; //value for the credentials param we want to use on each request\n}\n\nexport class Configuration {\n    constructor(private configuration: ConfigurationParameters = {}) {}\n\n    set config(configuration: Configuration) {\n        this.configuration = configuration;\n    }\n\n    get basePath(): string {\n        return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH;\n    }\n\n    get fetchApi(): FetchAPI | undefined {\n        return this.configuration.fetchApi;\n    }\n\n    get middleware(): Middleware[] {\n        return this.configuration.middleware || [];\n    }\n\n    get queryParamsStringify(): (params: HTTPQuery) => string {\n        return this.configuration.queryParamsStringify || querystring;\n    }\n\n    get username(): string | undefined {\n        return this.configuration.username;\n    }\n\n    get password(): string | undefined {\n        return this.configuration.password;\n    }\n\n    get apiKey(): ((name: string) => string | Promise<string>) | undefined {\n        const apiKey = this.configuration.apiKey;\n        if (apiKey) {\n            return typeof apiKey === 'function' ? apiKey : () => apiKey;\n        }\n        return undefined;\n    }\n\n    get accessToken(): ((name?: string, scopes?: string[]) => string | Promise<string>) | undefined {\n        const accessToken = this.configuration.accessToken;\n        if (accessToken) {\n            return typeof accessToken === 'function' ? accessToken : async () => accessToken;\n        }\n        return undefined;\n    }\n\n    get headers(): HTTPHeaders | undefined {\n        return this.configuration.headers;\n    }\n\n    get credentials(): RequestCredentials | undefined {\n        return this.configuration.credentials;\n    }\n}\n\nexport const DefaultConfig = new Configuration();\n\n/**\n * This is the base class for all generated API classes.\n */\nexport class BaseAPI {\n\n    private static readonly jsonRegex = new RegExp('^(:?application\\/json|[^;/ \\t]+\\/[^;/ \\t]+[+]json)[ \\t]*(:?;.*)?$', 'i');\n    private middleware: Middleware[];\n\n    constructor(protected configuration = DefaultConfig) {\n        this.middleware = configuration.middleware;\n    }\n\n    withMiddleware<T extends BaseAPI>(this: T, ...middlewares: Middleware[]) {\n        const next = this.clone<T>();\n        next.middleware = next.middleware.concat(...middlewares);\n        return next;\n    }\n\n    withPreMiddleware<T extends BaseAPI>(this: T, ...preMiddlewares: Array<Middleware['pre']>) {\n        const middlewares = preMiddlewares.map((pre) => ({ pre }));\n        return this.withMiddleware<T>(...middlewares);\n    }\n\n    withPostMiddleware<T extends BaseAPI>(this: T, ...postMiddlewares: Array<Middleware['post']>) {\n        const middlewares = postMiddlewares.map((post) => ({ post }));\n        return this.withMiddleware<T>(...middlewares);\n    }\n\n    /**\n     * Check if the given MIME is a JSON MIME.\n     * JSON MIME examples:\n     *   application/json\n     *   application/json; charset=UTF8\n     *   APPLICATION/JSON\n     *   application/vnd.company+json\n     * @param mime - MIME (Multipurpose Internet Mail Extensions)\n     * @return True if the given MIME is JSON, false otherwise.\n     */\n    protected isJsonMime(mime: string | null | undefined): boolean {\n        if (!mime) {\n            return false;\n        }\n        return BaseAPI.jsonRegex.test(mime);\n    }\n\n    protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise<Response> {\n        const { url, init } = await this.createFetchParams(context, initOverrides);\n        const response = await this.fetchApi(url, init);\n        if (response && (response.status >= 200 && response.status < 300)) {\n            return response;\n        }\n        throw new ResponseError(response, 'Response returned an error code');\n    }\n\n    private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) {\n        let url = this.configuration.basePath + context.path;\n        if (context.query !== undefined && Object.keys(context.query).length !== 0) {\n            // only add the querystring to the URL if there are query parameters.\n            // this is done to avoid urls ending with a \"?\" character which buggy webservers\n            // do not handle correctly sometimes.\n            url += '?' + this.configuration.queryParamsStringify(context.query);\n        }\n\n        const headers = Object.assign({}, this.configuration.headers, context.headers);\n        Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {});\n\n        const initOverrideFn =\n            typeof initOverrides === \"function\"\n                ? initOverrides\n                : async () => initOverrides;\n\n        const initParams = {\n            method: context.method,\n            headers,\n            body: context.body,\n            credentials: this.configuration.credentials,\n        };\n\n        const overriddenInit: RequestInit = {\n            ...initParams,\n            ...(await initOverrideFn({\n                init: initParams,\n                context,\n            }))\n        };\n\n        let body: any;\n        if (isFormData(overriddenInit.body)\n            || (overriddenInit.body instanceof URLSearchParams)\n            || isBlob(overriddenInit.body)) {\n          body = overriddenInit.body;\n        } else if (this.isJsonMime(headers['Content-Type'])) {\n          body = JSON.stringify(overriddenInit.body);\n        } else {\n          body = overriddenInit.body;\n        }\n\n        const init: RequestInit = {\n            ...overriddenInit,\n            body\n        };\n\n        return { url, init };\n    }\n\n    private fetchApi = async (url: string, init: RequestInit) => {\n        let fetchParams = { url, init };\n        for (const middleware of this.middleware) {\n            if (middleware.pre) {\n                fetchParams = await middleware.pre({\n                    fetch: this.fetchApi,\n                    ...fetchParams,\n                }) || fetchParams;\n            }\n        }\n        let response: Response | undefined = undefined;\n        try {\n            response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init);\n        } catch (e) {\n            for (const middleware of this.middleware) {\n                if (middleware.onError) {\n                    response = await middleware.onError({\n                        fetch: this.fetchApi,\n                        url: fetchParams.url,\n                        init: fetchParams.init,\n                        error: e,\n                        response: response ? response.clone() : undefined,\n                    }) || response;\n                }\n            }\n            if (response === undefined) {\n              if (e instanceof Error) {\n                throw new FetchError(e, 'The request failed and the interceptors did not return an alternative response');\n              } else {\n                throw e;\n              }\n            }\n        }\n        for (const middleware of this.middleware) {\n            if (middleware.post) {\n                response = await middleware.post({\n                    fetch: this.fetchApi,\n                    url: fetchParams.url,\n                    init: fetchParams.init,\n                    response: response.clone(),\n                }) || response;\n            }\n        }\n        return response;\n    }\n\n    /**\n     * Create a shallow clone of `this` by constructing a new instance\n     * and then shallow cloning data members.\n     */\n    private clone<T extends BaseAPI>(this: T): T {\n        const constructor = this.constructor as any;\n        const next = new constructor(this.configuration);\n        next.middleware = this.middleware.slice();\n        return next;\n    }\n};\n\nfunction isBlob(value: any): value is Blob {\n    return typeof Blob !== 'undefined' && value instanceof Blob;\n}\n\nfunction isFormData(value: any): value is FormData {\n    return typeof FormData !== \"undefined\" && value instanceof FormData;\n}\n\nexport class ResponseError extends Error {\n    override name: \"ResponseError\" = \"ResponseError\";\n    constructor(public response: Response, msg?: string) {\n        super(msg);\n    }\n}\n\nexport class FetchError extends Error {\n    override name: \"FetchError\" = \"FetchError\";\n    constructor(public cause: Error, msg?: string) {\n        super(msg);\n    }\n}\n\nexport class RequiredError extends Error {\n    override name: \"RequiredError\" = \"RequiredError\";\n    constructor(public field: string, msg?: string) {\n        super(msg);\n    }\n}\n\nexport const COLLECTION_FORMATS = {\n    csv: \",\",\n    ssv: \" \",\n    tsv: \"\\t\",\n    pipes: \"|\",\n};\n\nexport type FetchAPI = WindowOrWorkerGlobalScope['fetch'];\n\nexport type Json = any;\nexport type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD';\nexport type HTTPHeaders = { [key: string]: string };\nexport type HTTPQuery = { [key: string]: string | number | null | boolean | Array<string | number | null | boolean> | Set<string | number | null | boolean> | HTTPQuery };\nexport type HTTPBody = Json | FormData | URLSearchParams;\nexport type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody };\nexport type ModelPropertyNaming = 'camelCase' | 'snake_case' | 'PascalCase' | 'original';\n\nexport type InitOverrideFunction = (requestContext: { init: HTTPRequestInit, context: RequestOpts }) => Promise<RequestInit>\n\nexport interface FetchParams {\n    url: string;\n    init: RequestInit;\n}\n\nexport interface RequestOpts {\n    path: string;\n    method: HTTPMethod;\n    headers: HTTPHeaders;\n    query?: HTTPQuery;\n    body?: HTTPBody;\n}\n\nexport function querystring(params: HTTPQuery, prefix: string = ''): string {\n    return Object.keys(params)\n        .map(key => querystringSingleKey(key, params[key], prefix))\n        .filter(part => part.length > 0)\n        .join('&');\n}\n\nfunction querystringSingleKey(key: string, value: string | number | null | undefined | boolean | Array<string | number | null | boolean> | Set<string | number | null | boolean> | HTTPQuery, keyPrefix: string = ''): string {\n    const fullKey = keyPrefix + (keyPrefix.length ? `[${key}]` : key);\n    if (value instanceof Array) {\n        const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue)))\n            .join(`&${encodeURIComponent(fullKey)}=`);\n        return `${encodeURIComponent(fullKey)}=${multiValue}`;\n    }\n    if (value instanceof Set) {\n        const valueAsArray = Array.from(value);\n        return querystringSingleKey(key, valueAsArray, keyPrefix);\n    }\n    if (value instanceof Date) {\n        return `${encodeURIComponent(fullKey)}=${encodeURIComponent(value.toISOString())}`;\n    }\n    if (value instanceof Object) {\n        return querystring(value as HTTPQuery, fullKey);\n    }\n    return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`;\n}\n\nexport function exists(json: any, key: string) {\n    const value = json[key];\n    return value !== null && value !== undefined;\n}\n\nexport function mapValues(data: any, fn: (item: any) => any) {\n    const result: { [key: string]: any } = {};\n    for (const key of Object.keys(data)) {\n        result[key] = fn(data[key]);\n    }\n    return result;\n}\n\nexport function canConsumeForm(consumes: Consume[]): boolean {\n    for (const consume of consumes) {\n        if ('multipart/form-data' === consume.contentType) {\n            return true;\n        }\n    }\n    return false;\n}\n\nexport interface Consume {\n    contentType: string;\n}\n\nexport interface RequestContext {\n    fetch: FetchAPI;\n    url: string;\n    init: RequestInit;\n}\n\nexport interface ResponseContext {\n    fetch: FetchAPI;\n    url: string;\n    init: RequestInit;\n    response: Response;\n}\n\nexport interface ErrorContext {\n    fetch: FetchAPI;\n    url: string;\n    init: RequestInit;\n    error: unknown;\n    response?: Response;\n}\n\nexport interface Middleware {\n    pre?(context: RequestContext): Promise<FetchParams | void>;\n    post?(context: ResponseContext): Promise<Response | void>;\n    onError?(context: ErrorContext): Promise<Response | void>;\n}\n\nexport interface ApiResponse<T> {\n    raw: Response;\n    value(): Promise<T>;\n}\n\nexport interface ResponseTransformer<T> {\n    (json: any): T;\n}\n\nexport class JSONApiResponse<T> {\n    constructor(public raw: Response, private transformer: ResponseTransformer<T> = (jsonValue: any) => jsonValue) {}\n\n    async value(): Promise<T> {\n        return this.transformer(await this.raw.json());\n    }\n}\n\nexport class VoidApiResponse {\n    constructor(public raw: Response) {}\n\n    async value(): Promise<void> {\n        return undefined;\n    }\n}\n\nexport class BlobApiResponse {\n    constructor(public raw: Response) {}\n\n    async value(): Promise<Blob> {\n        return await this.raw.blob();\n    };\n}\n\nexport class TextApiResponse {\n    constructor(public raw: Response) {}\n\n    async value(): Promise<string> {\n        return await this.raw.text();\n    };\n}\n"
  },
  {
    "path": "web/src/lib/apiClient.ts",
    "content": "import {\n  Configuration,\n  ConfigApi,\n  SessionsApi,\n  type RequestContext,\n  type ResponseContext,\n} from \"./api\";\nimport { getApiBaseUrl } from \"../hooks/utils\";\nimport { getAuthHeader } from \"./auth\";\n\n/**\n * Format validation errors from FastAPI into a readable string.\n * FastAPI returns validation errors as:\n * { \"detail\": [{ \"loc\": [\"body\", \"llm\", \"model\"], \"msg\": \"Field required\", \"type\": \"missing\" }] }\n */\nfunction formatValidationError(detail: unknown): string {\n  if (Array.isArray(detail)) {\n    return detail\n      .map((err) => {\n        if (err && typeof err === \"object\" && \"msg\" in err) {\n          const loc = Array.isArray(err.loc) ? err.loc.slice(1).join(\".\") : \"\";\n          return loc ? `${loc}: ${err.msg}` : err.msg;\n        }\n        return String(err);\n      })\n      .join(\"; \");\n  }\n  if (typeof detail === \"string\") {\n    return detail;\n  }\n  return \"Validation failed\";\n}\n\n/**\n * Create API configuration with the current base URL.\n * Lazily evaluated to support runtime base URL changes.\n */\nfunction createConfig(): Configuration {\n  return new Configuration({\n    basePath: getApiBaseUrl(),\n    middleware: [\n      {\n        pre: async (context: RequestContext) => {\n          context.init.headers = {\n            ...context.init.headers,\n            ...getAuthHeader(),\n          };\n          return context;\n        },\n        post: async (context: ResponseContext) => {\n          if (!context.response.ok) {\n            const data = await context.response.json();\n            let message: string;\n\n            if (context.response.status === 422 && data.detail) {\n              // FastAPI validation error\n              message = formatValidationError(data.detail);\n            } else if (typeof data.detail === \"string\") {\n              message = data.detail;\n            } else if (typeof data.msg === \"string\") {\n              message = data.msg;\n            } else {\n              message = \"Request failed\";\n            }\n\n            switch (context.response.status) {\n              case 401:\n                console.error(\"Authentication failed. Please login again.\");\n                break;\n              case 403:\n                console.error(message);\n                break;\n              case 404:\n                console.error(\"The requested resource was not found.\");\n                break;\n              default:\n                console.error(message);\n            }\n\n            throw new Error(message);\n          }\n          return context.response;\n        },\n      },\n    ],\n  });\n}\n\n// Lazy-initialized API client that creates config on first access\nlet _apiClient: typeof apiClient | null = null;\n\nexport const apiClient = {\n  get config() {\n    return new ConfigApi(createConfig());\n  },\n  get sessions() {\n    return new SessionsApi(createConfig());\n  },\n};\n"
  },
  {
    "path": "web/src/lib/auth.ts",
    "content": "const AUTH_TOKEN_KEY = \"kimi_auth_token\";\nconst AUTH_TOKEN_TIMESTAMP_KEY = \"kimi_auth_token_ts\";\nconst AUTH_TOKEN_PARAM = \"token\";\nconst TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours\n\nexport function getAuthToken(): string | null {\n  const token = localStorage.getItem(AUTH_TOKEN_KEY);\n  if (!token) {\n    return null;\n  }\n\n  // Check if token has expired\n  const timestamp = localStorage.getItem(AUTH_TOKEN_TIMESTAMP_KEY);\n  if (timestamp) {\n    const storedAt = parseInt(timestamp, 10);\n    if (Number.isNaN(storedAt)) {\n      // Treat non-parsable timestamps as expired/corrupted\n      clearAuthToken();\n      return null;\n    }\n    const age = Date.now() - storedAt;\n    if (age > TOKEN_EXPIRY_MS) {\n      clearAuthToken();\n      return null;\n    }\n  }\n\n  return token;\n}\n\nexport function setAuthToken(token: string): void {\n  localStorage.setItem(AUTH_TOKEN_KEY, token);\n  localStorage.setItem(AUTH_TOKEN_TIMESTAMP_KEY, Date.now().toString());\n}\n\nexport function clearAuthToken(): void {\n  localStorage.removeItem(AUTH_TOKEN_KEY);\n  localStorage.removeItem(AUTH_TOKEN_TIMESTAMP_KEY);\n}\n\nexport function consumeAuthTokenFromUrl(): string | null {\n  const url = new URL(window.location.href);\n  const token = url.searchParams.get(AUTH_TOKEN_PARAM);\n  if (!token) {\n    return null;\n  }\n  url.searchParams.delete(AUTH_TOKEN_PARAM);\n  window.history.replaceState({}, \"\", url.toString());\n  return token;\n}\n\nexport function getAuthHeader(): Record<string, string> {\n  let token = getAuthToken();\n  // Fallback: try reading from URL if localStorage is empty\n  if (!token) {\n    const url = new URL(window.location.href);\n    token = url.searchParams.get(AUTH_TOKEN_PARAM);\n  }\n  if (!token) {\n    return {};\n  }\n  return { Authorization: `Bearer ${token}` };\n}\n"
  },
  {
    "path": "web/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/**\n * Normalize and shorten a title string.\n * - Replaces multiple whitespace with single space\n * - Trims leading/trailing whitespace\n * - Shortens to maxLength characters with ellipsis\n */\nexport function shortenTitle(\n  title: string | undefined | null,\n  maxLength = 50,\n): string {\n  if (!title) return \"\";\n\n  // Normalize: collapse whitespace and trim\n  const normalized = title.replace(/\\s+/g, \" \").trim();\n\n  if (normalized.length <= maxLength) {\n    return normalized;\n  }\n\n  // Shorten with ellipsis\n  return `${normalized.slice(0, maxLength - 1)}…`;\n}\n"
  },
  {
    "path": "web/src/lib/version.ts",
    "content": "declare const __KIMI_CLI_VERSION__: string | undefined;\n\nexport const kimiCliVersion =\n  typeof __KIMI_CLI_VERSION__ !== \"undefined\" && __KIMI_CLI_VERSION__\n    ? __KIMI_CLI_VERSION__\n    : \"dev\";\n"
  },
  {
    "path": "web/src/main.tsx",
    "content": "const bootstrap = async (): Promise<void> => {\n  if (import.meta.env.DEV) {\n    try {\n      const { scan } = await import(\"react-scan\");\n      scan({ enabled: true });\n    } catch {\n      // react-scan not available, skip\n    }\n  }\n\n  await import(\"./bootstrap\");\n};\n\nbootstrap().catch((error: unknown) => {\n  console.error(\"[main] bootstrap failed:\", error);\n});\n"
  },
  {
    "path": "web/src/react-scan.d.ts",
    "content": "declare module \"react-scan\" {\n  export function scan(options: { enabled: boolean }): void;\n}\n"
  },
  {
    "path": "web/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"vite/client\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"erasableSyntaxOnly\": false,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n\n    /* Tailwind */\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"@ai-elements\": [\"./src/components/ai-elements/index.ts\"],\n      \"@ai-elements/*\": [\"./src/components/ai-elements/*\"]\n    }\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"src/components/ai-elements\", \"src/lib/api\"]\n}\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"@ai-elements\": [\"./src/components/ai-elements/index.ts\"],\n      \"@ai-elements/*\": [\"./src/components/ai-elements/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/vite.config.ts",
    "content": "import fs from \"node:fs\";\nimport path from \"node:path\";\nimport tailwindcss from \"@tailwindcss/vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { visualizer } from \"rollup-plugin-visualizer\";\nimport { defineConfig } from \"vite\";\nimport { nodePolyfills } from \"vite-plugin-node-polyfills\";\n\nconst PYPROJECT_VERSION_REGEX = /^\\s*version\\s*=\\s*\"([^\"]+)\"/m;\n\nfunction readKimiCliVersion(): string {\n  const fallback = process.env.KIMI_CLI_VERSION ?? \"dev\";\n  const pyprojectPath = path.resolve(__dirname, \"../pyproject.toml\");\n\n  try {\n    const pyproject = fs.readFileSync(pyprojectPath, \"utf8\");\n    const match = pyproject.match(PYPROJECT_VERSION_REGEX);\n    if (match?.[1]) {\n      return match[1];\n    }\n  } catch (error) {\n    console.warn(\"[vite] Unable to read version\", pyprojectPath, error);\n  }\n\n  return fallback;\n}\n\nconst kimiCliVersion = readKimiCliVersion();\nconst shouldAnalyze = process.env.ANALYZE === \"true\";\n\n// https://vite.dev/config/\nexport default defineConfig({\n  // Use relative paths so assets work under any base path.\n  base: \"./\",\n  plugins: [\n    nodePolyfills({\n      include: [\"path\", \"url\"],\n    }),\n    react(),\n    tailwindcss(),\n    ...(shouldAnalyze\n      ? [\n          visualizer({\n            brotliSize: true,\n            filename: \"dist/bundle-report.html\",\n            gzipSize: true,\n            open: false,\n            template: \"treemap\",\n          }),\n        ]\n      : []),\n  ],\n  define: {\n    __KIMI_CLI_VERSION__: JSON.stringify(kimiCliVersion),\n  },\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n      \"@ai-elements\": path.resolve(__dirname, \"./src/components/ai-elements\"),\n    },\n  },\n  server: {\n    allowedHosts: true,\n    proxy: {\n      \"/api\": {\n        target: process.env.VITE_API_TARGET ?? \"http://127.0.0.1:5494\",\n        changeOrigin: true,\n        ws: true, // Enable WebSocket proxy\n      },\n    },\n  },\n});\n"
  }
]