import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type MouseEvent as ReactMouseEvent,
  type ReactNode
} from 'react'
import {
  addProject,
  apiInjectActiveModels,
  apiInjectStatus,
  getConfig,
  listModels,
  listSessions,
  onOpenProject,
  onPtyData,
  onPtyExit,
  lastAssistant,
  lastToolUse,
  onMobileInbound,
  onSessionStatus,
  onWechatInbound,
  mobilePublishSessions,
  mobileSnapshot,
  ptyKill,
  ptyWrite,
  removeProject,
  renameSession,
  sessionMeta,
  takePendingOpen,
  wechatSendNotice
} from './ipc'
import type { Config, LaunchOptions, ModelInfo, ProjectEntry, TabStatus, TerminalTab } from './types'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { Sidebar } from './components/Sidebar'
import { SessionList } from './components/SessionList'
import { TerminalView, writeToTerminal, readTerminalScreen, serializeTerminal } from './components/TerminalView'
import { CommandDrawer } from './components/CommandDrawer'
import { CommandsPage } from './components/CommandsPage'
import { RulesPage } from './components/RulesPage'
import { ProjectRulesView } from './components/ProjectRulesView'
import { FilePreview } from './components/FilePreview'
import { SettingsPage } from './components/SettingsPage'
import { SearchPage } from './components/SearchPage'
import { McpPage } from './components/McpPage'
import { TunnelsPage } from './components/TunnelsPage'
import { ApiInjectPage } from './components/ApiInjectPage'
import { AgentsPage } from './components/AgentsPage'
import { InboxDialog } from './components/InboxDialog'
import { NewSessionDialog, ResumeSessionDialog, buildClaudeArgs } from './components/NewSessionDialog'
import { ConfirmDialog, Modal } from './components/Modal'
import { IconClose, IconGear, IconList, IconPlus, IconSplit } from './components/Icons'

export type View =
  | { kind: 'project'; path: string }
  | { kind: 'commands' }
  | { kind: 'rules' }
  | { kind: 'settings' }
  | { kind: 'search' }
  | { kind: 'mcp' }
  | { kind: 'tunnels' }
  | { kind: 'apiInject' }
  | { kind: 'agents' }

/** 项目内当前显示的区块：会话列表 / 项目规则 / 终端工作区 / 文件预览 */
type Section = 'home' | 'rules' | 'term' | 'preview'

/** 单页最多 6 栏；更多会话用分页承载 */
const MAX_PANES = 6

/** 多栏排布方式：grid=自动宫格，columns=并行（全部并排成一行） */
type PaneLayout = 'grid' | 'columns'

/**
 * N 栏的行排布（行优先，每项为该行的栏数）。
 * grid（宫格，默认）：1 / 2 / 2+1 / 2+2 / 3+2 / 3+3
 * columns（并行）：单行 N 栏并排
 */
function rowsOf(n: number, layout: PaneLayout = 'grid'): number[] {
  if (n <= 1) return [1]
  if (layout === 'columns') return [n]
  if (n === 2) return [2]
  if (n === 3) return [2, 1]
  if (n === 4) return [2, 2]
  if (n === 5) return [3, 2]
  return [3, 3]
}

/** 一个工作区分页：若干栏位（存 tabId）+ 聚焦栏位 + 可拖拽的行列比例 */
interface WorkPage {
  panes: (string | null)[]
  focused: number
  /** 多栏排布方式（宫格 / 并行） */
  layout: PaneLayout
  /** 每行高度百分比，和为 100 */
  rowRatios: number[]
  /** 每行中各栏宽度百分比，行内和为 100 */
  colRatios: number[][]
}

const evenRatios = (count: number): number[] => Array.from({ length: count }, () => 100 / count)

/** 由栏位列表构造分页（行列比例重置为均分） */
function makePage(panes: (string | null)[], focused = 0, layout: PaneLayout = 'grid'): WorkPage {
  const rows = rowsOf(panes.length, layout)
  return { panes, focused, layout, rowRatios: evenRatios(rows.length), colRatios: rows.map(evenRatios) }
}

/** 栏位下标 → 行/列位置（行优先） */
function paneRowCol(n: number, idx: number, layout: PaneLayout): { row: number; col: number } {
  const rows = rowsOf(n, layout)
  let row = 0
  let start = 0
  while (idx >= start + rows[row]) {
    start += rows[row]
    row++
  }
  return { row, col: idx - start }
}

const sumTo = (arr: number[], end: number) => arr.slice(0, end).reduce((a, b) => a + b, 0)

const HOOK_STATUS: Record<string, TabStatus> = {
  UserPromptSubmit: 'working',
  Stop: 'idle',
  Notification: 'attention'
}

/** 微信回复"完整输出"的字符上限（足够大≈不截断；send_message 会按 3500 字自动分条发） */
const WX_FULL_REPLY = 500000
/** 粘性"当前会话"有效期：超过此时长无操作即失效，无编号消息回退到兜底逻辑 */
const WX_CURRENT_TTL = 15 * 60 * 1000
/** 暂存图片有效期：超过此时长未等到配套文字即视为过期（避免旧图误粘到无关新文字上） */
const WX_IMG_TTL = 10 * 60 * 1000
/** attention 自动带屏 / 看屏抓取的终端行数 */
const WX_SCREEN_LINES = 20

/** 权限模式词 → LaunchOptions.permissionMode */
const PERM_MODE: Record<string, LaunchOptions['permissionMode']> = {
  plan: 'plan',
  计划: 'plan',
  acceptedits: 'acceptEdits',
  自动编辑: 'acceptEdits',
  接受编辑: 'acceptEdits',
  bypass: 'bypassPermissions',
  绕过: 'bypassPermissions',
  危险: 'bypassPermissions'
}

/** 微信发"帮助/help"时自动回复的指令速查 */
const WECHAT_HELP = [
  'CCGUI 微信遥控 · 指令速查',
  '',
  '【多会话与 #编号】',
  '每个运行中会话有个 #编号（见通知标题【#3 …】）。',
  '· 多会话时先发「#3」切到它，之后无编号的文字/图片都发给 #3（15 分钟内有效）',
  '· 也可随时在消息开头加编号直接指定，如「#5 继续」',
  '· 不带编号且没切过会话 → 发给最近在等你的会话',
  '',
  '【会话与项目】',
  '· 项目 — 列所有项目（P1/P2…）',
  '· P1 会话 — 列该项目运行中 + 历史最近5（H1/H2…）',
  '· H1 恢复 [plan/自动编辑/绕过] — 启动历史会话',
  '· P1 新会话 [plan/自动编辑/绕过] — 新建会话',
  '· 会话 — 列所有运行中会话',
  '',
  '【对话与队列】',
  '· 直接发文字 — 发给当前会话（忙则排队、空闲即发）',
  '· #3 输出 — 看该会话最近完整回复',
  '· 队列 / 撤回 / 删 N / 清空 — 队列管理',
  '· 停止 — 中断会话',
  '',
  '【答选项 / 菜单】Claude 让你选时会把选项发给你',
  '· 回数字选（如 2；多选回 1 3）；权限框回 1/2/3 或 y / n',
  '· 上 / 下 / 左 / 右 / 回车 / 空格 — 操作菜单，可连发如「下下回车」',
  '· #3 屏幕 — 看终端当前画面（菜单不识别时兜底）',
  '',
  '【图片】直接发图＝暂存给当前会话；接着发文字会和它一起提交（忙则一起入队）。#3 发图＝单独发，#3 清图＝取消',
  '',
  '帮助 — 再看本清单'
].join('\n')

/** 微信端结构化选项：一个问题（AskUserQuestion 的一题，或权限确认） */
type WxQuestion = { question: string; multiSelect: boolean; options: string[] }
type WxPrompt = { kind: 'ask' | 'permission'; questions?: WxQuestion[]; ts: number }

/** 权限确认时把工具入参摘要成一行可读文本 */
function permissionSummary(name: string, input: unknown): string {
  const o = (input ?? {}) as Record<string, unknown>
  if (typeof input !== 'object' || input === null) return '(无参数)'
  if (name === 'Bash') {
    const cmd = String(o.command ?? '').slice(0, 600)
    return o.description ? `${cmd}\n— ${String(o.description)}` : cmd
  }
  if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Read'].includes(name)) {
    return String(o.file_path ?? o.notebook_path ?? '')
  }
  if (name === 'WebFetch') return String(o.url ?? '')
  try {
    return JSON.stringify(input).slice(0, 400)
  } catch {
    return '(参数)'
  }
}

/** 把待处理工具调用渲染成微信结构化文本；返回 null 表示无法结构化（走屏幕兜底） */
function formatToolPrompt(tu: { name: string; input: unknown }): {
  kind: 'ask' | 'permission'
  questions?: WxQuestion[]
  text: string
} | null {
  const input = tu.input as { questions?: unknown[] } | null
  if (tu.name === 'AskUserQuestion' && Array.isArray(input?.questions) && input.questions.length) {
    const questions: WxQuestion[] = (input.questions as Record<string, unknown>[]).map((q) => ({
      question: String(q?.question ?? q?.header ?? ''),
      multiSelect: !!q?.multiSelect,
      options: Array.isArray(q?.options)
        ? (q.options as Record<string, unknown>[]).map((o) => String(o?.label ?? o))
        : []
    }))
    const lines: string[] = []
    questions.forEach((q, qi) => {
      const tag = q.multiSelect ? '多选' : '单选'
      lines.push(questions.length > 1 ? `问题${qi + 1}（${tag}）${q.question}` : `${q.question}（${tag}）`)
      q.options.forEach((o, oi) => lines.push(`  ${oi + 1}. ${o}`))
    })
    const hint =
      questions.length > 1
        ? '多个问题：用 上/下/回车 逐题操作，或回数字选当前题；#编号 屏幕 看当前题'
        : questions[0].multiSelect
          ? '回多个数字选（如「1 3」）'
          : '回数字选（如「2」）'
    return { kind: 'ask', questions, text: `Claude 想确认：\n${lines.join('\n')}\n${hint}` }
  }
  if (tu.name) {
    return {
      kind: 'permission',
      text: `Claude 要执行【${tu.name}】：\n${permissionSummary(tu.name, tu.input)}\n回 1 允许 / 2 允许且不再问 / 3 拒绝`
    }
  }
  return null
}

