from __future__ import annotations

import asyncio
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, cast

from rich.text import Text
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import DataTable, Footer, Header, RichLog

from aicd.env_probe import (
    BANNER_CRITICAL,
    BANNER_NEUTRAL,
    BANNER_RECOMMEND,
    startup_env_chat_banner_lines,
)
from aicd.runtime import RuntimeContext
from aicd.ui.slash_commands import (
    chat_slash_help_text,
    join_args_for_shell,
    parse_slash_line,
    slash_help_text,
    task_slash_help_text,
)
from aicd.agent.idle_pulse import is_main_idle_pulse
from aicd.ui.slash_suggester import ChatTextArea


def _try_write_system_clipboard(text: str) -> bool:
    """在不依赖 Textual 的情况下把纯文本写入 OS 剪贴板（TUI 内通常无法用鼠标拖选）。"""
    if sys.platform == "win32":
        clip = os.path.join(
            os.environ.get("SystemRoot", r"C:\Windows"), "System32", "clip.exe"
        )
        if not os.path.isfile(clip):
            return False
        try:
            cr = subprocess.run(
                [clip],
                input=text.encode("utf-16-le", errors="replace"),
                check=False,
                capture_output=True,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            )
            return cr.returncode == 0
        except OSError:
            return False
    if sys.platform == "darwin":
        try:
            cr = subprocess.run(
                ["pbcopy"],
                input=text.encode("utf-8", errors="replace"),
                check=False,
                capture_output=True,
            )
            return cr.returncode == 0
        except OSError:
            return False
    for argv in (["wl-copy"], ["xclip", "-selection", "clipboard"]):
        try:
            cr = subprocess.run(
                argv,
                input=text.encode("utf-8", errors="replace"),
                check=False,
                capture_output=True,
            )
            if cr.returncode == 0:
                return True
        except OSError:
            continue
    return False


