/** * pi-tui-pro —— TUI 界面优化扩展 * * 解决的 5 个问题: * 1. 用户消息背景色不够醒目 → 调整 userMessageBg 颜色 * 2. 思考挡位在输入框上方且常隐藏 → 固定显示在右下角模型名旁 * 3. 右下角时间被 token 统计挤成省略号 → 时间固定在行尾,永不截断 * 4. token 消耗看不到缓存命中 → 增加缓存读(R)/缓存写(W)/命中率(CH) * 5. 思考块/工具输出只能全展开全折叠 → 鼠标点击单个块切换折叠/展开 * * 点击折叠实现(v4,彻底绕开组件树行号计算): * - 每个可折叠块的"可点行"(折叠标签行 / 展开块首行)内嵌一段不可见的 * OSC 标记 `\x1b]777;pi;fold;\x07`(终端不显示、不占宽)。 * - 点击时直接扫描点击行的渲染内容(scrollContentLines)里的标记, * 向上回溯最多 80 行(覆盖展开块的内容区),命中标记即切换对应块。 * - 完全不做组件树行号累计 → 免疫行偏移 / 组件记录丢失等一切旧问题。 */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { appendFileSync } from "node:fs"; import { Markdown as FallbackMarkdown, // 仅作兜底(拿不到真实类时用) TuiAltScreen as ImportedTuiAltScreen, // 鼠标补丁兜底目标(单载时即主进程类) truncateToWidth, visibleWidth, type TUI, type Theme, } from "@earendil-works/pi-tui"; // ============================================================ // 诊断日志(调试用,PI_TUI_PRO_DEBUG=1 时开启;默认关闭) // ============================================================ const DEBUG_LOG = "C:/Users/Administrator/.pi/agent/pi-tui-pro-debug.log"; function dbg(...args: any[]) { if (process.env.PI_TUI_PRO_DEBUG !== "1") return; try { appendFileSync( DEBUG_LOG, `${new Date().toISOString()} ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n` ); } catch {} } // ============================================================ // 第一部分:右下角状态栏(需求 2/3/4) // 布局:[目录/分支] [↑输入 ↓输出 R缓存读 W缓存写 CH命中率 上下文 花费 时长] [模型 • 挡位] HH:MM // 截断优先级:时间/模型/挡位永不截断 > 目录 > token 统计 // ============================================================ /** token 数量格式化:1234 → 1.2k,12345 → 12k */ function fmtTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; return `${Math.round(count / 1000000)}M`; } /** 会话时长格式化:5s / 3m25s / 1h12m */ function fmtDuration(ms: number): string { const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m${s % 60}s`; return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`; } class ProFooter { private tui: TUI; private theme: Theme; private ctx: any; // ExtensionContext private footerData: any; // ReadonlyFooterDataProvider private sessionStart = Date.now(); private timer?: ReturnType; constructor(tui: TUI, theme: Theme, footerData: any, ctx: any) { this.tui = tui; this.theme = theme; this.footerData = footerData; this.ctx = ctx; this.timer = setInterval(() => this.tui.requestRender(), 5000); } dispose() { if (this.timer) clearInterval(this.timer); this.timer = undefined; } invalidate() {} /** 累加本次会话所有条目的 token 消耗(含缓存读写,需求4) */ private collectUsage(): { totals: any; hitRate?: number } { const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; let hitRate: number | undefined; try { for (const entry of this.ctx.sessionManager.getEntries()) { if (entry.type === "message" && entry.message.role === "assistant") { const u = entry.message.usage; if (!u) continue; totals.input += u.input; totals.output += u.output; totals.cacheRead += u.cacheRead; totals.cacheWrite += u.cacheWrite; totals.cost += u.cost?.total ?? 0; const promptTokens = u.input + u.cacheRead + u.cacheWrite; hitRate = promptTokens > 0 ? (u.cacheRead / promptTokens) * 100 : hitRate; } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) { const u = entry.message.usage; totals.input += u.input; totals.output += u.output; totals.cacheRead += u.cacheRead; totals.cacheWrite += u.cacheWrite; totals.cost += u.cost?.total ?? 0; } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { const u = entry.usage; totals.input += u.input; totals.output += u.output; totals.cacheRead += u.cacheRead; totals.cacheWrite += u.cacheWrite; totals.cost += u.cost?.total ?? 0; } } } catch { // 统计失败不阻塞渲染 } return { totals, hitRate }; } render(width: number): string[] { const model: any = this.ctx.model; // ---- 右侧固定段:模型名 + 思考挡位 + 时间(永不截断,需求2/3) ---- let right = model?.name || model?.id || "no-model"; if (model?.reasoning) { const lv: string = this.ctx.thinkingLevel || "off"; const colorToken = "thinking" + lv.charAt(0).toUpperCase() + lv.slice(1); let colored: string; try { colored = this.theme.fg(colorToken, lv); } catch { colored = this.theme.fg("muted", lv); } right += " " + this.theme.fg("dim", "•") + " " + colored; } const now = new Date(); const hhmm = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; right += " " + hhmm; // ---- 中间弹性段:token 统计(需求4,缓存命中在此显示) ---- const { totals, hitRate } = this.collectUsage(); const stats: string[] = []; if (totals.input) stats.push(`↑${fmtTokens(totals.input)}`); if (totals.output) stats.push(`↓${fmtTokens(totals.output)}`); if (totals.cacheRead) stats.push(`R${fmtTokens(totals.cacheRead)}`); if (totals.cacheWrite) stats.push(`W${fmtTokens(totals.cacheWrite)}`); if ((totals.cacheRead > 0 || totals.cacheWrite > 0) && hitRate !== undefined) { stats.push(`CH${hitRate.toFixed(1)}%`); } try { const cu: any = this.ctx.getContextUsage(); if (cu) { const pct: number | null = cu.percent ?? null; const total: number = cu.contextWindow ?? 0; const used: number | null = cu.tokens ?? null; const text = used !== null && pct !== null ? `${fmtTokens(used)}/${fmtTokens(total)} ${pct.toFixed(1)}%` : `?/${fmtTokens(total)}`; stats.push( (pct ?? 0) > 90 ? this.theme.fg("error", text) : (pct ?? 0) > 70 ? this.theme.fg("warning", text) : this.theme.fg("success", text) ); } } catch {} let usingSub = false; try { usingSub = model ? !!this.ctx.modelRegistry.isUsingOAuth(model) : false; } catch {} if (totals.cost > 0 || usingSub) { stats.push(`$${totals.cost.toFixed(3)}${usingSub ? " (sub)" : ""}`); } stats.push(fmtDuration(Date.now() - this.sessionStart)); try { const statuses = this.footerData.getExtensionStatuses?.() ?? new Map(); if (statuses.size > 0) { for (const [, text] of statuses) { if (text) stats.push(text); } } } catch {} const statsStr = stats.join(" "); const statsW = visibleWidth(statsStr); // ---- 左侧段:目录 + git 分支 + 会话名(可截断) ---- let left = this.ctx.cwd; const home = process.env.HOME || process.env.USERPROFILE || ""; if (home && left.startsWith(home)) left = "~" + left.slice(home.length); try { const branch = this.footerData.getGitBranch?.(); if (branch) left += ` (${branch})`; const sname = this.ctx.sessionManager.getSessionName(); if (sname) left += ` • ${sname}`; } catch {} // ---- 布局:right 永不截断,left 先截断,再截 stats ---- const rightW = visibleWidth(right); const minPad = 2; const minGap = 1; const avail = width - rightW - minPad; let line: string; const leftW = visibleWidth(left); if (leftW + minGap + statsW <= avail) { line = left + " ".repeat(avail - leftW - statsW) + statsStr + " ".repeat(minPad) + right; } else if (statsW + minGap <= avail) { const tLeft = truncateToWidth(left, avail - statsW - minGap, ""); line = tLeft + " ".repeat(avail - visibleWidth(tLeft) - statsW) + statsStr + " ".repeat(minPad) + right; } else { const keepLeft = Math.max(0, Math.min(leftW, Math.floor(avail / 3))); const tLeft = keepLeft > 0 ? truncateToWidth(left, keepLeft, "") : ""; const tLeftW = visibleWidth(tLeft); const tStats = truncateToWidth(statsStr, Math.max(0, avail - tLeftW - minGap), "…"); line = tLeft + " ".repeat(avail - tLeftW - visibleWidth(tStats)) + tStats + " ".repeat(minPad) + right; } return [this.theme.fg("dim", line)]; } } // ============================================================ // 第二部分:用户消息背景色(需求1) // ============================================================ function hexToBgAnsi(hex: string): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `\x1b[48;2;${r};${g};${b}m`; } function hexTo256(hex: string): number { const r = Math.round((parseInt(hex.slice(1, 3), 16) / 255) * 5); const g = Math.round((parseInt(hex.slice(3, 5), 16) / 255) * 5); const b = Math.round((parseInt(hex.slice(5, 7), 16) / 255) * 5); return 16 + 36 * r + 6 * g + b; } /** * 让用户消息背景更醒目但不刺眼。 * ctx.ui.theme 返回的是 Proxy,instanceof 判定失败,所以用 * getTheme(名字) 拿新鲜真实例改完再 setTheme。 */ function applyUserMessageBg(ctx: any) { try { const cur: any = ctx.ui.theme; if (!cur) return; const name = String(cur?.name || "").toLowerCase(); const isLight = name.includes("light"); const hex = isLight ? "#c3cbdc" : "#4b5470"; const ansi = cur.mode === "truecolor" ? hexToBgAnsi(hex) : `\x1b[48;5;${hexTo256(hex)}m`; let t: any; try { t = typeof ctx.ui.getTheme === "function" ? ctx.ui.getTheme(cur?.name) : undefined; } catch {} if (!t?.bgColors?.set) t = cur; t.bgColors.set("userMessageBg", ansi); const r = ctx.ui.setTheme(t); if (r && !r.success) { try { cur.bgColors?.set("userMessageBg", ansi); } catch {} } } catch { // 主题调整失败不影响其他功能 } } // ============================================================ // 第三部分:鼠标点击折叠单个思考块/工具输出(需求5)—— v4 标记方案 // // 每个可折叠块分配一个唯一数字 ID。块的"可点行"(折叠标签行、 // 展开块首行)渲染时内嵌 OSC 标记 `\x1b]777;pi;fold;\x07`。 // 点击时:在点击行往上最多 80 行内扫描标记 → 命中 ID → toggle。 // 工具块的可点行是"标题行"(toolTitle 色),展开块内容区向上 // 回溯即可命中首行标记。 // ============================================================ /** 标记格式与匹配正则 */ const MARKER_PREFIX = "\x1b]777;pi;fold;"; const MARKER_RE = /\x1b\]777;pi;fold;(\d+)\x07/g; /** 唯一 ID → 折叠目标 */ let nextFoldId = 1; const foldTargets = new Map(); /** 工具块实例 → ID(防泄漏) */ const toolIds = new WeakMap(); /** 思考块单块折叠状态:message 对象 → (run序号 → 是否折叠) */ const thinkingOverrides = new WeakMap>(); /** 工具块单块展开状态:组件实例 → 是否展开 */ const toolOverrides = new WeakMap(); /** 每个 message 上次渲染时的全局 hide 值(检测全局切换→重建场景) */ const lastGlobalHide = new WeakMap(); /** 类名字符串(跨模块稳定);优先用 getPrototypeOf 穿透 Proxy 包装 */ const nameOf = (c: any): string => (Object.getPrototypeOf(c)?.constructor?.name ?? c?.constructor?.name ?? "") || ""; /** 拿对象的真实类(穿透 pi 的 createInteractiveTuiReference Proxy) */ const realClassOf = (c: any): any => Object.getPrototypeOf(c)?.constructor ?? c?.constructor; /** 已打过补丁的类 */ const patchedClasses = new WeakSet(); /** 从组件树收集到的真实类 */ const realClasses: Record = {}; /** 主题获取器(session_start 时注入) */ const passthroughTheme = { fg: (_c: any, s: any) => s, bg: (_c: any, s: any) => s, italic: (s: any) => s, bold: (s: any) => s, underline: (s: any) => s, inverse: (s: any) => s, }; let currentThemeGetter: () => any = () => passthroughTheme; /** 折叠标签文案 */ function foldLabelText(label: string): string { return `💭 ${label} · 点击展开`; } /** 把 message 内容切成内容块列表(text / thinking run),规则与原渲染逻辑一致;字段全部防御 */ function contentBlocks(message: any): Array<{ type: "text" } | { type: "thinking"; runIdx: number }> { const blocks: Array<{ type: "text" } | { type: "thinking"; runIdx: number }> = []; let runIdx = 0; try { const content = Array.isArray(message?.content) ? message.content : []; for (let i = 0; i < content.length; i++) { const c = content[i]; if (c?.type === "text" && typeof c.text === "string" && c.text.trim()) { blocks.push({ type: "text" }); } else if (c?.type === "thinking") { const run: string[] = []; for (; i < content.length; i++) { const tc = content[i]; if (tc?.type !== "thinking") break; if (typeof tc.thinking === "string" && tc.thinking.trim()) run.push(tc.thinking.trim()); } i--; if (run.length > 0) { blocks.push({ type: "thinking", runIdx }); runIdx++; } } } } catch { // 消息结构异常时返回空列表(折叠功能降级,不影响渲染) } return blocks; } /** 取某个 thinking run 的合并文本(字段防御) */ function thinkingRunText(message: any, targetRunIdx: number): string { let runIdx = 0; try { const content = Array.isArray(message?.content) ? message.content : []; for (let i = 0; i < content.length; i++) { const c = content[i]; if (c?.type !== "thinking") continue; const run: string[] = []; for (; i < content.length; i++) { const tc = content[i]; if (tc?.type !== "thinking") break; if (typeof tc.thinking === "string" && tc.thinking.trim()) run.push(tc.thinking.trim()); } i--; if (runIdx === targetRunIdx) return run.join("\n\n"); runIdx++; } } catch { // 结构异常返回空 } return ""; } /** 替换容器里的某个子组件 */ function replaceChild(container: any, oldComp: any, newComp: any) { const arr = container.children; const i = arr.indexOf(oldComp); if (i >= 0) arr[i] = newComp; } /** 分配/复用某块(message+runIdx)的 ID */ function ensureFoldId(comp: any, runIdx: number, message: any): number { const map = (comp.__foldIds ??= new Map()); let id = map.get(runIdx); if (!id) { id = nextFoldId++; map.set(runIdx, id); } foldTargets.set(id, { kind: "thinking", comp, runIdx, message }); return id; } /** * 给指定的(真实)AssistantMessageComponent 类打补丁: * 渲染后按单块覆盖状态定向替换(折叠↔展开),并在可点行内嵌 OSC 标记。 */ function patchAssistantMessageOn(cls: any, getTheme: () => Theme) { if (!cls?.prototype?.updateContent) { dbg("patchAssistantMessageOn: no prototype.updateContent"); return; } if (patchedClasses.has(cls)) return; patchedClasses.add(cls); dbg("patchAssistantMessageOn: patched", nameOf(cls.prototype)); const proto = cls.prototype; const orig = proto.updateContent; // 全局切换思考展示时清除单块状态: // 注意 WeakMap 没有 clear(),不能直接调;改为依赖 updateContent 里的 // lastGlobalHide 检测(重建/更新的组件发现全局值变化后自动清 override) const origSetHide = proto.setHideThinkingBlock; if (origSetHide) { proto.setHideThinkingBlock = function (hide: boolean) { dbg("setHideThinkingBlock called", hide); return origSetHide.call(this, hide); }; } proto.updateContent = function (message: any, isStreaming?: boolean) { // 原渲染也包 try/catch:万一 pi 内部渲染遇到异常消息结构抛错, // 我们直接吞掉并跳过自己的逻辑,绝不让异常逃出(会导致整个进程退出) try { orig.call(this, message, isStreaming); } catch (e) { dbg("orig updateContent threw", String(e)); return; } if (!message?.content) return; // 注意:以下所有后处理都包在 try/catch 里——pi 的输入处理对异常 // 没有保护,任何抛错都会直接导致整个 pi 进程退出(比如 ctrl+t 重建消息时)。 // 后处理失败只影响折叠标记,绝不能让 pi 崩掉。 try { // 全局折叠设置变化时(全局 toggle 后重建组件),清除该消息的单块状态 const prevHide = lastGlobalHide.get(message); if (prevHide !== undefined && prevHide !== this.hideThinkingBlock) { thinkingOverrides.delete(message); dbg("global hide changed, cleared override for message"); } lastGlobalHide.set(message, this.hideThinkingBlock); const blocks = contentBlocks(message); const kids: any[] = (this.contentContainer?.children ?? []).filter((c: any) => nameOf(c) !== "Spacer"); const overrides = thinkingOverrides.get(message); this.piThinkingRuns = []; for (let bi = 0; bi < blocks.length; bi++) { const block = blocks[bi]; const comp = kids[bi]; if (!comp) break; if (block.type !== "thinking") continue; const collapsed = overrides?.get(block.runIdx) ?? this.hideThinkingBlock; const isLabel = nameOf(comp) === "Text"; // 折叠态是 Text 标签,展开态是 Markdown const id = ensureFoldId(this, block.runIdx, message); const marker = `${MARKER_PREFIX}${id}\x07`; let finalComp = comp; if (collapsed && !isLabel) { // 全局展开但该块手动折叠 → 换成折叠标签(标签行带标记,可点击展开) const TextCls = realClasses.text ?? comp.constructor; finalComp = new TextCls( getTheme().italic(getTheme().fg("thinkingText", marker + foldLabelText(this.hiddenThinkingLabel))), this.outputPad, 0 ); replaceChild(this.contentContainer, comp, finalComp); } else if (!collapsed && isLabel) { // 全局折叠但该块手动展开 → 换成完整内容(首行带标记,内容区点击可向上回溯) const MdCls = realClasses.markdown ?? FallbackMarkdown; finalComp = new MdCls( marker + "\n" + thinkingRunText(message, block.runIdx), this.outputPad, 0, this.markdownTheme, { color: (t: string) => getTheme().fg("thinkingText", t), italic: true }, undefined ); replaceChild(this.contentContainer, comp, finalComp); } else if (collapsed && isLabel) { // 保持折叠,更新提示文案(保留标记) try { comp.setText(getTheme().italic(getTheme().fg("thinkingText", marker + foldLabelText(this.hiddenThinkingLabel)))); } catch {} } this.piThinkingRuns.push({ runIdx: block.runIdx, component: finalComp }); } } catch (e) { dbg("updateContent post-processing threw", String(e)); } }; } /** * 给指定的(真实)ToolExecutionComponent 类打补丁: * 全局"全部展开/折叠"会遍历调用 setExpanded,此时清除单块覆盖状态。 */ function patchToolExecutionOn(cls: any) { if (!cls?.prototype?.setExpanded) { dbg("patchToolExecutionOn: no prototype.setExpanded"); return; } if (patchedClasses.has(cls)) return; patchedClasses.add(cls); dbg("patchToolExecutionOn: patched"); const proto = cls.prototype; const orig = proto.setExpanded; proto.setExpanded = function (expanded: boolean) { toolOverrides.delete(this); return orig.call(this, expanded); }; } /** * 从主滚动区的组件树里发现真实组件类并打补丁。 * 发现新类时对所有消息组件补跑 updateContent(让标记/文案立即就位)。 */ function ensurePatched(tui: any, getTheme: () => Theme = currentThemeGetter) { const sv = tui.getPrimaryScrollView?.(); if (!sv?.child) { dbg("ensurePatched: no primary scrollview/child"); return; } let newly = false; let found = ""; const visit = (comp: any): void => { for (const child of comp?.children ?? []) { try { const n = nameOf(child); if (n === "AssistantMessageComponent") { found += "A"; if (!patchedClasses.has(child.constructor)) { patchAssistantMessageOn(child.constructor, getTheme); newly = true; } } else if (n === "ToolExecutionComponent") { found += "T"; if (!patchedClasses.has(child.constructor)) { patchToolExecutionOn(child.constructor); newly = true; } } else if (n === "Markdown" && !realClasses.markdown) { realClasses.markdown = child.constructor; } else if (n === "Text" && !realClasses.text) { realClasses.text = child.constructor; } if (child.children) visit(child); } catch (e) { dbg("ensurePatched visit threw", String(e)); } } }; visit(sv.child); dbg("ensurePatched: tree scanned", { found: found.slice(0, 200), newly }); if (newly) { dbg("ensurePatched: newly patched classes, refreshing messages"); const refresh = (comp: any): void => { for (const child of comp?.children ?? []) { if (nameOf(child) === "AssistantMessageComponent" && child.lastMessage) { try { child.updateContent(child.lastMessage); } catch (e) { dbg("refresh message failed", String(e)); } } if (child.children) refresh(child); } }; refresh(sv.child); } } /** 在 layout 树里找到某个 ScrollView 的渲染盒(含 rect / scrollContentLines) */ function findScrollBox(box: any, sv: any): any { if (!box) return undefined; if (box.scrollView === sv) return box; for (const c of box.children ?? []) { const hit = findScrollBox(c, sv); if (hit) return hit; } return undefined; } /** * 工具块行号注册表(点击工具块任意行 → 命中工具块)。 * 遍历策略:普通容器递归(找到嵌套的工具组件),Box 类组件直接取 * render 行数(内部宽度自己处理,避免内边距导致的行号偏移)。 */ function buildToolRegistry(tui: any, sv: any, width: number): Array { const out: Array = []; let y = 0; const walk = (comp: any, offset: number): number => { for (const child of comp?.children ?? []) { try { const n = nameOf(child); if (n === "ToolExecutionComponent") { const rows = child.render(width).length; let id = toolIds.get(child); if (!id) { id = nextFoldId++; toolIds.set(child, id); foldTargets.set(id, { kind: "tool", comp: child }); } out.push({ id, start: offset, end: offset + rows, comp: child }); offset += rows; } else if (n === "ScrollView") { // 嵌套滚动区:视口行数,不递归内容 offset += child.render(width).length; } else if (Array.isArray(child.children) && typeof child.paddingX !== "number") { // 普通容器:递归找嵌套的工具组件 offset = walk(child, offset); } else { // 叶子 / Box(有内边距):直接取 render 行数,避免递归导致行号偏移 offset += child.render(width).length; } } catch (e) { // 单个组件 render 失败不影响注册表构建 dbg("walk child render threw", nameOf(child), String(e)); } } return offset; }; walk(sv.child, 0); return out; } /** 切换某个折叠目标 */ function toggleTarget(t: any, tui: any) { if (t.kind === "thinking") { const comp = t.comp; if (comp.isStreaming || !comp.lastMessage) return; // 流式输出中不折叠 let map = thinkingOverrides.get(t.message); if (!map) { map = new Map(); thinkingOverrides.set(t.message, map); } const cur = map.get(t.runIdx) ?? comp.hideThinkingBlock; map.set(t.runIdx, !cur); dbg("toggle thinking", { runIdx: t.runIdx, cur, next: !cur }); try { comp.updateContent(t.message); } catch (e) { dbg("toggle thinking updateContent threw", String(e)); } } else if (t.kind === "tool") { const comp = t.comp; if (comp.isPartial) return; // 工具执行中不折叠 const cur = toolOverrides.get(comp) ?? comp.expanded; toolOverrides.set(comp, !cur); comp.expanded = !cur; try { comp.updateDisplay(); } catch {} } tui.requestRender?.(); } /** 从内容行里提取标记 ID(可能同一行多个标记,取第一个) */ function extractMarkerId(line: string): number | undefined { if (!line) return undefined; MARKER_RE.lastIndex = 0; const m = MARKER_RE.exec(line); return m ? Number(m[1]) : undefined; } /** 点击坐标 → 折叠目标(返回 undefined 表示没点中) */ function findFoldTarget(tui: any, x: number, y: number) { // 确保真实类已打补丁(懒发现 + 兜底补跑) try { ensurePatched(tui); } catch {} const sv = tui.getPrimaryScrollView?.(); if (!sv) { dbg("findFoldTarget: no primary scrollview"); return undefined; } const box = findScrollBox(tui.currentLayout?.root, sv); if (!box) { dbg("findFoldTarget: no layout box"); return undefined; } const r = box.rect; if (x < r.x || x >= r.x + r.width || y < r.y || y >= r.y + r.height) { dbg("findFoldTarget: outside rect", { x, y, rect: r }); return undefined; } if (sv.currentScrollbar !== "hidden" && x >= r.x + r.width - 2) { dbg("findFoldTarget: scrollbar zone"); return undefined; } const row = sv.scrollTop + (y - r.y); const contentLines: string[] = box.scrollContentLines ?? []; // 1) 当前点击行本身带标记(折叠标签 / 展开块首行)→ 直接命中 const curId = extractMarkerId(contentLines[row]); if (curId !== undefined) { const t = foldTargets.get(curId); if (t) { dbg("findFoldTarget: row marker HIT", { row, id: curId, kind: t.kind }); return t; } } // 2) 工具块行号注册表(点击工具块任意行 → 命中工具块,不被上方标记截胡) const w = sv.getContentWidth(tui.terminal.columns); for (const e of buildToolRegistry(tui, sv, w)) { if (row >= e.start && row < e.end) { dbg("findFoldTarget: tool registry HIT", { row, start: e.start, end: e.end }); return foldTargets.get(e.id); } } // 3) 向上回溯最多 80 行找标记(展开块内容区点击 → 命中块首行标记) for (let i = row - 1; i >= Math.max(0, row - 80); i--) { const id = extractMarkerId(contentLines[i]); if (id !== undefined) { const t = foldTargets.get(id); if (t) { dbg("findFoldTarget: marker backscan HIT", { row, at: i, id, kind: t.kind }); return t; } } } dbg("findFoldTarget: no target at row", row); return undefined; } /** * 给指定的(真实)TuiAltScreen 类打补丁,拦截鼠标单击: * 按下→释放同点(未拖动)时,判断点击位置是否落在某个折叠块上。 */ function patchMouseOn(cls: any) { if (!cls?.prototype?.handleViewportInput) { dbg("patchMouseOn: no prototype.handleViewportInput"); return; } if (patchedClasses.has(cls)) return; patchedClasses.add(cls); dbg("patchMouseOn: patched", nameOf(cls.prototype)); const proto = cls.prototype; const orig = proto.handleViewportInput; proto.handleViewportInput = function (data: string) { // 整段包 try/catch:任何意外都不能让 pi 的输入处理崩溃(否则整个进程退出) try { const mouse = this.parseSgrMouseEvent?.(data); const isMotion = mouse && (mouse.button & 32) !== 0; const isLeft = mouse && !isMotion && ((mouse.button & 3) === 0 || (mouse.release && (mouse.button & 3) === 3)); if (isLeft) { if (!mouse.release) { this.__piFoldPress = { x: mouse.x, y: mouse.y }; } else if (this.__piFoldPress) { const p = this.__piFoldPress; this.__piFoldPress = undefined; if (p.x === mouse.x && p.y === mouse.y && !this.hasOverlay?.()) { try { const hit = findFoldTarget(this, mouse.x, mouse.y); if (hit) { // 清理选择状态残留 try { this.selectionAnchor = undefined; this.selectionFocus = undefined; this.selectionPressActive = false; this.selectionDragged = false; } catch {} toggleTarget(hit, this); dbg("fold toggled", hit.kind); return { consume: true }; } } catch (e) { dbg("click handling threw", String(e)); } } } } } catch (e) { dbg("viewport input patch threw", String(e)); } return orig.call(this, data); }; } // ============================================================ // 扩展入口 // ============================================================ /** 当前会话的 TUI 引用 */ let activeTui: any; /** 事件监听只注册一次 */ let eventsWired = false; // 具名导出仅供测试使用(pi 只认 default 导出,不影响运行) export { ProFooter, applyUserMessageBg, toggleTarget, findFoldTarget, ensurePatched, patchAssistantMessageOn, patchToolExecutionOn, patchMouseOn, }; export default function (pi: ExtensionAPI) { dbg("extension module loaded, version 4.0"); pi.on("session_start", (_event, ctx: any) => { if (!ctx.hasUI || ctx.mode !== "tui") { dbg("session_start skipped", { hasUI: ctx.hasUI, mode: ctx.mode }); return; } dbg("session_start entered"); currentThemeGetter = () => ctx.ui.theme ?? passthroughTheme; // 需求1:用户消息背景色 applyUserMessageBg(ctx); // 需求2/3/4:替换右下角状态栏 ctx.ui.setFooter((tui: any, theme: Theme, footerData: any) => { activeTui = tui; dbg("footer factory invoked, tui class =", nameOf(tui)); try { patchMouseOn(realClassOf(tui)); } catch (e) { dbg("patchMouseOn(realClassOf) failed", String(e)); } try { return new ProFooter(tui, theme, footerData, ctx); } catch (e) { dbg("ProFooter construct failed", String(e)); return { render: () => [""], invalidate() {}, dispose() {} }; } }); try { patchMouseOn(ImportedTuiAltScreen); } catch (e) { dbg("patchMouseOn(ImportedTuiAltScreen) failed", String(e)); } if (activeTui) { try { patchMouseOn(realClassOf(activeTui)); } catch {} } dbg("setFooter done"); // 若从已有会话恢复,立即发现真实类并打补丁 if (activeTui) { try { ensurePatched(activeTui); } catch (e) { dbg("ensurePatched at session_start failed", String(e)); } } if (!eventsWired) { eventsWired = true; pi.on("thinking_level_select", () => activeTui?.requestRender()); pi.on("model_select", () => activeTui?.requestRender()); pi.on("message_update", () => { if (!activeTui) return; try { ensurePatched(activeTui); } catch {} }); } }); }