/** 按键词 → 终端转义序列 */
const WX_KEY_SEQ: Record<string, string> = {
  上: '\x1b[A', 下: '\x1b[B', 左: '\x1b[D', 右: '\x1b[C',
  up: '\x1b[A', down: '\x1b[B', left: '\x1b[D', right: '\x1b[C',
  回车: '\r', 确定: '\r', 确认: '\r', enter: '\r', 空格: ' ', space: ' '
}
/** 把只含按键词的消息解析成按键序列（如「下下回车」「下 下 回车」「上回车」）；非纯按键词返回 null */
function tokenizeKeys(s: string): string[] | null {
  const kws = ['回车', '确定', '确认', '空格', '上', '下', '左', '右', 'enter', 'space', 'up', 'down', 'left', 'right']
  let rest = s.replace(/\s+/g, '')
  if (!rest) return null
  const out: string[] = []
  while (rest.length) {
    const low = rest.toLowerCase()
    const kw = kws.find((k) => low.startsWith(k))
    if (!kw) return null
    out.push(WX_KEY_SEQ[kw])
    rest = rest.slice(kw.length)
  }
  return out.length ? out : null
}
/** 依次把按键写入 PTY（间隔 90ms 让 TUI 逐步处理导航/选择） */
function sendKeys(tabId: string, keys: string[]) {
  keys.forEach((k, i) => setTimeout(() => void ptyWrite(tabId, k), i * 90))
}
/** 标签显示名：有手动覆盖名时优先用覆盖名，否则用会话记录的自动标题 */
function effectiveTitle(autoTitle: string, sessionId: string, cfg: Config | null): string {
  return cfg?.titleOverrides[sessionId] ?? autoTitle
}

function normPath(p: string): string {
  return p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
}

const pct = (n: number) => `${n}%`

/** 栏头高度：显示项目名 + 会话标题 + 会话选择器 */
const PANE_HEADER_H = 26

interface PaneGeom {
  left: string
  top: string
  width: string
  height: string
  borderLeft?: boolean
  borderTop?: boolean
}

/** 计算栏位几何（百分比定位），按分页的行列比例 */
function paneGeometry(page: WorkPage, idx: number): PaneGeom | null {
  if (idx < 0 || idx >= page.panes.length) return null
  const { row, col } = paneRowCol(page.panes.length, idx, page.layout)
  return {
    left: pct(sumTo(page.colRatios[row], col)),
    top: pct(sumTo(page.rowRatios, row)),
    width: pct(page.colRatios[row][col]),
    height: pct(page.rowRatios[row]),
    borderLeft: col > 0,
    borderTop: row > 0
  }
}

/** 栏头定位：贴在栏位顶部 */
function headerStyle(g: PaneGeom): CSSProperties {
  return {
    left: g.left,
    top: g.top,
    width: g.width,
    height: PANE_HEADER_H,
    ...(g.borderLeft ? { borderLeft: '1px solid var(--border)' } : {}),
    ...(g.borderTop ? { borderTop: '1px solid var(--border)' } : {})
  }
}

/** 终端主体定位：栏位去掉栏头后的剩余区域 */
function hostStyle(g: PaneGeom): CSSProperties {
  return {
    left: g.left,
    width: g.width,
    top: `calc(${g.top} + ${PANE_HEADER_H}px)`,
    height: `calc(${g.height} - ${PANE_HEADER_H}px)`,
    ...(g.borderLeft ? { borderLeft: '1px solid var(--border)' } : {})
  }
}