class AicdTui(App[None]):
    """主界面；AI 对话进行中标题栏脉冲提示。"""

    TITLE = "Aicd"

    BINDINGS = [
        Binding(
            "ctrl+shift+c",
            "cancel_chat",
            "停止AI",
            tooltip="中断当前主对话轮次（等同 /chat stop、/task stop）；后台 task_ai/task_shell 不停",
            show=True,
            priority=True,
        ),
    ]

    CSS = """
    #left { width: 1fr; min-width: 40; }
    #right { width: 45; min-width: 36; }
    #chat { height: 1fr; }
    #msg {
        height: 8;
        min-height: 4;
        max-height: 18;
    }
    #tasklog { height: 1fr; }
    DataTable { height: 12; }
    Header#app-header.-ai-busy {
        background: $foreground 10%;
    }
    """

    def __init__(
        self,
        ctx: RuntimeContext,
        *,
        bootstrap_user_message: str | None = None,
    ) -> None:
        super().__init__()
        self.ctx = ctx
        self._bootstrap_user_message = (
            bootstrap_user_message.strip() if bootstrap_user_message else None
        )
        self._selected_task: str | None = None
        self._bus_task: asyncio.Task[None] | None = None
        self._shutdown_once = False
        self._ai_header_busy: bool = False
        self._ai_header_pulse: bool = False
        self._ai_activity_suffix: str = ""
        self._chat_q: asyncio.Queue[str] = asyncio.Queue()
        self._chat_pump_task: asyncio.Task[None] | None = None
        self._heartbeat_task: asyncio.Task[None] | None = None
        self._current_chat_turn: asyncio.Task[str] | None = None
        self._input_history: list[str] = []
        self._input_history_cap = 500
        # 与 _input_history_path 一致；用于跨重启恢复底部输入历史
        self._input_history_filename = "tui_input_history.jsonl"

    def compose(self) -> ComposeResult:
        yield Header(show_clock=True, id="app-header")
        with Horizontal():
            with Vertical(id="left"):
                yield RichLog(id="chat", wrap=True, highlight=True, markup=True)
                yield ChatTextArea(
                    placeholder=(
                        "Enter 发送；Shift+Enter 换行；/ 命令行可用 → 采纳幽灵补全"
                    ),
                    id="msg",
                    soft_wrap=True,
                    tab_behavior="indent",
                    show_line_numbers=False,
                )
            with Vertical(id="right"):
                yield DataTable(id="tasks", cursor_type="row", zebra_stripes=True)
                yield RichLog(id="tasklog", wrap=True, highlight=True, markup=True)
        yield Footer()

    async def on_mount(self) -> None:
        table = self.query_one("#tasks", DataTable)
        table.add_columns("id", "type", "status")
        table.cursor_type = "row"
        await self.refresh_task_table()
        self._bus_task = asyncio.create_task(self._bus_loop())
        self._chat_pump_task = asyncio.create_task(self._chat_pump_loop())
        self._heartbeat_task = asyncio.create_task(self._idle_heartbeat_loop())
        chat = self.query_one("#chat", RichLog)
        chat.write(
            Text(
                "Aicd TUI — 左侧聊天，右侧任务与日志。"
                "  后台任务未结束时仍可继续发消息：主对话会排队依次处理；可同时存在多个运行中的子任务。"
                "  /? 命令；/work 工作目录；/doc AI 输出；/chat 主对话；/task 后台任务；/history；/llm /agent；/exit。"
                "  输入 `/` 后可用右方向键采纳幽灵补全；聊天区 **Enter** 发送，**Shift+Enter** 换行。"
                "  Ctrl+Shift+C、/chat stop、/task stop 中断当前主对话轮次（清排队）；Ctrl+Q 退出。",
                style="bold",
            )
        )
        snap = getattr(self.ctx, "runtime_env_snapshot", None) or {}
        has_key = bool(str(self.ctx.config.llm.api_key or "").strip())
        try:
            _banner_styles = {
                BANNER_CRITICAL: "bold red",
                BANNER_RECOMMEND: "green",
                BANNER_NEUTRAL: "cyan",
            }
            for line, kind in startup_env_chat_banner_lines(
                snap, has_llm_api_key=has_key
            ):
                chat.write(Text(line, style=_banner_styles.get(kind, "cyan")))
        except Exception as e:  # noqa: BLE001
            chat.write(
                Text(
                    f"[环境检查生成失败: {e}] 仍可使用；详情见 data/runtime_env.yaml",
                    style="yellow",
                )
            )
        self.set_interval(0.45, self._tick_ai_header_pulse)
        self._load_input_history_from_disk()
        if self._bootstrap_user_message:
            asyncio.create_task(self._run_bootstrap_user_message())

    async def _run_bootstrap_user_message(self) -> None:
        """启动后自动投递首条用户消息（与手动输入同路径，仍可继续交互）。"""
        await asyncio.sleep(0)
        text = self._bootstrap_user_message
        self._bootstrap_user_message = None
        if not text:
            return
        self._push_input_history(text)
        self.query_one("#chat", RichLog).write(Text(f"[你] {text}", style="green"))
        await self._chat_q.put(text)
        turn_busy = (
            self._current_chat_turn is not None
            and not self._current_chat_turn.done()
        )
        if turn_busy:
            nq = self._chat_q.qsize()
            suffix = f"（队列中约 {nq} 条）" if nq > 0 else ""
            self.query_one("#chat", RichLog).write(
                Text(
                    f"[系统] 主 AI 仍处理上一条；本条已排队，将依次执行{suffix}",
                    style="dim cyan",
                )
            )

    def _apply_ai_header_subtitle(self) -> None:
        """标题栏：脉冲圆点 + 当前阶段（等待模型 / 执行哪个工具），避免「像卡死无输出」。"""
        if not self._ai_header_busy:
            return
        dot = "●" if self._ai_header_pulse else "○"
        core = (self._ai_activity_suffix or "AI 处理中…").strip().replace("\n", " ")
        if len(core) > 96:
            core = core[:93] + "…"
        self.sub_title = f"{dot} {core}"

    def _tick_ai_header_pulse(self) -> None:
        if not self._ai_header_busy:
            return
        self._ai_header_pulse = not self._ai_header_pulse
        self._apply_ai_header_subtitle()

    def _set_ai_header_busy(self, busy: bool) -> None:
        self._ai_header_busy = busy
        if not busy:
            self._ai_header_pulse = False
            self._ai_activity_suffix = ""
            self.sub_title = ""
        else:
            self._ai_header_pulse = True
            if not (self._ai_activity_suffix or "").strip():
                self._ai_activity_suffix = "AI 处理中…"
            self._apply_ai_header_subtitle()
        try:
            hdr = self.query_one("#app-header", Header)
            hdr.set_class(busy, "-ai-busy")
        except Exception:
            pass

    def _drain_main_chat_queue(self) -> int:
        n = 0
        while True:
            try:
                self._chat_q.get_nowait()
                n += 1
            except asyncio.QueueEmpty:
                break
        return n

    def _interrupt_current_main_chat_turn(self, log: RichLog, tag: str) -> None:
        ct = self._current_chat_turn
        if ct is not None and not ct.done():
            ct.cancel()
            log.write(Text(f"{tag} 已请求中断当前主 AI 轮次…", style="yellow"))

    async def _main_chat_stop(
        self, log: RichLog, *, tag: str, session_event: str
    ) -> None:
        """中断当前主对话 ``agent.chat`` 协程并清空排队中的用户消息；不碰任务库与后台 task。"""
        ct0 = self._current_chat_turn
        had_active = ct0 is not None and not ct0.done()
        self._interrupt_current_main_chat_turn(log, tag)
        dropped = self._drain_main_chat_queue()
        if dropped:
            log.write(
                Text(
                    f"{tag} 已丢弃排队中的 {dropped} 条主对话请求",
                    style="cyan",
                )
            )
        if had_active or dropped:
            log.write(
                Text(
                    f"{tag} 后台 task_ai/task_shell 未停止；需要时用 /task list、task_cancel 或 /task clear。",
                    style="dim cyan",
                )
            )
        else:
            log.write(
                Text(
                    f"{tag} 当前无进行中的主 AI 轮次，队列为空。",
                    style="dim cyan",
                )
            )
        try:
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                session_event,
                "",
                f"dropped={dropped} had_active_turn={had_active}",
            )
        except Exception:
            pass

    def action_cancel_chat(self) -> None:
        t = self._current_chat_turn
        if t is not None and not t.done():
            t.cancel()
            self.query_one("#chat", RichLog).write(
                Text("[已发送中断，正在停止…]", style="yellow")
            )

    async def on_unmount(self) -> None:
        await self._shutdown_safe()

    async def _shutdown_safe(self) -> None:
        """安全退出：停聊天 Worker、取消后台任务、关库、停事件总线、关日志（幂等）。"""
        if self._shutdown_once:
            return
        self._shutdown_once = True
        try:
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "shutdown_begin",
                "",
                "",
            )
        except Exception:
            pass
        try:
            ct = self._current_chat_turn
            if ct is not None and not ct.done():
                ct.cancel()
        except Exception:
            pass
        try:
            pt = self._chat_pump_task
            if pt is not None and not pt.done():
                pt.cancel()
                try:
                    await asyncio.wait_for(pt, timeout=10.0)
                except asyncio.TimeoutError:
                    try:
                        self.ctx.session_log.write_event(
                            "tui",
                            "AicdTui",
                            "chat_pump_join_timeout",
                            "",
                            "10s",
                        )
                    except Exception:
                        pass
                except asyncio.CancelledError:
                    pass
        except Exception:
            pass
        for _ in range(15):
            await asyncio.sleep(0.04)
        try:
            await self.ctx.shutdown_graceful()
        except Exception:
            pass
        if self._heartbeat_task:
            self._heartbeat_task.cancel()
            try:
                await self._heartbeat_task
            except asyncio.CancelledError:
                pass
            self._heartbeat_task = None
        if self._bus_task:
            self._bus_task.cancel()
            try:
                await self._bus_task
            except asyncio.CancelledError:
                pass
            self._bus_task = None
        try:
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "shutdown_end",
                "",
                "ok",
            )
        except Exception:
            pass
        self.ctx.session_log.close()

    async def _bus_loop(self) -> None:
        q = self.ctx.bus.subscribe()
        try:
            while True:
                ev: dict[str, Any] = await q.get()
                await self._handle_bus_event(ev)
        except asyncio.CancelledError:
            self.ctx.bus.unsubscribe(q)
            raise

    async def _handle_bus_event(self, ev: dict[str, Any]) -> None:
        topic = ev.get("topic")
        if topic == "task":
            tid = ev.get("task_id")
            event = ev.get("event")
            if event == "history_cleared":
                self._selected_task = None
                tlog = self.query_one("#tasklog", RichLog)
                tlog.clear()
                tlog.write(Text("[任务历史已清空]", style="cyan"))
                await self.refresh_task_table()
                return
            if event == "log" and tid == self._selected_task:
                line = ev.get("line", "")
                self.query_one("#tasklog", RichLog).write(Text(line))
            if event == "status":
                await self.refresh_task_table()
                if tid == self._selected_task:
                    st = ev.get("status", "")
                    self.query_one("#tasklog", RichLog).write(
                        Text(f"[status] {tid} -> {st}", style="cyan")
                    )
            if event == "progress" and tid == self._selected_task:
                msg = ev.get("message")
                line = str(msg).strip() if msg is not None else ""
                if not line:
                    phase = ev.get("phase", "")
                    if phase == "sub_agent":
                        lr = ev.get("llm_round")
                        mr = ev.get("max_llm_rounds")
                        lt = ev.get("last_tool", "")
                        line = f"[进度] LLM {lr}/{mr}" + (
                            f" 末次工具 {lt}" if lt else ""
                        )
                    elif ev.get("percent") is not None:
                        line = f"[进度] {ev.get('percent')}% {ev.get('message', '')}"
                    else:
                        line = json.dumps(
                            {k: v for k, v in ev.items() if k not in ("topic", "event")},
                            ensure_ascii=False,
                        )[:500]
                self.query_one("#tasklog", RichLog).write(Text(line, style="cyan"))
        elif topic == "agent":
            if ev.get("event") == "phase":
                ph = str(ev.get("phase") or "")
                if ph == "llm":
                    self._ai_activity_suffix = str(ev.get("detail") or "等待大模型…")
                elif ph == "tool":
                    nm = str(ev.get("name") or "")
                    self._ai_activity_suffix = str(
                        ev.get("detail") or (f"工具 {nm}…" if nm else "执行工具…")
                    )
                else:
                    self._ai_activity_suffix = str(ev.get("detail") or "处理中…")
                if self._ai_header_busy:
                    self._apply_ai_header_subtitle()
                return
            if ev.get("event") == "tool":
                name = ev.get("name", "")
                prev = ev.get("preview", "")
                self.query_one("#chat", RichLog).write(
                    Text(f"[tool {name}] {prev}", style="dim")
                )
            elif ev.get("event") == "log":
                self.query_one("#chat", RichLog).write(Text(ev.get("line", "")))

    async def refresh_task_table(self) -> None:
        table = self.query_one("#tasks", DataTable)
        table.clear(columns=True)
        table.add_columns("id", "type", "status")
        rows = await self.ctx.tasks.list_tasks(80)
        active_states = frozenset({"running", "pending"})

        def _ts(r: Any) -> str:
            return getattr(r, "updated_at", None) or ""

        running = [r for r in rows if (getattr(r, "status", "") or "").lower() in active_states]
        done = [r for r in rows if (getattr(r, "status", "") or "").lower() not in active_states]
        running.sort(key=_ts, reverse=True)
        done.sort(key=_ts, reverse=True)
        # 运行中/排队置顶，便于发现新起的子任务；已完成仍保留（SQLite 持久化，除非 /task clear）
        for r in running + done:
            short_id = r.id[:8] + "…" if len(r.id) > 10 else r.id
            table.add_row(short_id, r.type, r.status, key=r.id)

    async def on_data_table_row_highlighted(
        self, event: DataTable.RowHighlighted
    ) -> None:
        await self._show_task_detail(event.row_key)

    async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
        await self._show_task_detail(event.row_key)

    async def _show_task_detail(self, row_key: Any) -> None:
        val = getattr(row_key, "value", None)
        if val is None:
            return
        tid = cast(str, val)
        self._selected_task = tid
        log = self.query_one("#tasklog", RichLog)
        log.clear()
        log.write(Text(f"选中任务: {tid}", style="bold"))
        row = await self.ctx.tasks.get_task_row(tid)
        if row:
            log.write(Text(f"type={row.type} status={row.status}", style="dim"))
            log.write(
                Text(
                    f"queued={row.created_at}  updated={row.updated_at}",
                    style="dim",
                )
            )
            pl = row.payload or {}
            dt = pl.get("display_title")
            if isinstance(dt, str) and dt.strip():
                log.write(Text(f"标题: {dt.strip()[:500]}", style="bold"))
            pu = pl.get("purpose")
            if isinstance(pu, str) and pu.strip():
                log.write(Text(f"目的: {pu.strip()[:1200]}", style="dim"))
            for key, label in (
                ("started_at", "开始(UTC)"),
                ("finished_at", "结束(UTC)"),
                ("duration_sec", "耗时秒"),
            ):
                v = pl.get(key)
                if v is not None and str(v).strip():
                    log.write(Text(f"{label}: {v}", style="dim"))
            if row.status == "running":
                sa = pl.get("started_at")
                if isinstance(sa, str) and sa.strip():
                    try:
                        t0 = datetime.fromisoformat(sa.replace("Z", "+00:00"))
                        if t0.tzinfo is None:
                            t0 = t0.replace(tzinfo=timezone.utc)
                        elapsed = int(
                            (datetime.now(timezone.utc) - t0).total_seconds()
                        )
                        log.write(
                            Text(f"已运行约 {elapsed}s（进行中）", style="yellow")
                        )
                    except ValueError:
                        pass
            est = pl.get("estimated_steps")
            if est is not None:
                log.write(Text(f"预计步骤数: {est}", style="dim"))
            if row.type == "shell":
                cmd = pl.get("command")
                if isinstance(cmd, str) and cmd.strip():
                    log.write(Text(f"命令: {cmd.strip()[:2000]}", style="dim"))
                sp = pl.get("shell_process")
                if isinstance(sp, dict) and sp:
                    log.write(
                        Text(
                            "Shell 进程: "
                            + json.dumps(sp, ensure_ascii=False)[:1200],
                            style="cyan",
                        )
                    )
            prog = pl.get("progress")
            if isinstance(prog, dict) and prog:
                log.write(
                    Text(
                        "进度: "
                        + json.dumps(prog, ensure_ascii=False)[:1600],
                        style="cyan",
                    )
                )
            trace = pl.get("sub_agent_trace")
            if isinstance(trace, dict) and trace:
                log.write(
                    Text(
                        "子Agent轨迹: "
                        + json.dumps(trace, ensure_ascii=False)[:1600],
                        style="cyan",
                    )
                )
            if row.result_summary:
                log.write(Text(str(row.result_summary)[:2000]))
            if row.error:
                log.write(Text(str(row.error), style="red"))

    def _input_history_path(self) -> Path:
        return Path(self.ctx.layout["data"]) / self._input_history_filename

    def _load_input_history_from_disk(self) -> None:
        path = self._input_history_path()
        if not path.is_file():
            return
        cap = self._input_history_cap
        loaded: list[str] = []
        try:
            with path.open("r", encoding="utf-8") as f:
                for raw in f:
                    line = raw.strip()
                    if not line:
                        continue
                    try:
                        loaded.append(json.loads(line))
                    except json.JSONDecodeError:
                        continue
        except OSError:
            return
        if len(loaded) > cap:
            loaded = loaded[-cap:]
        self._input_history = loaded

    def _persist_input_history(self) -> None:
        path = self._input_history_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".jsonl.tmp")
        try:
            with tmp.open("w", encoding="utf-8", newline="\n") as f:
                for item in self._input_history:
                    f.write(json.dumps(item, ensure_ascii=False) + "\n")
            tmp.replace(path)
        except OSError:
            try:
                if tmp.is_file():
                    tmp.unlink(missing_ok=True)
            except OSError:
                pass

    def _push_input_history(self, text: str) -> None:
        cap = self._input_history_cap
        self._input_history.append(text)
        if len(self._input_history) > cap:
            del self._input_history[: len(self._input_history) - cap]
        self._persist_input_history()

    def _input_history_prompt_only(self) -> list[str]:
        """仅发给主 AI 的提示词（不以 / 开头的输入）；/ 命令仍写入磁盘但不参与 list/N 编号。"""
        return [
            x
            for x in self._input_history
            if x and not x.lstrip().startswith("/")
        ]

    def _copy_plain_for_user(self, text: str) -> str:
        """同步到系统剪贴板并返回一行状态说明（RichLog 在备用屏中无法用鼠标拖选）。"""
        try:
            self.copy_to_clipboard(text)
        except Exception:
            pass
        if _try_write_system_clipboard(text):
            return "已写入系统剪贴板，可在其他窗口 Ctrl+V（macOS：Cmd+V）粘贴。"
        return (
            "未能调用系统剪贴板；已尝试终端 OSC52。"
            "可换 Windows Terminal，或使用 /history copy <N> 仅再试剪贴板。"
        )

    @staticmethod
    def _history_slash_help() -> str:
        return (
            "[/history] 底部输入历史：磁盘仍保存全部行；"
            "**list / N 仅针对发给主 AI 的提示词**（不以 / 开头的输入），本地 / 命令不列入编号。\n"
            "说明：左侧为 Textual 绘制区，底部为多行输入（Enter 发送、Shift+Enter 换行）；**一般不能像普通终端那样用鼠标拖选复制**；"
            "下面命令会尽量把全文写入**系统剪贴板**。\n"
            "最近条目持久化在 HOME/data/tui_input_history.jsonl，跨重启保留。\n"
            "  /history list          提示词编号清单（1=最旧，数字越大越新）\n"
            "  /history clear         清空内存与上述文件中的**全部**记录（含 / 命令行）\n"
            "  /history <N>           打印第 N 条提示词全文，并尝试写入系统剪贴板\n"
            "  /history copy <N>      仅写入剪贴板（不刷屏；N 与 list 一致）\n"
            "  /history <N> copy      同上\n"
            "  /history help          本说明"
        )

    @on(ChatTextArea.Submitted)
    async def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
        if event.chat.id != "msg":
            return
        text = event.value.strip()
        if not text:
            return
        self._push_input_history(text)
        self.query_one("#chat", RichLog).write(Text(f"[你] {text}", style="green"))
        parsed = parse_slash_line(text)
        if parsed is not None:
            await self._run_slash_command(parsed[0], parsed[1])
            return
        await self._chat_q.put(text)
        turn_busy = (
            self._current_chat_turn is not None
            and not self._current_chat_turn.done()
        )
        if turn_busy:
            nq = self._chat_q.qsize()
            suffix = f"（队列中约 {nq} 条）" if nq > 0 else ""
            self.query_one("#chat", RichLog).write(
                Text(
                    f"[系统] 主 AI 仍处理上一条；本条已排队，将依次执行{suffix}",
                    style="dim cyan",
                )
            )

    async def _idle_heartbeat_loop(self) -> None:
        """主对话空闲时按配置注入定时检查（与真人消息共用队列，有输入时让路）。"""
        try:
            while not self._shutdown_once:
                sec = int(self.ctx.config.agent.idle_heartbeat_sec or 0)
                if sec <= 0:
                    await asyncio.sleep(12)
                    continue
                wait = max(5, min(3600, sec))
                await asyncio.sleep(wait)
                if self._shutdown_once:
                    break
                ct = self._current_chat_turn
                if ct is not None and not ct.done():
                    continue
                if self._chat_q.qsize() > 0:
                    continue
                from aicd.agent.idle_pulse import main_idle_pulse_user_text

                await self._chat_q.put(main_idle_pulse_user_text())
        except asyncio.CancelledError:
            raise

    async def _chat_pump_loop(self) -> None:
        """顺序处理用户消息：新输入排队，不再取消上一轮（避免误显「助手回复已中断」）。"""
        try:
            while True:
                text = await self._chat_q.get()
                if is_main_idle_pulse(text):
                    self.query_one("#chat", RichLog).write(
                        Text(
                            "[系统] 主对话定时心跳：检查后台子任务…",
                            style="dim cyan",
                        )
                    )
                self._set_ai_header_busy(True)
                turn = asyncio.create_task(self.ctx.agent.chat(text))
                self._current_chat_turn = turn
                try:
                    reply = await turn
                except asyncio.CancelledError:
                    # /exit 会先置 _shutdown_once 再 cancel turn；勿误报为「中断」
                    if not self._shutdown_once:
                        self.query_one("#chat", RichLog).write(
                            Text("[助手回复已中断]", style="yellow")
                        )
                except Exception as e:  # noqa: BLE001
                    self.ctx.session_log.write_exception(
                        "tui",
                        "AicdTui",
                        "chat_turn",
                        e,
                    )
                    err = f"错误: {e}"
                    self.query_one("#chat", RichLog).write(
                        Text(f"[助手] {err}", style="bold")
                    )
                else:
                    self.query_one("#chat", RichLog).write(
                        Text(f"[助手] {reply}", style="bold")
                    )
                finally:
                    self._current_chat_turn = None
                    self._set_ai_header_busy(False)
        except asyncio.CancelledError:
            raise

    async def _run_slash_command(self, cmd: str, args: list[str]) -> None:
        log = self.query_one("#chat", RichLog)
        if cmd == "help":
            log.write(Text(slash_help_text(), style="cyan"))
            return
        if cmd == "chat":
            await self._slash_chat(args)
            return
        if cmd == "history":
            if not args or (
                len(args) == 1 and str(args[0]).strip().lower() in ("help", "h", "?")
            ):
                log.write(Text(self._history_slash_help(), style="cyan"))
                return
            if len(args) >= 2:
                a0 = str(args[0]).strip().lower()
                a1 = str(args[1]).strip()
                n_copy: int | None = None
                if a0 == "copy" and a1.isdigit():
                    n_copy = int(a1)
                elif str(args[0]).strip().isdigit() and a1.lower() == "copy":
                    n_copy = int(str(args[0]).strip())
                if n_copy is not None:
                    prompts = self._input_history_prompt_only()
                    if n_copy < 1 or n_copy > len(prompts):
                        mx = len(prompts)
                        log.write(
                            Text(
                                f"[/history] 无效序号 {n_copy}（当前提示词共 {mx} 条，有效 1–{mx}）"
                                if mx
                                else "[/history] 暂无提示词记录（以 / 开头的本地命令不列入编号）",
                                style="yellow",
                            )
                        )
                        return
                    chosen = prompts[n_copy - 1]
                    clip_msg = self._copy_plain_for_user(chosen)
                    log.write(
                        Text(
                            f"[/history] 第 {n_copy} 条提示词（约 {len(chosen)} 字符）。{clip_msg}",
                            style="cyan",
                        )
                    )
                    return
            sub = str(args[0]).strip()
            low = sub.lower()
            if low == "list":
                prompts = self._input_history_prompt_only()
                if not prompts:
                    log.write(
                        Text(
                            "[/history] 暂无提示词记录（以 / 开头的本地命令不列入清单）",
                            style="yellow",
                        )
                    )
                    return
                lines: list[str] = [
                    "[/history] 提示词清单（与 /history N 的 N 一致；不含 / 命令）：",
                    "",
                ]
                for i, line in enumerate(prompts, start=1):
                    preview = line if len(line) <= 140 else line[:137] + "…"
                    lines.append(f"  {i}.  {preview}")
                log.write(Text("\n".join(lines), style="cyan"))
                return
            if low == "clear":
                n = len(self._input_history)
                self._input_history.clear()
                self._persist_input_history()
                log.write(
                    Text(
                        f"[/history] 已清空输入历史（共删除 {n} 条，含 / 命令行）",
                        style="cyan",
                    )
                )
                return
            if sub.isdigit():
                n = int(sub)
                prompts = self._input_history_prompt_only()
                if n < 1 or n > len(prompts):
                    mx = len(prompts)
                    log.write(
                        Text(
                            f"[/history] 无效序号 {n}（当前提示词共 {mx} 条，有效 1–{mx}）"
                            if mx
                            else "[/history] 暂无提示词记录（以 / 开头的本地命令不列入编号）",
                            style="yellow",
                        )
                    )
                    return
                chosen = prompts[n - 1]
                clip_msg = self._copy_plain_for_user(chosen)
                log.write(
                    Text(
                        f"[/history] 第 {n} 条提示词（全文如下）。{clip_msg}",
                        style="cyan",
                    )
                )
                log.write(Text(chosen, style="white"))
                log.write(Text("— 全文结束 —", style="dim cyan"))
                return
            log.write(
                Text(
                    f"[/history] 未知参数 {sub!r}，使用 /history list | clear | copy <N> | help",
                    style="yellow",
                )
            )
            return
        if cmd == "work":
            path_arg = " ".join(args).strip()
            ok, msg = self.ctx.set_ai_workspace(path_arg or None)
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/work",
                path_arg or "(query)",
                f"ok={ok} {msg[:2000]}",
            )
            if ok:
                log.write(Text(f"[/work] AI 工作目录: {msg}", style="cyan"))
            else:
                log.write(Text(f"[/work] 失败: {msg}", style="red"))
            return
        if cmd == "doc":
            path_arg = " ".join(args).strip()
            ok, msg = self.ctx.set_ai_output_dir(path_arg or None)
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/doc",
                path_arg or "(query)",
                f"ok={ok} {msg[:2000]}",
            )
            if ok:
                log.write(Text(f"[/doc] {msg}", style="cyan"))
            else:
                log.write(Text(f"[/doc] 失败: {msg}", style="red"))
            return
        if cmd == "llm":
            ok, msg = self.ctx.apply_slash_llm(args)
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/llm",
                repr(args)[:500],
                f"ok={ok}",
            )
            log.write(
                Text(
                    msg if ok else f"失败: {msg}",
                    style="cyan" if ok else "red",
                )
            )
            return
        if cmd == "agent":
            ok, msg = self.ctx.apply_slash_agent(args)
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/agent",
                repr(args)[:500],
                f"ok={ok}",
            )
            log.write(
                Text(
                    msg if ok else f"失败: {msg}",
                    style="cyan" if ok else "red",
                )
            )
            return
        if cmd == "task":
            await self._slash_task(args)
            return
        if cmd in ("exit", "quit", "q"):
            log.write(
                Text(
                    "[/exit] 正在安全退出：停止聊天、取消后台任务、关闭数据库…",
                    style="cyan",
                )
            )
            try:
                self.ctx.session_log.write_event(
                    "tui",
                    "AicdTui",
                    f"/{cmd}",
                    "",
                    "safe_exit_begin",
                )
            except Exception:
                pass
            await self._shutdown_safe()
            try:
                self.query_one("#chat", RichLog).write(
                    Text("[/exit] 已停止后台任务并关闭数据库，再见。", style="dim cyan")
                )
            except Exception:
                pass
            self.exit()
            return
        log.write(Text(f"未知命令 /{cmd}，输入 /? 查看列表", style="yellow"))

    async def _slash_chat(self, args: list[str]) -> None:
        log = self.query_one("#chat", RichLog)
        if not args or str(args[0]).strip().lower() in ("help", "?", "h"):
            log.write(Text(chat_slash_help_text(), style="cyan"))
            return
        sub = str(args[0]).strip().lower()
        if sub == "stop":
            await self._main_chat_stop(
                log, tag="[/chat stop]", session_event="/chat stop"
            )
            return
        log.write(
            Text(
                f"未知 /chat 子命令 {sub!r}，使用 /chat help（仅支持 stop）",
                style="yellow",
            )
        )

    async def _slash_task(self, args: list[str]) -> None:
        log = self.query_one("#chat", RichLog)
        if not args or args[0].lower() in ("help", "?", "h"):
            log.write(Text(task_slash_help_text(), style="cyan"))
            return
        sub = args[0].lower()
        if sub == "stop":
            await self._main_chat_stop(
                log, tag="[/task stop]", session_event="/task stop"
            )
            return
        if sub == "list":
            await self.refresh_task_table()
            rows = await self.ctx.tasks.list_tasks(80)
            log.write(
                Text(
                    f"[/task list] 已刷新任务表（约 {len(rows)} 条，按更新时间排序）。",
                    style="cyan",
                )
            )
            return
        if sub == "clear":
            # 先停主对话当前轮并丢弃排队请求，避免清空后台后模型仍按旧指令继续 task_ai
            self._interrupt_current_main_chat_turn(log, "[/task clear]")
            dropped = self._drain_main_chat_queue()
            if dropped:
                log.write(
                    Text(
                        f"[/task clear] 已丢弃排队中的 {dropped} 条主对话请求",
                        style="cyan",
                    )
                )
            try:
                result = await self.ctx.tasks.clear_task_history()
            except Exception as e:  # noqa: BLE001
                self.ctx.session_log.write_exception(
                    "tui",
                    "AicdTui",
                    "/task clear",
                    e,
                )
                log.write(Text(f"[/task clear] 失败: {e}", style="red"))
                return
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/task clear",
                "",
                str(result)[:2000],
            )
            self._selected_task = None
            tlog = self.query_one("#tasklog", RichLog)
            tlog.clear()
            tlog.write(Text("[任务历史已清空]", style="cyan"))
            await self.refresh_task_table()
            log.write(
                Text(
                    "[/task clear] 已清空：取消运行中任务，删除库中任务与 task_messages，"
                    f"清理工作区目录（详情: {result}）",
                    style="cyan",
                )
            )
            return
        if sub == "ai":
            instruction = " ".join(args[1:]).strip()
            if not instruction:
                log.write(
                    Text(
                        "用法: /task ai <说明…>  例: /task ai 在工作区统计所有 .py 行数",
                        style="yellow",
                    )
                )
                return
            tid = await self.ctx.tasks.create_ai_task(instruction)
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/task ai",
                instruction[:2000],
                f"task_id={tid}",
            )
            log.write(
                Text(
                    f"[/task ai] 已排队后台子 Agent\n"
                    f"  task_id={tid}\n"
                    f"右侧任务表与日志可跟踪；也可让主对话用 task_result 查询。",
                    style="cyan",
                )
            )
            await self.refresh_task_table()
            return
        if sub == "shell":
            cmd = join_args_for_shell(args[1:])
            if not cmd.strip():
                log.write(
                    Text(
                        '用法: /task shell <命令>  例: /task shell curl -L -o tmp/dl.zip URL',
                        style="yellow",
                    )
                )
                return
            try:
                tid = await self.ctx.tasks.create_shell_task(cmd)
            except ValueError as e:
                log.write(Text(str(e), style="red"))
                return
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/task shell",
                cmd[:2000],
                f"task_id={tid}",
            )
            log.write(
                Text(
                    f"[/task shell] 已排队后台 Shell\n"
                    f"  task_id={tid}\n"
                    f"输出在右侧任务日志；主对话可用 task_list / task_result 查看状态。",
                    style="cyan",
                )
            )
            await self.refresh_task_table()
            return
        if sub == "dummy":
            steps = 5
            if len(args) > 1:
                try:
                    steps = max(1, min(500, int(args[1])))
                except ValueError:
                    log.write(Text("dummy 步数须为整数", style="yellow"))
                    return
            tid = await self.ctx.tasks.create_dummy_long_task(steps=steps)
            self.ctx.session_log.write_event(
                "tui",
                "AicdTui",
                "/task dummy",
                f"steps={steps}",
                f"task_id={tid}",
            )
            log.write(
                Text(f"[/task dummy] task_id={tid} steps={steps}", style="cyan")
            )
            await self.refresh_task_table()
            return
        log.write(
            Text(
                f"未知子命令 {sub!r}，输入 /task help",
                style="yellow",
            )
        )