export default function App() {
  const [config, setConfig] = useState<Config | null>(null)
  const [view, setView] = useState<View>({ kind: 'search' })
  const [tabs, setTabs] = useState<TerminalTab[]>([])
  const [sections, setSections] = useState<Record<string, Section>>({})
  /** 每个项目当前预览的文件（单文件替换式） */
  const [previewFiles, setPreviewFiles] = useState<Record<string, string | null>>({})
  const [previewDirty, setPreviewDirty] = useState(false)
  const previewDirtyRef = useRef(previewDirty)
  previewDirtyRef.current = previewDirty
  // 工作区是全局的：若干分页，每页若干栏位（栏位可放任意项目的标签）
  const [pages, setPages] = useState<WorkPage[]>([makePage([null])])
  const [activePage, setActivePage] = useState(0)
  const [drawerOpen, setDrawerOpen] = useState(true)
  const [dark, setDark] = useState(false)
  const [newSessionOpen, setNewSessionOpen] = useState(false)
  const [closingTab, setClosingTab] = useState<TerminalTab | null>(null)
  const [removingProject, setRemovingProject] = useState<string | null>(null)
  const [inboxOpen, setInboxOpen] = useState(false)
  /** 栏头"模型与参数"菜单 */
  const [paramMenu, setParamMenu] = useState<{ tab: TerminalTab; x: number; y: number } | null>(null)
  /** 栏头关闭菜单：选择「仅关闭分栏」或「关闭分栏及终端」 */
  const [closeMenu, setCloseMenu] = useState<{ idx: number; x: number; y: number } | null>(null)
  /** 标签卡右键菜单：重命名 / 关闭标签 */
  const [tabMenu, setTabMenu] = useState<{ tab: TerminalTab; x: number; y: number } | null>(null)
  /** 标签卡重命名对话框（写入 titleOverrides，与历史会话页面同源） */
  const [renamingTab, setRenamingTab] = useState<TerminalTab | null>(null)
  const [renameTabText, setRenameTabText] = useState('')
  /** 恢复会话对话框：选择本次恢复使用的权限模式 */
  const [resumingSession, setResumingSession] = useState<{
    projectPath: string
    sessionId: string
    title: string
  } | null>(null)
  /** 官方模型清单（/v1/models 动态拉取，首次打开菜单时加载） */
  const [models, setModels] = useState<ModelInfo[] | null>(null)
  const [modelsError, setModelsError] = useState('')
  /** 当前清单对应的注入态（official/direct/router）；态变则缓存失效重拉 */
  const [modelsFor, setModelsFor] = useState<string | null>(null)
  const [apiMode, setApiMode] = useState<string | null>(null)
  /** 提示队列：tabId -> 待发送的提示列表，回合结束（Stop）时自动逐条发送 */
  const [queues, setQueues] = useState<Record<string, string[]>>({})
  const queuesRef = useRef(queues)
  queuesRef.current = queues

  // 供事件监听器读取最新状态（监听器只注册一次）
  const configRef = useRef(config)
  configRef.current = config
  /** 会话编号单调计数器：openTab 时分配 wxLabel（微信通知里标识会话、回复时寻址） */
  const wxSeqRef = useRef(1)
  /** 微信"P项目 会话"列出历史会话时的临时编号映射：H编号 -> 会话定位（每次列表刷新） */
  const wxHistRef = useRef<Record<number, { projectPath: string; sessionId: string; title: string }>>({})
  /** 微信"当前会话"（粘性）：无编号消息/图片默认发给它；含时间戳，超 WX_CURRENT_TTL 失效 */
  const wxCurrentRef = useRef<{ tabId: string; ts: number } | null>(null)
  /** 微信暂存图片：tabId -> 待与下一条文字原子提交的图片路径（含时间戳，超 WX_IMG_TTL 过期） */
  const wxPendingImgRef = useRef<Record<string, { paths: string[]; ts: number }>>({})
  /** 每个会话最近一次 PTY 输出时间戳，用于判断"是否就绪可发"（静默信号，比 status 更可靠） */
  const lastDataRef = useRef<Record<string, number>>({})
  /**
   * 在途发送：tabId -> 已写入终端但尚未确认提交的指令。队列泵据此做"提交确认 + 补回车"闭环，
   * 避免回车被 TUI 吞掉时正文滞留输入框却被当成已发出。firstLine 用于读屏判断输入框是否仍残留正文。
   */
  const pendingSendRef = useRef<Record<string, { firstLine: string; sentAt: number; retries: number }>>({})
  /** 微信端当前展示给用户的结构化选项（AskUserQuestion/权限确认），用于把"回数字"翻译成按键 */
  const wxPromptRef = useRef<Record<string, WxPrompt>>({})

  /**
   * 会话是否就绪可发：用 PTY 静默时长判断，避开 status 不准的两种情况——
   * ① 刚 Stop 后 TUI 未回到输入态就发会被吞回车；② ESC 中断不触发 Stop、status 卡 working。
   * idle 静默 >0.8s（含 settle）即发；working 但静默 >2.5s 视为已停（ESC/工具间隙）也发；attention 不发（先答权限）。
   */
  const readyToSend = useCallback((tab: TerminalTab) => {
    if (tab.status === 'exited' || tab.status === 'attention') return false
    const quiet = Date.now() - (lastDataRef.current[tab.id] ?? 0)
    return tab.status === 'idle' ? quiet > 800 : quiet > 2500
  }, [])
  const viewRef = useRef(view)
  viewRef.current = view
  const sectionsRef = useRef(sections)
  sectionsRef.current = sections
  const tabsRef = useRef(tabs)
  tabsRef.current = tabs
  const pagesRef = useRef(pages)
  pagesRef.current = pages
  const activePageRef = useRef(activePage)
  activePageRef.current = activePage
  const termAreaRef = useRef<HTMLDivElement>(null)
  /** 聚焦栏位中的标签（文件拖放/文件树/指令面板的目标） */
  const focusedTabRef = useRef<TerminalTab | null>(null)

  const isTabVisible = useCallback((tab: TerminalTab) => {
    const v = viewRef.current
    if (v.kind !== 'project') return false
    if ((sectionsRef.current[v.path] ?? 'home') !== 'term') return false
    return pagesRef.current[activePageRef.current]?.panes.includes(tab.id) ?? false
  }, [])

  // ---------- 主题 ----------
  const theme = config?.settings.theme ?? 'system'
  useEffect(() => {
    const media = window.matchMedia('(prefers-color-scheme: dark)')
    const apply = () => {
      const isDark = theme === 'dark' || (theme === 'system' && media.matches)
      document.documentElement.dataset.theme = isDark ? 'dark' : 'light'
      setDark(isDark)
    }
    apply()
    media.addEventListener('change', apply)
    return () => media.removeEventListener('change', apply)
  }, [theme])

  // ---------- 栏位与分页操作 ----------

  /** 修改指定分页（默认当前页） */
  const updatePage = useCallback((updater: (pg: WorkPage) => WorkPage, pi?: number) => {
    const target = pi ?? activePageRef.current
    setPages((prev) => prev.map((pg, i) => (i === target ? updater(pg) : pg)))
  }, [])

  /** 把标签放入当前页指定栏位（保证一个标签在所有分页中只出现一次） */
  const assignPane = useCallback((idx: number, tabId: string | null) => {
    setPages((prev) =>
      prev.map((pg, pi) => {
        const panes = tabId !== null ? pg.panes.map((p) => (p === tabId ? null : p)) : [...pg.panes]
        if (pi === activePageRef.current) panes[idx] = tabId
        return { ...pg, panes }
      })
    )
  }, [])

  /** 聚焦指定分页（默认当前页）的栏位 */
  const focusPane = useCallback((idx: number, pi?: number) => {
    const target = pi ?? activePageRef.current
    setPages((prev) =>
      prev.map((pg, i) => (i === target && pg.focused !== idx ? { ...pg, focused: idx } : pg))
    )
  }, [])

  /** 当前页聚焦栏位的下标 */
  const focusedIdx = useCallback(
    () => pagesRef.current[activePageRef.current]?.focused ?? 0,
    []
  )

  /** 当前页添加一栏（上限 6）：自动填入未上屏的运行中标签，并聚焦新栏 */
  const addPane = useCallback(() => {
    setPages((prev) =>
      prev.map((pg, pi) => {
        if (pi !== activePageRef.current || pg.panes.length >= MAX_PANES) return pg
        const assigned = new Set(prev.flatMap((p) => p.panes).filter(Boolean))
        const candidate = tabsRef.current.find((t) => t.status !== 'exited' && !assigned.has(t.id))
        const panes = [...pg.panes, candidate ? candidate.id : null]
        return makePage(panes, panes.length - 1, pg.layout)
      })
    )
  }, [])

  /** 切换当前页的多栏排布方式（宫格 / 并行），行列比例按新结构重置为均分 */
  const setPaneLayout = useCallback((layout: PaneLayout) => {
    setPages((prev) =>
      prev.map((pg, pi) =>
        pi === activePageRef.current ? makePage(pg.panes, pg.focused, layout) : pg
      )
    )
  }, [])

  /** 关闭当前页的一栏：仅移除栏位，栏内会话保持后台运行 */
  const removePane = useCallback((idx: number) => {
    setPages((prev) =>
      prev.map((pg, pi) => {
        if (pi !== activePageRef.current || pg.panes.length <= 1) return pg
        const panes = pg.panes.filter((_, i) => i !== idx)
        const focused = Math.min(pg.focused > idx ? pg.focused - 1 : pg.focused, panes.length - 1)
        return makePage(panes, focused, pg.layout)
      })
    )
  }, [])

  /** 新建分页并切换过去 */
  const addPage = useCallback(() => {
    setActivePage(pagesRef.current.length)
    setPages((prev) => [...prev, makePage([null])])
  }, [])

  /** 关闭分页：页内会话保持后台运行 */
  const closePage = useCallback((pi: number) => {
    const len = pagesRef.current.length
    if (len <= 1) return
    setPages((prev) => prev.filter((_, i) => i !== pi))
    setActivePage((cur) => Math.min(cur > pi ? cur - 1 : cur, len - 2))
  }, [])

  // ---------- 标签操作 ----------
  const openTab = useCallback(
    (projectPath: string, sessionId: string, title: string, args: string[], model = '') => {
      setView({ kind: 'project', path: projectPath })
      setSections((s) => ({ ...s, [projectPath]: 'term' }))
      // 同一会话已有标签时直接上屏，避免重复启动
      const existing = tabsRef.current.find(
        (t) => t.projectPath === projectPath && t.sessionId === sessionId && t.status !== 'exited'
      )
      if (existing) {
        assignPane(focusedIdx(), existing.id)
        return
      }
      const tab: TerminalTab = {
        id: crypto.randomUUID(),
        projectPath,
        sessionId,
        // autoTitle 存原始自动标题；title 叠加覆盖名后显示。Stop 时由会话记录校正 autoTitle
        autoTitle: title,
        title: effectiveTitle(title, sessionId, configRef.current),
        // 初始即"空闲在提示符等输入"：刚启动的会话不会触发 Stop hook，
        // 若初始设 'working' 会被误判为忙碌，导致微信入站消息一直卡在队列发不出。
        // 真正忙碌由 UserPromptSubmit hook 置 'working'。
        status: 'idle',
        unread: false,
        statusSince: Date.now(),
        lastNotice: '',
        model,
        thinking: false,
        args,
        wxLabel: wxSeqRef.current++
      }
      setTabs((t) => [...t, tab])
      assignPane(focusedIdx(), tab.id)
    },
    [assignPane, focusedIdx]
  )

  const newSession = useCallback(
    (projectPath: string, opts: LaunchOptions) => {
      const sessionId = crypto.randomUUID()
      const args = ['--session-id', sessionId, ...buildClaudeArgs(opts)]
      // 启动参数里的模型先作为临时显示，首回合结束后被实际模型 ID 覆盖
      openTab(projectPath, sessionId, '新会话', args, opts.model)
    },
    [openTab]
  )

  /** 点恢复：先弹对话框选权限模式（Claude Code 不保存上次模式，bypass 需启动时显式带标志） */
  const resumeSession = useCallback((projectPath: string, sessionId: string, title: string) => {
    setResumingSession({ projectPath, sessionId, title })
  }, [])

  /** 对话框确认后按所选权限模式恢复会话 */
  const doResume = useCallback(
    (
      projectPath: string,
      sessionId: string,
      title: string,
      permissionMode: LaunchOptions['permissionMode']
    ) => {
      const args = ['--resume', sessionId]
      if (permissionMode) args.push('--permission-mode', permissionMode)
      openTab(projectPath, sessionId, title, args)
      // 立即从会话记录读取该会话此前使用的模型与思考标记，不必等首回合结束
      void sessionMeta(projectPath, sessionId).then((meta) => {
        if (!meta.model && !meta.thinking) return
        setTabs((all) =>
          all.map((t) =>
            t.projectPath === projectPath && t.sessionId === sessionId
              ? { ...t, model: t.model || meta.model, thinking: meta.thinking }
              : t
          )
        )
      })
    },
    [openTab]
  )

  const closeTab = useCallback((tab: TerminalTab) => {
    void ptyKill(tab.id)
    setTabs((all) => all.filter((t) => t.id !== tab.id))
    setQueues((q) => {
      const { [tab.id]: _removed, ...rest } = q
      return rest
    })
    // 所有分页都没有其他上屏标签时，回到该项目的会话列表
    const stillOnScreen = pagesRef.current.some((pg) =>
      pg.panes.some((p) => p !== null && p !== tab.id)
    )
    if (!stillOnScreen) setSections((s) => ({ ...s, [tab.projectPath]: 'home' }))
    setPages((prev) =>
      prev.map((pg) =>
        pg.panes.includes(tab.id)
          ? { ...pg, panes: pg.panes.map((p) => (p === tab.id ? null : p)) }
          : pg
      )
    )
  }, [])

  /** 关闭当前页的一栏并终止栏内会话：先终止 claude 进程，再移除栏位 */
  const removePaneAndTerminal = useCallback(
    (idx: number) => {
      const pg = pagesRef.current[activePageRef.current]
      const tabId = pg?.panes[idx] ?? null
      const tab = tabId ? tabsRef.current.find((t) => t.id === tabId) : null
      if (tab) closeTab(tab)
      removePane(idx)
    },
    [closeTab, removePane]
  )

  // 移动端 App：发布活动会话快照（tabs / 状态变化即推送，供手机端会话列表与状态徽标）
  useEffect(() => {
    const s = configRef.current?.settings
    if (!s?.mobileEnabled && !s?.mobileRelayEnabled) return
    const snapshot = tabs
      .filter((t) => t.status !== 'exited')
      .map((t) => ({
        tabId: t.id,
        sessionId: t.sessionId,
        projectPath: t.projectPath,
        title: t.title,
        status: t.status,
        model: t.model
      }))
    void mobilePublishSessions(snapshot)
  }, [tabs])

  // 移动端 App：远程遥控（对称于 onWechatInbound，复用桌面编排 newSession/doResume/closeTab/sendKeys）
  useEffect(() => {
    let un: (() => void) | undefined
    const answerFromMobile = (tabId: string, choice: string) => {
      const askQ = wxPromptRef.current[tabId]?.questions?.[0]
      if (askQ && !askQ.multiSelect && /^\d{1,2}$/.test(choice)) {
        const n = Number(choice)
        if (n >= 1 && n <= askQ.options.length) sendKeys(tabId, [...Array(n - 1).fill('\x1b[B'), '\r'])
        delete wxPromptRef.current[tabId]
      } else if (askQ && askQ.multiSelect && /^[\d\s,，、]+$/.test(choice)) {
        const nums = [
          ...new Set(
            choice
              .split(/[\s,，、]+/)
              .map(Number)
              .filter((x) => x >= 1 && x <= askQ.options.length)
          )
        ].sort((a, b) => a - b)
        if (nums.length) {
          const keys: string[] = []
          let pos = 1
          for (const n of nums) {
            while (pos < n) {
              keys.push('\x1b[B')
              pos++
            }
            keys.push(' ')
          }
          keys.push('\r')
          sendKeys(tabId, keys)
        }
        delete wxPromptRef.current[tabId]
      } else {
        // 权限类或直接按键：y/n/1/2/3 直接写入并回车
        writeToTerminal(tabId, choice, true)
      }
    }
    void onMobileInbound((p) => {
      if (p.kind === 'new' && p.projectPath) {
        newSession(p.projectPath, {
          model: p.model ?? '',
          permissionMode: (p.permissionMode ?? '') as LaunchOptions['permissionMode'],
          extraArgs: p.extraArgs ?? ''
        })
      } else if (p.kind === 'resume' && p.projectPath && p.sessionId) {
        doResume(p.projectPath, p.sessionId, '', (p.permissionMode ?? '') as LaunchOptions['permissionMode'])
      } else if (p.kind === 'close' && p.tabId) {
        const tab = tabsRef.current.find((t) => t.id === p.tabId)
        if (tab) closeTab(tab)
      } else if (p.kind === 'answer' && p.tabId) {
        answerFromMobile(p.tabId, p.choice ?? '')
      } else if (p.kind === 'serialize' && p.tabId && p.connId != null) {
        // 手机 attach：序列化该会话完整回看缓冲，定向回传给发起的连接
        const data = serializeTerminal(p.tabId)
        if (data) void mobileSnapshot(p.connId, p.tabId, data)
      } else if (p.kind === 'projectAdded') {
        // 手机端添加了项目：配置已在后端落盘，前端刷新配置让侧栏立即显示
        void getConfig().then(setConfig)
      }
    }).then((fn) => {
      un = fn
    })
    return () => un?.()
  }, [newSession, doResume, closeTab])

  // ---------- 初始化 + 全局事件 ----------
  useEffect(() => {
    let disposed = false
    const unlisteners: (() => void)[] = []

    void (async () => {
      const c = await getConfig()
      if (disposed) return
      setConfig(c)
      if (c.projects.length > 0) {
        setView({ kind: 'project', path: c.projects[0].path })
      }
      // 冷启动时由 --open 传入的项目
      const pending = await takePendingOpen()
      if (pending && !disposed) {
        setConfig(await getConfig())
        setView({ kind: 'project', path: pending.path })
        if (pending.isNew) {
          newSession(pending.path, { model: '', permissionMode: '', extraArgs: '' })
        }
      }
    })()

    // 运行中由右键菜单再次打开
    void onOpenProject(async (p) => {
      setConfig(await getConfig())
      setView({ kind: 'project', path: p.path })
      if (p.isNew) {
        newSession(p.path, { model: '', permissionMode: '', extraArgs: '' })
      }
    }).then((fn) => unlisteners.push(fn))

    // 终端退出 → 标记状态
    void onPtyExit((p) => {
      setTabs((all) =>
        all.map((t) => (t.id === p.tabId ? { ...t, status: 'exited' as TabStatus } : t))
      )
    }).then((fn) => unlisteners.push(fn))

    // 不可见标签产生输出 → 未读标记；同时记录最近输出时间（队列泵的"就绪"判据）
    void onPtyData((p) => {
      lastDataRef.current[p.tabId] = Date.now()
      const tab = tabsRef.current.find((t) => t.id === p.tabId)
      if (tab && !isTabVisible(tab) && !tab.unread) {
        setTabs((all) => all.map((t) => (t.id === p.tabId ? { ...t, unread: true } : t)))
      }
    }).then((fn) => unlisteners.push(fn))

    // hooks 上报的会话状态
    void onSessionStatus((p) => {
      const status = HOOK_STATUS[p.event]
      if (!status) return
      // 闲置等待（回合结束后 60 秒没回应）不算待处理：状态保持空闲，
      // 只有权限确认等真正需要动作的通知才转为 attention
      if (p.event === 'Notification' && /waiting for your input/i.test(p.message)) return
      // 匹配必须在 tabsRef 上同步完成：setTabs 的更新函数是延迟执行的，
      // 不能用它收集匹配结果（否则后续队列逻辑拿到的永远是空集）
      const all = tabsRef.current
      let matched = all.filter((t) => t.sessionId === p.sessionId && t.status !== 'exited')
      if (matched.length === 0 && p.cwd) {
        // 会话 id 对不上（如 resume 后分叉）时，回退到按项目目录匹配唯一运行中的标签
        const byCwd = all.filter(
          (t) => normPath(t.projectPath) === normPath(p.cwd) && t.status !== 'exited'
        )
        if (byCwd.length === 1) matched = byCwd
      }
      if (matched.length === 0) return
      const matchedIds = new Set(matched.map((m) => m.id))
      // 提示信息：等待时记录 hook 消息，回合结束时记录完成摘要
      const notice = p.event === 'Notification' ? p.message : p.event === 'Stop' ? p.summary : null
      setTabs((current) =>
        current.map((t) =>
          matchedIds.has(t.id)
            ? {
                ...t,
                status,
                statusSince: t.status === status ? t.statusSince : Date.now(),
                lastNotice: notice !== null && notice !== '' ? notice : t.lastNotice,
                model: p.model || t.model,
                thinking: p.event === 'Stop' ? p.thinking : t.thinking,
                // Stop 带回会话记录解析出的自动标题（非 Stop 为空，用 || 保留原值），
                // 据此把"新会话"占位热更新为真实标题；覆盖名仍优先
                autoTitle: p.title || t.autoTitle,
                title: effectiveTitle(p.title || t.autoTitle, t.sessionId, configRef.current)
              }
            : t
        )
      )
      // 注：移动端已改为直接在镜像终端里操作，不再推送结构化选项/权限弹窗（避免读屏推断误报）

      // 出站微信通知（模型A：通知必带会话标识 #编号 + 项目名 + 标题）
      const wcfg = configRef.current?.settings
      if (wcfg?.wechatEnabled) {
        const pushAttn = wcfg.wechatPushAttention && p.event === 'Notification'
        const pushDone = wcfg.wechatPushDone && p.event === 'Stop'
        if (pushAttn || pushDone) {
          const pname = (path: string) =>
            configRef.current?.projects.find((pr) => pr.path === path)?.name ?? path
          for (const t of matched) {
            const head = `【#${t.wxLabel} · ${pname(t.projectPath)} · ${t.title}】`
            if (pushAttn) {
              // 稍等让 transcript 落盘，读最近一个待处理工具调用 → 结构化展示选项/权限；
              // 识别不了（如 /model 选择器等非工具菜单）再退回清理后的终端画面。
              const tab = t
              setTimeout(() => {
                void lastToolUse(tab.projectPath, tab.sessionId).then((tu) => {
                  const fmt = tu ? formatToolPrompt(tu) : null
                  if (fmt) {
                    wxPromptRef.current[tab.id] = { kind: fmt.kind, questions: fmt.questions, ts: Date.now() }
                    void wechatSendNotice(`${head}\n${fmt.text}`)
                  } else {
                    const scr = readTerminalScreen(tab.id, WX_SCREEN_LINES)
                    void wechatSendNotice(
                      scr
                        ? `${head} 需要你处理：\n${scr}\n（回 y/n 或数字；菜单用 上/下 + 回车）`
                        : `${head}\n${p.message || '等待你的确认'}\n回 y/n 或数字；发「#${tab.wxLabel} 屏幕」看画面`
                    )
                  }
                })
              }, 600)
            } else {
              // 完成：稍等 transcript 落盘，再取最近一条**完整**回复推送（长回复由后端分条发）。
              // 直接用 hook 的 p.summary 会因末段未落盘而只拿到前面的文本块。
              setTimeout(() => {
                void lastAssistant(t.projectPath, t.sessionId, WX_FULL_REPLY).then((text) => {
                  void wechatSendNotice(`${head}\n本轮完成：${text || p.summary || '（无文本）'}`)
                })
              }, 600)
            }
          }
        }
      }
      // 队列发送统一由"就绪泵"驱动（见下方 useEffect），不再在 Stop 时单独发——
      // ESC 中断不触发 Stop，靠 Stop 发会卡住；改用 PTY 静默信号更可靠。
    }).then((fn) => unlisteners.push(fn))

    // 微信入站消息（模型A：解析 #编号精确路由，否则兜底到等待最久的会话）
    void onWechatInbound((p) => {
      const cfg = configRef.current
      if (!cfg?.settings.wechatEnabled || !cfg.settings.wechatRemoteInput) return
      const tabsNow = tabsRef.current
      const pname = (path: string) =>
        configRef.current?.projects.find((pr) => pr.path === path)?.name ?? path
      // —— 目标会话解析辅助 ——
      const aliveById = (id?: string | null) =>
        id ? (tabsNow.find((t) => t.id === id && t.status !== 'exited') ?? null) : null
      const attentionFirst = () =>
        tabsNow.filter((t) => t.status === 'attention').sort((a, b) => a.statusSince - b.statusSince)[0] ?? null
      const focusedAlive = () => aliveById(focusedTabRef.current?.id)
      const lastAlive = () => tabsNow.filter((t) => t.status !== 'exited').slice(-1)[0] ?? null
      // 粘性"当前会话"：未过期且存活才有效（超时自动清除回退）
      const currentAlive = (): TerminalTab | null => {
        const c = wxCurrentRef.current
        if (!c || Date.now() - c.ts > WX_CURRENT_TTL) {
          wxCurrentRef.current = null
          return null
        }
        const t = aliveById(c.tabId)
        if (!t) wxCurrentRef.current = null
        return t
      }
      const setCurrent = (id: string) => {
        wxCurrentRef.current = { tabId: id, ts: Date.now() }
      }
      // 图片消息：暂存到当前会话（不进输入框），与下一条文字原子提交，避免图文时序错配
      if (p.imagePath) {
        const tgt = currentAlive() ?? focusedAlive() ?? attentionFirst() ?? lastAlive()
        if (!tgt) {
          void wechatSendNotice('图片已收到，但当前没有可用会话')
          return
        }
        setCurrent(tgt.id)
        const cur = wxPendingImgRef.current[tgt.id]
        const fresh = cur && Date.now() - cur.ts < WX_IMG_TTL ? cur.paths : []
        const paths = [...fresh, p.imagePath]
        wxPendingImgRef.current[tgt.id] = { paths, ts: Date.now() }
        void wechatSendNotice(
          `图片已暂存给 #${tgt.wxLabel} ${pname(tgt.projectPath)}（共 ${paths.length} 张）\n` +
            `接着发文字会和它一起提交；或「#${tgt.wxLabel} 发图」单独发、「#${tgt.wxLabel} 清图」取消`
        )
        return
      }
      const raw = p.text.trim()
      if (!raw) return
      // 帮助：发"帮助/help"等回这份指令速查（全局，不需指定会话）
      if (/^(帮助|help|指令|命令|菜单)$/i.test(raw)) {
        void wechatSendNotice(WECHAT_HELP)
        return
      }
      // 列出当前所有会话（全局，不需指定会话）
      if (/^(会话|对话|列表|sessions?|list|ls)$/i.test(raw)) {
        const STAT: Record<string, string> = {
          working: '运行中',
          attention: '待确认',
          idle: '空闲',
          exited: '已退出'
        }
        const live = tabsNow.filter((t) => t.status !== 'exited')
        if (!live.length) {
          void wechatSendNotice('当前没有打开的会话')
          return
        }
        const lines = live
          .slice()
          .sort((a, b) => a.wxLabel - b.wxLabel)
          .map((t) => `#${t.wxLabel} ${pname(t.projectPath)} · ${t.title}（${STAT[t.status] ?? t.status}）`)
        void wechatSendNotice('当前会话：\n' + lines.join('\n'))
        return
      }
      // ——— 项目 / 历史会话 / 启动（全局，不针对运行中会话）———
      const resolveProject = (token: string): ProjectEntry | null => {
        const projs = configRef.current?.projects ?? []
        const pm = token.match(/^P(\d+)$/i)
        if (pm) return projs[Number(pm[1]) - 1] ?? null
        const t = token.trim().toLowerCase()
        return (
          projs.find((pr) => pr.name.toLowerCase() === t) ??
          projs.find((pr) => pr.name.toLowerCase().includes(t)) ??
          null
        )
      }
      const parseMode = (w?: string): LaunchOptions['permissionMode'] =>
        w ? (PERM_MODE[w.toLowerCase()] ?? '') : ''
      // 项目列表
      if (/^(项目|projects?)$/i.test(raw)) {
        const projs = configRef.current?.projects ?? []
        if (!projs.length) {
          void wechatSendNotice('当前没有项目')
          return
        }
        const lines = projs.map((pr, i) => {
          const n = tabsNow.filter((t) => t.projectPath === pr.path && t.status !== 'exited').length
          return `P${i + 1} ${pr.name}（运行中 ${n}）`
        })
        void wechatSendNotice('项目列表：\n' + lines.join('\n') + '\n（发「P1 会话」看某项目会话）')
        return
      }
      // 启动历史会话：H编号 恢复 [权限模式]
      const resumeM = raw.match(/^H(\d+)\s*恢复(?:\s+(\S+))?$/i)
      if (resumeM) {
        const ent = wxHistRef.current[Number(resumeM[1])]
        if (!ent) {
          void wechatSendNotice('该 H 编号已失效，请先发「P1 会话」重新列出')
          return
        }
        const mode = parseMode(resumeM[2])
        const predicted = wxSeqRef.current
        doResume(ent.projectPath, ent.sessionId, ent.title, mode)
        void wechatSendNotice(
          `已恢复 #${predicted} ${pname(ent.projectPath)} · ${ent.title}` +
            (mode ? `（${resumeM[2]}）` : '')
        )
        return
      }
      // 新建会话：P编号/项目名 新会话 [权限模式]（仅当解析到项目时接管，否则当普通文本）
      const newM = raw.match(/^(P\d+|.+?)\s*新会话(?:\s+(\S+))?$/i)
      if (newM) {
        const proj = resolveProject(newM[1])
        if (proj) {
          const mode = parseMode(newM[2])
          const predicted = wxSeqRef.current
          newSession(proj.path, { model: '', permissionMode: mode, extraArgs: '' })
          void wechatSendNotice(
            `已在 ${proj.name} 新建会话 #${predicted}` + (mode ? `（${newM[2]}）` : '')
          )
          return
        }
      }
      // 项目会话列表：P编号/项目名 会话（仅当解析到项目时接管，否则当普通文本）
      const projSessM = raw.match(/^(P\d+|.+?)\s*会话$/i)
      if (projSessM) {
        const proj = resolveProject(projSessM[1])
        if (proj) {
          const runs = tabsNow.filter((t) => t.projectPath === proj.path && t.status !== 'exited')
          const runIds = new Set(runs.map((t) => t.sessionId))
          const STAT: Record<string, string> = {
            working: '运行中',
            attention: '待确认',
            idle: '空闲'
          }
          void listSessions(proj.path).then((all) => {
            const hist = all
              .filter((s) => !runIds.has(s.id))
              .sort((a, b) => b.mtime - a.mtime)
              .slice(0, 5)
            const map: Record<number, { projectPath: string; sessionId: string; title: string }> = {}
            const histLines = hist.map((s, i) => {
              map[i + 1] = { projectPath: proj.path, sessionId: s.id, title: s.title }
              return `H${i + 1} ${s.title}`
            })
            wxHistRef.current = map
            const runLines = runs.map((t) => `#${t.wxLabel} ${t.title}（${STAT[t.status] ?? t.status}）`)
            void wechatSendNotice(
              [
                `${proj.name} 会话：`,
                '运行中：' + (runLines.length ? '\n' + runLines.join('\n') : '（无）'),
                '历史(最近5)：' + (histLines.length ? '\n' + histLines.join('\n') : '（无）'),
                '（发「H1 恢复」或「H1 恢复 plan/自动编辑/绕过」启动）'
              ].join('\n')
            )
          })
          return
        }
      }
      // ——— 目标会话：#N 显式 / 粘性当前会话 / 兜底 ———
      const labelMatch = raw.match(/#(\d+)/)
      const cmd = raw.replace(/#\d+/, '').trim()
      // 按键类（答权限框/操作菜单）：恰好一个会话待权限时优先答它，不改当前会话
      const isKeyLike = /^(y|n|yes|no|是|否|允许|拒绝|回车|确定|确认|enter|上|下|左|右|up|down|left|right|\d{1,2})$/i.test(cmd)
      let target: TerminalTab | null = null
      let refresh = false
      if (labelMatch) {
        target = tabsNow.find((t) => t.wxLabel === Number(labelMatch[1]) && t.status !== 'exited') ?? null
        if (!target) {
          void wechatSendNotice(`#${labelMatch[1]} 不存在或已退出，发「会话」查看当前编号`)
          return
        }
        refresh = true
      } else {
        if (isKeyLike) {
          const att = tabsNow.filter((t) => t.status === 'attention')
          if (att.length === 1) target = att[0]
        }
        if (!target) {
          target = currentAlive()
          if (target) refresh = true
          else target = attentionFirst() ?? focusedAlive() ?? lastAlive()
        }
      }
      if (!target) {
        void wechatSendNotice('没有可操作的会话：请带 #编号，或先发「会话」查看')
        return
      }
      const tgt = target
      if (refresh) setCurrent(tgt.id) // 显式寻址或命中当前会话 → 设/刷新当前会话
      const label = `#${tgt.wxLabel} ${pname(tgt.projectPath)}`
      // 单独发 #编号：切换当前会话
      if (!cmd) {
        void wechatSendNotice(
          `已设为当前会话 ${label}，之后无编号的消息/图片都发它（${Math.round(WX_CURRENT_TTL / 60000)} 分钟内有效）`
        )
        return
      }
      const queueOf = () => queuesRef.current[tgt.id] ?? []
      const low = cmd.toLowerCase()
      // 看屏：抓终端底部 N 行（看清选项/菜单）
      if (/^(屏幕|看屏|screen)$/i.test(cmd)) {
        const scr = readTerminalScreen(tgt.id, WX_SCREEN_LINES)
        void wechatSendNotice(
          scr ? `${label} 终端画面：\n${scr}` : `${label} 无法获取画面（该会话未在工作区显示）`
        )
        return
      }
      // 结构化选项(AskUserQuestion 单题)：把"回数字"翻译成方向键选择
      const prompt = wxPromptRef.current[tgt.id]
      const askQ =
        prompt && prompt.kind === 'ask' && prompt.questions?.length === 1 && Date.now() - prompt.ts < WX_CURRENT_TTL
          ? prompt.questions[0]
          : null
      if (askQ && !askQ.multiSelect && /^\d{1,2}$/.test(cmd)) {
        const n = Number(cmd)
        if (n >= 1 && n <= askQ.options.length) {
          delete wxPromptRef.current[tgt.id]
          sendKeys(tgt.id, [...Array(n - 1).fill('\x1b[B'), '\r'])
          void wechatSendNotice(`${label} 已选择：${askQ.options[n - 1]}`)
          return
        }
      }
      if (askQ && askQ.multiSelect && /\d/.test(cmd) && /^[\d\s,，、]+$/.test(cmd)) {
        const nums = [
          ...new Set(cmd.split(/[\s,，、]+/).map(Number).filter((x) => x >= 1 && x <= askQ.options.length))
        ].sort((a, b) => a - b)
        if (nums.length) {
          delete wxPromptRef.current[tgt.id]
          const keys: string[] = []
          let pos = 1
          for (const n of nums) {
            while (pos < n) {
              keys.push('\x1b[B')
              pos++
            }
            keys.push(' ') // 空格切换多选
          }
          keys.push('\r')
          sendKeys(tgt.id, keys)
          void wechatSendNotice(`${label} 已选择：${nums.map((n) => askQ.options[n - 1]).join('、')}`)
          return
        }
      }
      // 按键序列：上/下/左/右/回车/空格（单个或连发，如「下下回车」），翻译成终端按键
      const keySeq = tokenizeKeys(cmd)
      if (keySeq) {
        if (prompt) delete wxPromptRef.current[tgt.id]
        sendKeys(tgt.id, keySeq)
        void wechatSendNotice(`${label} 已发送按键：${cmd}`)
        return
      }
      // y/n/是否允许拒绝，或数字选项（答权限框/普通菜单）：按键 + 回车
      const KEY: Record<string, string> = {
        y: 'y', yes: 'y', 是: 'y', 允许: 'y',
        n: 'n', no: 'n', 否: 'n', 拒绝: 'n'
      }
      if (low in KEY || /^\d{1,2}$/.test(cmd)) {
        if (prompt) delete wxPromptRef.current[tgt.id]
        writeToTerminal(tgt.id, low in KEY ? KEY[low] : cmd, true)
        void wechatSendNotice(`${label} 已发送：${cmd}`)
        return
      }
      // 单独发出暂存图片（不等文字）
      if (/^(发图|发送图片|提交图片)$/i.test(cmd)) {
        const pend = wxPendingImgRef.current[tgt.id]
        if (!pend || !pend.paths.length || Date.now() - pend.ts >= WX_IMG_TTL) {
          void wechatSendNotice(`${label} 没有待发送的图片`)
          return
        }
        const n = pend.paths.length
        delete wxPendingImgRef.current[tgt.id]
        const payload = pend.paths.map((pp) => `"${pp}"`).join(' ')
        setQueues((m) => ({ ...m, [tgt.id]: [...(m[tgt.id] ?? []), payload] }))
        void wechatSendNotice(`${label} ${n} 张图片已入队，会话就绪后发送`)
        return
      }
      // 取消暂存图片
      if (/^(清图|取消图片|清除图片)$/i.test(cmd)) {
        const had = wxPendingImgRef.current[tgt.id]?.paths.length ?? 0
        delete wxPendingImgRef.current[tgt.id]
        void wechatSendNotice(`${label} 已清除暂存图片（${had} 张）`)
        return
      }
      // —— 队列管理控制指令（先于"当作提示入队"处理）——
      if (/^(队列|\?|？|queue)$/i.test(cmd)) {
        const q = queueOf()
        const body = q.length ? q.map((t, i) => `${i + 1}. ${t}`).join('\n') : '（空）'
        void wechatSendNotice(`${label} 队列（${q.length}）：\n${body}`)
        return
      }
      if (/^(撤回|撤销|undo)$/i.test(cmd)) {
        const q = queueOf()
        if (!q.length) {
          void wechatSendNotice(`${label} 队列为空，无可撤回`)
          return
        }
        const removed = q[q.length - 1]
        setQueues((m) => ({ ...m, [tgt.id]: q.slice(0, -1) }))
        void wechatSendNotice(`${label} 已撤回：${removed}`)
        return
      }
      const delMatch = cmd.match(/^(?:删|删除|移除|del|delete)\s*(\d+)$/i)
      if (delMatch) {
        const k = Number(delMatch[1])
        const q = queueOf()
        if (k < 1 || k > q.length) {
          void wechatSendNotice(`${label} 没有第 ${k} 条`)
          return
        }
        const removed = q[k - 1]
        setQueues((m) => ({ ...m, [tgt.id]: q.filter((_, i) => i !== k - 1) }))
        void wechatSendNotice(`${label} 已删除第 ${k} 条：${removed}`)
        return
      }
      if (/^(清空|清队列|clear)$/i.test(cmd)) {
        setQueues((m) => ({ ...m, [tgt.id]: [] }))
        void wechatSendNotice(`${label} 队列已清空`)
        return
      }
      if (/^(停止|中断|stop|esc)$/i.test(cmd)) {
        void ptyWrite(tgt.id, '\x1b')
        void wechatSendNotice(`${label} 已发送中断（Esc）`)
        return
      }
      // 查看该会话最近一条完整回复（按需拉取，长回复由后端分条发）
      if (/^(输出|看|回复|output)$/i.test(cmd)) {
        void lastAssistant(tgt.projectPath, tgt.sessionId, WX_FULL_REPLY).then((text) => {
          void wechatSendNotice(`${label} 最近回复：\n${text || '（暂无文本回复）'}`)
        })
        return
      }
      // —— 其余文本：默认发给当前会话 ——
      if (!cmd) return
      // 把暂存图片前置到本条文字，合并成一条原子提交（图片始终和它配套的文字绑在同一项里，
      // 不再依赖输入框保留路径；忙时也是整条入队，回合结束一次发出，杜绝图文错配）。
      const pend = wxPendingImgRef.current[tgt.id]
      let textToSend = cmd
      let imgNote = ''
      if (pend && pend.paths.length) {
        if (Date.now() - pend.ts < WX_IMG_TTL) {
          textToSend = pend.paths.map((pp) => `"${pp}"`).join(' ') + ' ' + cmd
          imgNote = `（含 ${pend.paths.length} 张图片）`
        }
        delete wxPendingImgRef.current[tgt.id]
      }
      const q = queueOf()
      // 统一入队，由"就绪泵"在会话就绪（PTY 静默够久）时逐条发出，不再依赖 status / Stop。
      setQueues((m) => ({ ...m, [tgt.id]: [...q, textToSend] }))
      const sendingNow = q.length === 0 && readyToSend(tgt)
      void wechatSendNotice(
        sendingNow
          ? `${label} 会话空闲，正在发送${imgNote}：\n"${textToSend}"`
          : `已入队 ${label} 第 ${q.length + 1} 位${imgNote}（会话就绪后自动发）：\n"${textToSend}"\n` +
              `发错了？回复『#${tgt.wxLabel} 撤回』撤这条，或『#${tgt.wxLabel} 队列』查看`
      )
    }).then((fn) => unlisteners.push(fn))

    // 拖拽文件到窗口：把路径填入聚焦栏位的终端输入框
    void getCurrentWebviewWindow()
      .onDragDropEvent((event) => {
        if (event.payload.type !== 'drop') return
        const target = focusedTabRef.current
        if (!target || event.payload.paths.length === 0) return
        writeToTerminal(target.id, event.payload.paths.map((p) => `"${p}"`).join(' '), false)
      })
      .then((fn) => unlisteners.push(fn))

    return () => {
      disposed = true
      unlisteners.forEach((fn) => fn())
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  // 统一队列泵：会话"就绪"时发出队首，并对已发出的指令做"提交确认 + 补回车"闭环。
  // 现象：正文已落入输入框但回车被 TUI 吞掉（刚 Stop/工具间隙未回输入态、括号粘贴未 flush、
  // 斜杠补全菜单等），指令滞留输入框不提交、却被当成已发出。这里发出后不立即认定成功：
  // 普通文本提交成功会触发 UserPromptSubmit→status='working'（前后台通用强信号），
  // 已上过屏的会话还能读屏确认输入框已清空；确认窗口内仍未确认且会话静默非 attention 时
  // 补一个回车（对已提交的空输入框补回车无害），多次仍未确认且读屏确认正文仍滞留才回报微信失败。
  useEffect(() => {
    const tick = () => {
      const cfg = configRef.current
      const pname = (path: string) => cfg?.projects.find((pr) => pr.path === path)?.name ?? path
      for (const tab of tabsRef.current) {
        const pend = pendingSendRef.current[tab.id]
        if (pend) {
          // 读屏（仅已上过屏的会话非空）：判断输入框底部是否仍残留刚发正文
          const screen = readTerminalScreen(tab.id, 6)
          const stillThere = screen ? screen.includes(pend.firstLine) : null
          // 确认提交：进入工作态，或读屏确认输入框已不含正文
          if (tab.status === 'working' || stillThere === false) {
            delete pendingSendRef.current[tab.id]
            continue
          }
          // 给确认留一个窗口（覆盖 UserPromptSubmit 回传往返），避免刚发就误判
          if (Date.now() - pend.sentAt < 700) continue
          // 待确认（权限弹窗）不补回车以免误确认；会话未静默也先等其回到输入态
          if (tab.status === 'attention' || !readyToSend(tab)) continue
          if (pend.retries >= 2) {
            delete pendingSendRef.current[tab.id]
            // 读屏明确正文仍卡在输入框才回报；读不到屏（后台未渲染会话）不误报
            if (stillThere === true) {
              void wechatSendNotice(
                `#${tab.wxLabel} ${pname(tab.projectPath)} 指令疑似未发出，正文仍卡在输入框，请手动回车或检查会话`
              )
            }
            continue
          }
          // 补一个回车，尝试提交滞留在输入框的正文（不抢焦点，避免打断用户当前操作）
          void ptyWrite(tab.id, '\r')
          pendingSendRef.current[tab.id] = { ...pend, sentAt: Date.now(), retries: pend.retries + 1 }
          continue
        }
        // 无在途发送：就绪则取队首发出，登记为在途待确认（不再假设一次写入必定提交）
        const q = queuesRef.current[tab.id]
        if (!q || !q.length || !readyToSend(tab)) continue
        const [next, ...rest] = q
        setQueues((m) => ({ ...m, [tab.id]: rest }))
        writeToTerminal(tab.id, next, true)
        const firstLine = (next.split('\n').find((l) => l.trim()) ?? next).trim().slice(0, 16)
        pendingSendRef.current[tab.id] = { firstLine, sentAt: Date.now(), retries: 0 }
      }
    }
    const timer = setInterval(tick, 600)
    return () => clearInterval(timer)
  }, [readyToSend])

  // ---------- 当前视图派生状态 ----------
  const currentProject = view.kind === 'project' ? view.path : null
  const section: Section = currentProject ? (sections[currentProject] ?? 'home') : 'home'
  const projectTabs = tabs.filter((t) => t.projectPath === currentProject)
  const showTerminals = !!currentProject && section === 'term'
  const page = pages[activePage] ?? pages[pages.length - 1]
  const visiblePanes = page.panes
  const focusedTab = tabs.find((t) => t.id === visiblePanes[page.focused]) ?? null
  focusedTabRef.current = showTerminals ? focusedTab : null

  // 上屏的标签清除未读
  useEffect(() => {
    if (!showTerminals) return
    const visibleIds = new Set(visiblePanes.filter(Boolean))
    if (tabs.some((t) => visibleIds.has(t.id) && t.unread)) {
      setTabs((all) => all.map((t) => (visibleIds.has(t.id) ? { ...t, unread: false } : t)))
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [showTerminals, pages, activePage, tabs])

  // 覆盖名（titleOverrides）变化时热更新运行中标签的显示名：
  // 新增/修改覆盖名立即生效，清空则回落到会话记录的自动标题（autoTitle）
  useEffect(() => {
    setTabs((all) => {
      let changed = false
      const next = all.map((t) => {
        const eff = effectiveTitle(t.autoTitle, t.sessionId, config)
        if (eff === t.title) return t
        changed = true
        return { ...t, title: eff }
      })
      return changed ? next : all
    })
  }, [config])

  if (!config) {
    return <div className="app" />
  }

  const setSection = (s: Section) => {
    if (!currentProject) return
    setSections((prev) => ({ ...prev, [currentProject]: s }))
  }

  /** 拖动行分隔条：调整相邻两行的高度，终端尺寸由 ResizeObserver 自动适配 */
  const startRowDrag = (row: number) => (e: ReactMouseEvent) => {
    e.preventDefault()
    const area = termAreaRef.current
    if (!area) return
    const onMove = (ev: MouseEvent) => {
      const rect = area.getBoundingClientRect()
      const p = ((ev.clientY - rect.top) / rect.height) * 100
      updatePage((pg) => {
        const rows = [...pg.rowRatios]
        const topEdge = sumTo(rows, row - 1)
        const span = rows[row - 1] + rows[row]
        const y = Math.min(topEdge + span - 15, Math.max(topEdge + 15, p))
        rows[row - 1] = y - topEdge
        rows[row] = span - rows[row - 1]
        return { ...pg, rowRatios: rows }
      })
    }
    const onUp = () => {
      window.removeEventListener('mousemove', onMove)
      window.removeEventListener('mouseup', onUp)
    }
    window.addEventListener('mousemove', onMove)
    window.addEventListener('mouseup', onUp)
  }

  /** 拖动列分隔条：调整某行内相邻两栏的宽度 */
  const startColDrag = (row: number, col: number) => (e: ReactMouseEvent) => {
    e.preventDefault()
    const area = termAreaRef.current
    if (!area) return
    const onMove = (ev: MouseEvent) => {
      const rect = area.getBoundingClientRect()
      const p = ((ev.clientX - rect.left) / rect.width) * 100
      updatePage((pg) => {
        const cols = [...pg.colRatios[row]]
        const leftEdge = sumTo(cols, col - 1)
        const span = cols[col - 1] + cols[col]
        const x = Math.min(leftEdge + span - 12, Math.max(leftEdge + 12, p))
        cols[col - 1] = x - leftEdge
        cols[col] = span - cols[col - 1]
        const colRatios = [...pg.colRatios]
        colRatios[row] = cols
        return { ...pg, colRatios }
      })
    }
    const onUp = () => {
      window.removeEventListener('mousemove', onMove)
      window.removeEventListener('mouseup', onUp)
    }
    window.addEventListener('mousemove', onMove)
    window.addEventListener('mouseup', onUp)
  }

  /** 分隔条元素：行间横条 + 各行内竖条 */
  const dividerEls: ReactNode[] = []
  if (showTerminals) {
    const rows = rowsOf(visiblePanes.length, page.layout)
    for (let r = 0; r < rows.length; r++) {
      const top = sumTo(page.rowRatios, r)
      if (r > 0) {
        dividerEls.push(
          <div
            key={`row-${r}`}
            className="split-divider horizontal"
            style={{ top: `calc(${pct(top)} - 3px)` }}
            onMouseDown={startRowDrag(r)}
          />
        )
      }
      for (let c = 1; c < rows[r]; c++) {
        dividerEls.push(
          <div
            key={`col-${r}-${c}`}
            className="split-divider"
            style={{
              left: `calc(${pct(sumTo(page.colRatios[r], c))} - 3px)`,
              top: pct(top),
              height: pct(page.rowRatios[r]),
              bottom: 'auto'
            }}
            onMouseDown={startColDrag(r, c)}
          />
        )
      }
    }
  }

  const projectName = (path: string) => config.projects.find((p) => p.path === path)?.name ?? path

  /** 跳转到某个标签：切项目、进工作区；已上屏则切到所在分页并聚焦，否则放入当前页聚焦栏 */
  const jumpToTab = (tab: TerminalTab) => {
    setView({ kind: 'project', path: tab.projectPath })
    setSections((prev) => ({ ...prev, [tab.projectPath]: 'term' }))
    for (let pi = 0; pi < pages.length; pi++) {
      const idx = pages[pi].panes.indexOf(tab.id)
      if (idx >= 0) {
        setActivePage(pi)
        focusPane(idx, pi)
        return
      }
    }
    assignPane(page.focused, tab.id)
  }

  const attentionTabs = tabs.filter((t) => t.status === 'attention')

  /** 侧边栏点击项目：有活跃会话则直接进入工作区并聚焦（当前页已上屏的优先，其次其他分页），否则进入会话管理页 */
  const selectProject = (path: string) => {
    setView({ kind: 'project', path })
    const active = tabs.filter((t) => t.projectPath === path && t.status !== 'exited')
    if (active.length === 0) {
      // 无运行中会话时工作区里没有该项目的内容，停留在 term 区块会显得"没切换"；规则/预览区块不动
      if ((sections[path] ?? 'home') === 'term') {
        setSections((prev) => ({ ...prev, [path]: 'home' }))
      }
      return
    }
    setSections((prev) => ({ ...prev, [path]: 'term' }))
    const onCurrent = active.find((t) => visiblePanes.includes(t.id))
    if (onCurrent) {
      focusPane(visiblePanes.indexOf(onCurrent.id))
      return
    }
    for (let pi = 0; pi < pages.length; pi++) {
      const hit = active.find((t) => pages[pi].panes.includes(t.id))
      if (hit) {
        setActivePage(pi)
        focusPane(pages[pi].panes.indexOf(hit.id), pi)
        return
      }
    }
    assignPane(page.focused, active[0].id)
  }

  /** 文件树单击：打开/切换预览（有未保存修改时先确认） */
  const openPreview = (path: string) => {
    if (!currentProject) return
    const current = previewFiles[currentProject]
    if (current === path) {
      setSection('preview')
      return
    }
    if (previewDirtyRef.current && !window.confirm('当前预览文件有未保存的修改，放弃并切换？')) {
      return
    }
    setPreviewDirty(false)
    setPreviewFiles((p) => ({ ...p, [currentProject]: path }))
    setSection('preview')
  }

  /** 关闭预览标签：回到终端（有标签上屏时）或会话列表 */
  const closePreview = () => {
    if (!currentProject) return
    if (previewDirtyRef.current && !window.confirm('预览文件有未保存的修改，放弃并关闭？')) {
      return
    }
    setPreviewDirty(false)
    setPreviewFiles((p) => ({ ...p, [currentProject]: null }))
    const hasOnScreen = pages.some((pg) => pg.panes.some((id) => id !== null))
    setSection(hasOnScreen ? 'term' : 'home')
  }

  return (
    <div className="app">
      <Sidebar
        config={config}
        view={view}
        tabs={tabs}
        onSelectProject={selectProject}
        onAddProject={async (path) => {
          setConfig(await addProject(path))
          setView({ kind: 'project', path })
        }}
        onRemoveProject={(path) => setRemovingProject(path)}
        onNavigate={setView}
        attentionCount={attentionTabs.length}
        onOpenInbox={() => setInboxOpen(true)}
        onPreviewFile={openPreview}
        onInsertPath={
          showTerminals && focusedTab
            ? (text) => {
                const target = focusedTabRef.current
                if (target) writeToTerminal(target.id, text, false)
              }
            : null
        }
      />
      <div className="main">
        {currentProject && (
          <div className="tabbar">
            <div
              className={`tab ${section === 'home' ? 'active' : ''}`}
              onClick={() => setSection('home')}
            >
              <span className="tab-title">会话</span>
            </div>
            <div
              className={`tab ${section === 'rules' ? 'active' : ''}`}
              onClick={() => setSection('rules')}
            >
              <span className="tab-title">项目规则</span>
            </div>
            {currentProject && previewFiles[currentProject] && (
              <div
                className={`tab ${section === 'preview' ? 'active' : ''}`}
                title={previewFiles[currentProject]!}
                onClick={() => setSection('preview')}
              >
                <span className="tab-title">
                  {previewDirty ? '● ' : ''}
                  {previewFiles[currentProject]!.split('\\').pop()}
                </span>
                <button
                  className="tab-x"
                  title="关闭预览"
                  onClick={(e) => {
                    e.stopPropagation()
                    closePreview()
                  }}
                >
                  <IconClose width={11} height={11} />
                </button>
              </div>
            )}
            {projectTabs.map((tab) => {
              const pageIdx = pages.findIndex((pg) => pg.panes.includes(tab.id))
              const onScreen = pageIdx === activePage
              const isFocused = section === 'term' && visiblePanes[page.focused] === tab.id
              return (
                <div
                  key={tab.id}
                  className={`tab ${isFocused ? 'active' : ''}`}
                  title={`${tab.title}${
                    pageIdx < 0 ? '' : onScreen ? '（已上屏）' : `（第 ${pageIdx + 1} 页）`
                  }`}
                  onContextMenu={(e) => {
                    e.preventDefault()
                    setTabMenu({ tab, x: e.clientX, y: e.clientY })
                  }}
                  onClick={() => {
                    setSection('term')
                    // 已上屏的标签点击 = 切到所在分页并聚焦其栏位；未上屏的放入当前页聚焦栏
                    if (pageIdx >= 0) {
                      setActivePage(pageIdx)
                      focusPane(pages[pageIdx].panes.indexOf(tab.id), pageIdx)
                    } else {
                      assignPane(page.focused, tab.id)
                    }
                  }}
                >
                  <span className={`dot ${tab.unread && !onScreen ? 'unread' : tab.status}`} />
                  <span className="tab-title">{tab.title}</span>
                  <button
                    className="tab-x"
                    title="关闭标签"
                    onClick={(e) => {
                      e.stopPropagation()
                      if (tab.status === 'exited') closeTab(tab)
                      else setClosingTab(tab)
                    }}
                  >
                    <IconClose width={11} height={11} />
                  </button>
                </div>
              )
            })}
            <button className="btn ghost small" title="新建会话" onClick={() => setNewSessionOpen(true)}>
              <IconPlus />
            </button>
            <div style={{ flex: 1 }} />
            {section === 'term' && (
              <>
                {pages.length > 1 &&
                  pages.map((_, pi) => (
                    <button
                      key={pi}
                      className={`btn ghost small ${pi === activePage ? 'primary' : ''}`}
                      title={`切换到第 ${pi + 1} 页`}
                      onClick={() => setActivePage(pi)}
                    >
                      {pi + 1}
                    </button>
                  ))}
                <button className="btn ghost small" title="新建分页" onClick={addPage}>
                  <IconPlus />页
                </button>
                {pages.length > 1 && (
                  <button
                    className="btn ghost small"
                    title="关闭当前分页（页内会话保持后台运行）"
                    onClick={() => closePage(activePage)}
                  >
                    <IconClose width={11} height={11} />页
                  </button>
                )}
                {visiblePanes.length > 2 && (
                  <>
                    <button
                      className={`btn ghost small ${page.layout === 'grid' ? 'primary' : ''}`}
                      title="宫格排布（自动网格）"
                      onClick={() => setPaneLayout('grid')}
                    >
                      宫格
                    </button>
                    <button
                      className={`btn ghost small ${page.layout === 'columns' ? 'primary' : ''}`}
                      title="并行排布（全部并排成一行）"
                      onClick={() => setPaneLayout('columns')}
                    >
                      并行
                    </button>
                  </>
                )}
                <button
                  className="btn ghost small"
                  title={
                    visiblePanes.length >= MAX_PANES
                      ? `本页已满（${MAX_PANES} 栏），请新建分页`
                      : `添加分栏（本页 ${visiblePanes.length}/${MAX_PANES}）`
                  }
                  disabled={visiblePanes.length >= MAX_PANES}
                  onClick={addPane}
                >
                  <IconSplit />
                </button>
                <button
                  className="btn ghost small"
                  title="显示/隐藏指令面板"
                  onClick={() => setDrawerOpen((v) => !v)}
                >
                  <IconList />
                </button>
              </>
            )}
          </div>
        )}

        <div className="content">
          {view.kind === 'commands' && <CommandsPage config={config} onConfigChange={setConfig} />}
          {view.kind === 'rules' && <RulesPage config={config} onConfigChange={setConfig} />}
          {view.kind === 'settings' && <SettingsPage config={config} onConfigChange={setConfig} />}
          {view.kind === 'search' && <SearchPage config={config} onResume={resumeSession} />}
          {view.kind === 'mcp' && <McpPage config={config} />}
          {view.kind === 'tunnels' && <TunnelsPage config={config} onNavigate={setView} />}
          {view.kind === 'apiInject' && <ApiInjectPage />}
          {view.kind === 'agents' && <AgentsPage />}
          {currentProject && section === 'home' && (
            <SessionList
              projectPath={currentProject}
              config={config}
              onConfigChange={setConfig}
              onNewSession={() => setNewSessionOpen(true)}
              onResume={(id, title) => resumeSession(currentProject, id, title)}
            />
          )}
          {currentProject && section === 'rules' && (
            <ProjectRulesView projectPath={currentProject} config={config} />
          )}
          {currentProject && section === 'preview' && previewFiles[currentProject] && (
            <FilePreview
              path={previewFiles[currentProject]!}
              dark={dark}
              onDirtyChange={setPreviewDirty}
            />
          )}

          {/* 终端工作区常驻：所有标签的 xterm 实例保活，仅切换可见性；栏位可放任意项目的标签 */}
          <div
            ref={termAreaRef}
            className={`terminal-area ${visiblePanes.length > 1 ? 'multi' : ''}`}
            style={{ display: showTerminals ? 'flex' : 'none', position: 'relative' }}
          >
            {tabs.map((tab) => {
              const paneIdx = showTerminals ? visiblePanes.indexOf(tab.id) : -1
              const geom = paneIdx >= 0 ? paneGeometry(page, paneIdx) : null
              return (
                <TerminalView
                  key={tab.id}
                  tab={tab}
                  paneStyle={geom ? hostStyle(geom) : null}
                  focused={paneIdx >= 0 && paneIdx === page.focused}
                  onFocus={() => focusPane(paneIdx)}
                  dark={dark}
                />
              )
            })}

            {/* 每个栏位的栏头：状态点 + 项目名 + 会话标题 + 会话选择器（跨项目） */}
            {showTerminals &&
              visiblePanes.map((tabId, idx) => {
                const geom = paneGeometry(page, idx)
                if (!geom) return null
                const tab = tabs.find((t) => t.id === tabId) ?? null
                return (
                  <div key={idx}>
                    <div
                      className={`pane-header ${idx === page.focused ? 'focused' : ''}`}
                      style={headerStyle(geom)}
                      onMouseDown={() => focusPane(idx)}
                      title={tab ? `${projectName(tab.projectPath)} · ${tab.title}` : '空栏'}
                    >
                      {tab && <span className={`dot ${tab.status}`} />}
                      {tab && <span className="pane-proj">{projectName(tab.projectPath)}</span>}
                      <span className={`pane-title ${tab ? '' : 'empty'}`}>
                        {tab ? tab.title : '空栏'}
                      </span>
                      {tab && tab.model && (
                        <span className="pane-model" title={`当前模型：${tab.model}`}>
                          {tab.model.replace(/^claude-/, '')}
                        </span>
                      )}
                      {tab && tab.thinking && (
                        <span className="pane-think" title="最近一回合使用了扩展思考（Alt+T 切换）">
                          思考
                        </span>
                      )}
                      {tab && (
                        <button
                          className="btn ghost small icon-only"
                          title="模型与参数"
                          onMouseDown={(e) => e.stopPropagation()}
                          onClick={(e) => {
                            const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
                            setParamMenu({ tab, x: Math.max(8, rect.right - 230), y: rect.bottom + 3 })
                            // 三态数据源：官方=官方实时清单；直连=当前接口清单；路由=本地聚合清单
                            setModelsError('')
                            void (async () => {
                              try {
                                const s = await apiInjectStatus()
                                setApiMode(s.mode)
                                // 缓存命中：官方/直连可缓存；路由清单是本地聚合（零成本），
                                // 且路由表可能刚在 API 注入页被编辑，每次重取
                                if (s.mode !== 'router' && models && modelsFor === s.mode) return
                                const list =
                                  s.mode === 'official'
                                    ? await listModels()
                                    : await apiInjectActiveModels()
                                setModels(list)
                                setModelsFor(s.mode)
                              } catch (err) {
                                setModelsError(String(err))
                              }
                            })()
                          }}
                        >
                          <IconGear width={12} height={12} />
                        </button>
                      )}
                      <select
                        className="select pane-select"
                        value={tab ? tab.id : ''}
                        onMouseDown={(e) => e.stopPropagation()}
                        onChange={(e) => {
                          assignPane(idx, e.target.value || null)
                          focusPane(idx)
                        }}
                      >
                        <option value="">（空栏）</option>
                        {config.projects.map((p) => {
                          const pts = tabs.filter((t) => t.projectPath === p.path)
                          if (pts.length === 0) return null
                          return (
                            <optgroup key={p.path} label={p.name}>
                              {pts.map((t) => (
                                <option key={t.id} value={t.id}>
                                  {t.title}
                                </option>
                              ))}
                            </optgroup>
                          )
                        })}
                      </select>
                      {visiblePanes.length > 1 && (
                        <button
                          className="btn ghost small icon-only"
                          title="关闭此栏"
                          onMouseDown={(e) => e.stopPropagation()}
                          onClick={(e) => {
                            // 空栏没有终端可关，直接移除栏位；有会话则弹出两种关闭方式
                            if (!tab) {
                              removePane(idx)
                              return
                            }
                            const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
                            setCloseMenu({ idx, x: Math.max(8, rect.right - 220), y: rect.bottom + 3 })
                          }}
                        >
                          <IconClose width={11} height={11} />
                        </button>
                      )}
                    </div>
                    {!tab && (
                      <div
                        className="pane-empty"
                        style={hostStyle(geom)}
                        onMouseDown={() => focusPane(idx)}
                      >
                        <div className="pane-empty-hint">
                          空栏：在栏头下拉框中选择一个会话，或点击标签栏中的会话
                        </div>
                      </div>
                    )}
                  </div>
                )
              })}

            {/* 分隔条：行间横条 + 各行内竖条 */}
            {dividerEls}
          </div>

          {showTerminals && drawerOpen && focusedTab && (
            <CommandDrawer
              config={config}
              projectPath={focusedTab.projectPath}
              tabId={focusedTab.id}
              onConfigChange={setConfig}
              queue={queues[focusedTab.id] ?? []}
              onQueueChange={(next) =>
                setQueues((q) => ({ ...q, [focusedTab.id]: next }))
              }
            />
          )}
        </div>
      </div>

      {paramMenu && (
        <>
          <div className="ctx-backdrop" onMouseDown={() => setParamMenu(null)} />
          <div className="ctx-menu" style={{ left: paramMenu.x, top: paramMenu.y, minWidth: 230 }}>
            <div className="ctx-title">
              {apiMode === 'router'
                ? '切换模型（路由清单）'
                : apiMode === 'direct'
                  ? '切换模型（当前接口清单）'
                  : '切换模型（官方实时清单）'}
            </div>
            {(!models || modelsFor !== apiMode) && !modelsError && (
              <div className="ctx-item disabled">加载中…</div>
            )}
            {modelsError && (
              <div className="ctx-item disabled" title={modelsError}>
                清单获取失败，可用下方选择器
              </div>
            )}
            {modelsFor === apiMode &&
              models?.map((m) => (
              <div
                key={m.id}
                className="ctx-item"
                onClick={() => {
                  const tabId = paramMenu.tab.id
                  writeToTerminal(tabId, `/model ${m.id}`, true)
                  // 乐观更新栏头徽标，无需等回合结束；下一个 Stop 会用 transcript 校正
                  setTabs((all) => all.map((t) => (t.id === tabId ? { ...t, model: m.id } : t)))
                  setParamMenu(null)
                }}
              >
                {m.displayName}
                {paramMenu.tab.model === m.id && <span className="ctx-current"> 当前</span>}
              </div>
            ))}
            <div className="ctx-sep" />
            <div className="ctx-title">参数（CLI 原生界面，永不过时）</div>
            <div
              className="ctx-item"
              onClick={() => {
                writeToTerminal(paramMenu.tab.id, '/model', true)
                setParamMenu(null)
              }}
            >
              模型选择器（含 1M/别名变体）…
            </div>
            <div
              className="ctx-item"
              onClick={() => {
                writeToTerminal(paramMenu.tab.id, '/effort', true)
                setParamMenu(null)
              }}
            >
              力度调节（low~max）…
            </div>
            <div
              className="ctx-item"
              onClick={() => {
                writeToTerminal(paramMenu.tab.id, '/fast', true)
                setParamMenu(null)
              }}
            >
              Fast 模式开/关（仅 Opus）
            </div>
            <div
              className="ctx-item"
              onClick={() => {
                // Thinking 没有斜杠命令，向 PTY 发送 Alt+T 按键序列
                void ptyWrite(paramMenu.tab.id, '\x1bt')
                setParamMenu(null)
              }}
            >
              Thinking 开/关（Alt+T）
            </div>
          </div>
        </>
      )}

      {closeMenu && (
        <>
          <div className="ctx-backdrop" onMouseDown={() => setCloseMenu(null)} />
          <div className="ctx-menu" style={{ left: closeMenu.x, top: closeMenu.y, minWidth: 220 }}>
            <div
              className="ctx-item"
              onClick={() => {
                removePane(closeMenu.idx)
                setCloseMenu(null)
              }}
            >
              关闭分栏<span className="ctx-hint"> 会话后台运行</span>
            </div>
            <div
              className="ctx-item danger"
              onClick={() => {
                removePaneAndTerminal(closeMenu.idx)
                setCloseMenu(null)
              }}
            >
              关闭分栏及终端<span className="ctx-hint"> 终止 claude 进程</span>
            </div>
          </div>
        </>
      )}

      {tabMenu && (
        <>
          <div
            className="ctx-backdrop"
            onMouseDown={() => setTabMenu(null)}
            onContextMenu={(e) => e.preventDefault()}
          />
          <div className="ctx-menu" style={{ left: tabMenu.x, top: tabMenu.y, minWidth: 160 }}>
            <div
              className="ctx-item"
              onClick={() => {
                setRenameTabText(tabMenu.tab.title)
                setRenamingTab(tabMenu.tab)
                setTabMenu(null)
              }}
            >
              重命名
            </div>
            <div
              className="ctx-item danger"
              onClick={() => {
                const t = tabMenu.tab
                setTabMenu(null)
                if (t.status === 'exited') closeTab(t)
                else setClosingTab(t)
              }}
            >
              关闭标签<span className="ctx-hint"> 运行中将终止进程</span>
            </div>
          </div>
        </>
      )}

      {renamingTab && (
        <Modal
          title="重命名会话"
          onClose={() => setRenamingTab(null)}
          footer={
            <>
              <button className="btn" onClick={() => setRenamingTab(null)}>
                取消
              </button>
              <button
                className="btn primary"
                onClick={() => {
                  // 写入 titleOverrides 并刷新 config，标题同步 effect 会热更新标签卡显示名
                  void renameSession(renamingTab.sessionId, renameTabText).then((c) => {
                    setConfig(c)
                    setRenamingTab(null)
                  })
                }}
              >
                保存
              </button>
            </>
          }
        >
          <p className="page-desc" style={{ marginTop: 0 }}>
            重命名仅影响 GUI 显示，不修改会话记录文件。清空则恢复原始标题。
          </p>
          <input
            className="input"
            style={{ width: '100%' }}
            value={renameTabText}
            autoFocus
            onChange={(e) => setRenameTabText(e.target.value)}
          />
        </Modal>
      )}

      {inboxOpen && (
        <InboxDialog
          tabs={attentionTabs}
          projectName={projectName}
          onJump={(tab) => {
            jumpToTab(tab)
            setInboxOpen(false)
          }}
          onClose={() => setInboxOpen(false)}
        />
      )}

      {newSessionOpen && currentProject && (
        <NewSessionDialog
          onClose={() => setNewSessionOpen(false)}
          onLaunch={(opts) => {
            setNewSessionOpen(false)
            newSession(currentProject, opts)
          }}
        />
      )}

      {resumingSession && (
        <ResumeSessionDialog
          title={resumingSession.title}
          onClose={() => setResumingSession(null)}
          onResume={(permissionMode) => {
            const { projectPath, sessionId, title } = resumingSession
            setResumingSession(null)
            doResume(projectPath, sessionId, title, permissionMode)
          }}
        />
      )}

      {closingTab && (
        <ConfirmDialog
          title="关闭终端标签"
          message={`会话「${closingTab.title}」（${projectName(closingTab.projectPath)}）仍在运行，关闭将终止 claude 进程。\n会话记录已保存，之后可以从历史会话恢复。`}
          confirmText="关闭"
          danger
          onCancel={() => setClosingTab(null)}
          onConfirm={() => {
            closeTab(closingTab)
            setClosingTab(null)
          }}
        />
      )}

      {removingProject && (
        <ConfirmDialog
          title="移除项目"
          message={`将「${removingProject}」从项目列表移除。\n不会删除项目目录和会话记录；该项目打开的终端会被关闭。`}
          confirmText="移除"
          danger
          onCancel={() => setRemovingProject(null)}
          onConfirm={async () => {
            const path = removingProject
            for (const tab of tabsRef.current.filter((t) => t.projectPath === path)) {
              closeTab(tab)
            }
            const c = await removeProject(path)
            setConfig(c)
            setRemovingProject(null)
            if (viewRef.current.kind === 'project' && viewRef.current.path === path) {
              setView(
                c.projects.length > 0 ? { kind: 'project', path: c.projects[0].path } : { kind: 'search' }
              )
            }
          }}
        />
      )}
    </div>
  )
}
