import type { Theme } from "@earendil-works/pi-coding-agent"; import { type Component, type Focusable, matchesKey, type TUI, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import { buildTree, type ChannelInfo, channelSummary, filterByRange, type GroupDim, hasCacheWrite, type Range, type Row, type SortKey, sumRecords, } from "./aggregate.ts"; import { formatCost, formatPercent, formatRange, formatRate, formatTokens } from "./format.ts"; import { buildCatalogSnapshot, loadPriceBook, PRICE_SOURCE_LABELS, type PriceBook, type PriceSource, removeManualRate, saveManualRate, syncCatalogSnapshot, } from "./pricing.ts"; import type { UsageRecord } from "./scan.ts"; const RANGES: Range[] = ["today", "7d", "30d", "all"]; const GROUPS: GroupDim[] = ["project", "model"]; const GROUP_LABELS: Record = { project: "项目", model: "模型" }; const SORT_KEYS: SortKey[] = ["cost", "input", "output", "cacheRead", "hitRate"]; const SORT_LABELS: Record = { cost: "金额", input: "input", output: "output", cacheRead: "cacheRead", hitRate: "命中率", }; /** 焦点区。上下键在区域之间移动,左右键在区域内的选项之间移动 */ type FocusArea = "time" | "group" | "sort" | "table"; const AREAS: FocusArea[] = ["time", "group", "sort", "table"]; type View = "usage" | "pricing"; const SHORT_SOURCE_LABELS: Record = { manual: "手工", "manual-wildcard": "通配", catalog: "目录", borrowed: "借用", }; const NUM_COL = 10; const RATE_COL = 8; const COST_COL = 10; const MIN_NAME = 18; const FALLBACK_ROWS = 30; /** 单价输入的字符上限:够表达任何合理单价,又不会把右侧列挤出可视区 */ const MAX_RATE_INPUT = 12; interface FlatRow { row: Row; depth: number; } const RATE_FIELDS = ["input", "output", "cacheRead", "cacheWrite"] as const; interface EditState { channel: string; /** 四个单价的输入文本,顺序同 RATE_FIELDS */ fields: string[]; index: number; /** 原渠道带阶梯:手工价整条替换,保存后阶梯会失效,需要在界面上明说 */ hadTiers: boolean; error?: string; } interface ColumnPlan { cacheWrite: boolean; cacheRead: boolean; hitRate: boolean; nameW: number; } export class TokenUsePanel implements Component, Focusable { focused = false; private readonly tui: TUI | null; private readonly records: UsageRecord[]; private readonly cwd: string; private readonly theme: Theme; private readonly done: () => void; /** 定价配置的落盘位置;留空则用 pi 的默认 agent 目录 */ private readonly agentDir: string | undefined; private book: PriceBook; private range: Range; private view: View = "usage"; private groupBy: GroupDim = "project"; private sortKey: SortKey = "cost"; private focus: FocusArea = "table"; private readonly expanded = new Set(); private cursor = 0; private scroll = 0; private flat: FlatRow[] = []; /** 定价页用:全部历史用到过的渠道 */ private channels: ChannelInfo[] = []; /** 用量页底部说明用:仅当前时间窗内的渠道 */ private scopedChannels: ChannelInfo[] = []; private editing: EditState | null = null; /** 保存/删除失败时的提示,操作成功后清空 */ private notice = ""; // 不用构造函数参数属性:Node 的 strip-only 类型擦除不支持该语法 constructor( tui: TUI | null, records: UsageRecord[], cwd: string, theme: Theme, book: PriceBook, done: () => void, initialRange: Range = "all", agentDir?: string, ) { this.tui = tui; this.records = records; this.cwd = cwd; this.theme = theme; this.book = book; this.done = done; this.agentDir = agentDir; this.range = initialRange; this.refreshChannels(); this.rebuild(); this.syncCatalog(); } /** 把官方价写进配置文件的 catalog 段,让文件里能直接看到并对照修改 */ private syncCatalog(): void { try { const channels = this.channels.map((c) => ({ provider: c.provider, model: c.model })); syncCatalogSnapshot(buildCatalogSnapshot(channels, this.book), this.agentDir); } catch (err) { // 同步失败不该挡住看用量,提示出来即可 this.notice = err instanceof Error ? err.message : String(err); } } /** 重新读取配置文件:面板开着时外部改了文件也能同步过来 */ private reload(): void { try { this.book = loadPriceBook(this.agentDir); this.refreshChannels(); this.notice = ""; this.rebuild(); } catch (err) { this.notice = err instanceof Error ? err.message : String(err); } } private scoped(): UsageRecord[] { return filterByRange(this.records, this.range); } /** 底部说明占几行:借价提示、无价提示、配置错误提示 */ private noticeRows(): number { let n = 0; if (this.book.errors.length > 0) n++; const borrowed = this.scopedChannels.filter((c) => c.source === "borrowed"); const unpriced = this.scopedChannels.filter((c) => c.source === null && c.tokens > 0); if (borrowed.length > 0) n++; if (unpriced.length > 0) n++; return n; } /** 表格视口行数:撑满终端剩余高度,面板才能盖住背后的对话内容 */ private viewportRows(): number { const rows = this.tui?.terminal?.rows ?? FALLBACK_ROWS; // usage: 边框2 标题1 筛选3 分隔2 表头1 合计1 键位1 = 11 // pricing: 边框2 标题1 分隔2 表头1 路径1 键位1 = 8(无时间筛选行) const chrome = this.view === "usage" ? 11 : 8; const notices = this.view === "usage" ? this.noticeRows() : this.pricingNoticeRows(); // 不设下限:矮终端下宁可一行数据都不显示,也不能让面板高过屏幕 return Math.max(0, rows - chrome - notices); } private pricingNoticeRows(): number { return (this.book.errors.length > 0 ? 1 : 0) + (this.pricingNotice() ? 1 : 0); } /** 保存/删除失败的完整原因。放底部而非来源列——那里只有 22 列,长消息会被截断 */ private pricingNotice(): string { return this.editing?.error ?? this.notice; } /** * 全量渠道只依赖 records 与 book,与时间窗/分组/排序无关, * 因此只在定价变动时重算,不跟着每次展开折叠走。 */ private refreshChannels(): void { // 定价是渠道的固有属性,与时间无关:这里用全部历史记录,不受时间窗影响 this.channels = channelSummary(this.records, this.book); } private rebuild(): void { const scoped = this.scoped(); this.scopedChannels = channelSummary(scoped, this.book); this.flat = []; this.flatten(buildTree(scoped, this.groupBy, this.sortKey, this.book), 0); const len = this.view === "usage" ? this.flat.length : this.channels.length; this.cursor = Math.min(this.cursor, Math.max(0, len - 1)); this.clampScroll(); } private flatten(rows: Row[], depth: number): void { for (const row of rows) { this.flat.push({ row, depth }); if (this.expanded.has(row.key) && row.children?.length) { this.flatten(row.children, depth + 1); } } } private rowCount(): number { return this.view === "usage" ? this.flat.length : this.channels.length; } private clampScroll(): void { const visible = this.viewportRows(); if (this.cursor < this.scroll) this.scroll = this.cursor; if (this.cursor >= this.scroll + visible) this.scroll = this.cursor - visible + 1; this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, this.rowCount() - visible))); } private moveArea(delta: number): void { const i = AREAS.indexOf(this.focus); this.focus = AREAS[Math.max(0, Math.min(AREAS.length - 1, i + delta))]!; } /** 在当前焦点区的选项间移动;表格区返回 false 交给展开/折叠处理 */ private cycleOption(delta: number): boolean { if (this.focus === "time") { const i = RANGES.findIndex((r) => r === this.range); // 命令行传入的自定义区间不在预设里,此时从头开始 this.range = RANGES[(Math.max(0, i) + delta + RANGES.length) % RANGES.length]!; this.cursor = 0; this.scroll = 0; this.rebuild(); return true; } if (this.focus === "group") { const i = GROUPS.indexOf(this.groupBy); this.groupBy = GROUPS[(i + delta + GROUPS.length) % GROUPS.length]!; // 维度换了,旧的展开路径不再有意义 this.expanded.clear(); this.cursor = 0; this.scroll = 0; this.rebuild(); return true; } if (this.focus === "sort") { const i = SORT_KEYS.indexOf(this.sortKey); this.sortKey = SORT_KEYS[(i + delta + SORT_KEYS.length) % SORT_KEYS.length]!; this.rebuild(); return true; } return false; } /** 编辑态吃掉所有按键,包括 Esc——此时 Esc 是取消编辑,不是关闭面板 */ private handleEditInput(state: EditState, data: string): void { if (matchesKey(data, "escape")) { this.editing = null; return; } if (matchesKey(data, "return")) { this.commitEdit(state); return; } if (matchesKey(data, "tab") || matchesKey(data, "right")) { state.index = (state.index + 1) % RATE_FIELDS.length; return; } if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) { state.index = (state.index - 1 + RATE_FIELDS.length) % RATE_FIELDS.length; return; } if (matchesKey(data, "backspace")) { state.fields[state.index] = state.fields[state.index]!.slice(0, -1); return; } // 只接受数字与小数点,且一个字段最多一个小数点;其余按键直接忽略。 // 长度也要封顶,否则输入会撑爆列宽把右侧几列挤出可视区 const current = state.fields[state.index]!; if (current.length >= MAX_RATE_INPUT) return; if (data.length === 1 && (/[0-9]/.test(data) || (data === "." && !current.includes(".")))) { state.fields[state.index] += data; } } private commitEdit(state: EditState): void { const values = state.fields.map((f) => (f === "" ? 0 : Number(f))); if (values.some((v) => !Number.isFinite(v) || v < 0)) { state.error = "单价必须是非负数字"; return; } try { saveManualRate( state.channel, { input: values[0]!, output: values[1]!, cacheRead: values[2]!, cacheWrite: values[3]!, }, this.agentDir, ); this.book = loadPriceBook(this.agentDir); this.refreshChannels(); this.editing = null; this.notice = ""; this.rebuild(); } catch (err) { state.error = err instanceof Error ? err.message : String(err); } } private startEdit(): void { const info = this.channels[this.cursor]; if (!info) return; this.editing = { channel: info.channel, // 预填当前生效值,改中转价时只需覆盖其中几个 fields: info.rate ? [info.rate.input, info.rate.output, info.rate.cacheRead, info.rate.cacheWrite].map(String) : ["0", "0", "0", "0"], index: 0, hadTiers: info.hasTiers, }; } private clearManualRate(): void { const info = this.channels[this.cursor]; if (!info || info.source !== "manual") return; try { removeManualRate(info.channel, this.agentDir); this.book = loadPriceBook(this.agentDir); this.refreshChannels(); this.notice = ""; this.rebuild(); } catch (err) { this.notice = err instanceof Error ? err.message : String(err); } } handleInput(data: string): void { if (this.editing) { this.handleEditInput(this.editing, data); return; } if (matchesKey(data, "escape")) { this.done(); return; } if (this.view === "pricing" && this.focus === "table") { if (data === "e") { this.startEdit(); return; } if (data === "d") { this.clearManualRate(); return; } if (data === "r") { this.reload(); return; } } if (data === "p") { this.view = this.view === "usage" ? "pricing" : "usage"; // 定价视图没有分组/排序概念,焦点只在时间与列表之间 this.focus = "table"; this.cursor = 0; this.scroll = 0; this.clampScroll(); return; } // 定价页没有可聚焦的筛选行,焦点必须始终留在表格上, // 否则 e/d/r 会因 focus !== "table" 而静默失效,界面上却看不出原因 if (matchesKey(data, "up")) { if (this.focus === "table" && this.cursor > 0) { this.cursor--; this.clampScroll(); } else if (this.view === "usage") { this.moveArea(-1); } return; } if (matchesKey(data, "down")) { if (this.focus === "table") { this.cursor = Math.min(this.rowCount() - 1, this.cursor + 1); this.clampScroll(); } else if (this.view === "usage") { this.moveArea(1); } return; } if (matchesKey(data, "right")) { if (this.cycleOption(1)) return; if (this.view !== "usage") return; const current = this.flat[this.cursor]?.row; if (current?.children?.length && !this.expanded.has(current.key)) { this.expanded.add(current.key); this.rebuild(); } return; } if (matchesKey(data, "left")) { if (this.cycleOption(-1)) return; if (this.view !== "usage") return; const current = this.flat[this.cursor]?.row; if (current && this.expanded.has(current.key)) { this.expanded.delete(current.key); this.rebuild(); } else if (this.cursor > 0) { // 已折叠时上跳到父行,符合树形导航直觉 const depth = this.flat[this.cursor]?.depth ?? 0; for (let i = this.cursor - 1; i >= 0; i--) { if ((this.flat[i]?.depth ?? 0) < depth) { this.cursor = i; break; } } this.clampScroll(); } return; } if (matchesKey(data, "return")) { if (this.focus !== "table") { this.focus = "table"; return; } if (this.view !== "usage") return; const current = this.flat[this.cursor]?.row; if (current?.children?.length) { if (this.expanded.has(current.key)) this.expanded.delete(current.key); else this.expanded.add(current.key); this.rebuild(); } return; } // 快捷键:不必先把焦点移上去也能切换 if (this.view === "usage" && matchesKey(data, "tab")) { this.focus = "group"; this.cycleOption(1); return; } if (this.view === "usage" && data === "t") { this.focus = "time"; this.cycleOption(1); return; } if (this.view === "usage" && data === "s") { this.focus = "sort"; this.cycleOption(1); } } /** 宽度不足时依次砍 cacheWrite → cacheRead → 命中率;名称列和金额列永不砍 */ private planColumns(innerW: number, showCacheWrite: boolean): ColumnPlan { const plan: ColumnPlan = { cacheWrite: showCacheWrite, cacheRead: true, hitRate: true, nameW: 0 }; const used = () => 1 + NUM_COL * 2 + (plan.cacheRead ? NUM_COL : 0) + (plan.cacheWrite ? NUM_COL : 0) + (plan.hitRate ? RATE_COL : 0) + COST_COL; for (const drop of ["cacheWrite", "cacheRead", "hitRate"] as const) { if (innerW - used() >= MIN_NAME) break; plan[drop] = false; } plan.nameW = Math.max(MIN_NAME, innerW - used()); return plan; } render(width: number): string[] { const th = this.theme; const w = Math.max(64, Math.min(width || 100, 200)); const innerW = w - 2; const lines: string[] = []; const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s))); const row = (content: string) => th.fg("border", "│") + pad(truncateToWidth(content, innerW), innerW) + th.fg("border", "│"); const rule = (l: string, r: string) => th.fg("border", `${l}${"─".repeat(innerW)}${r}`); lines.push(rule("╭", "╮")); const tab = (v: View, label: string) => this.view === v ? this.selectedChip(label, false) : th.fg("dim", ` ${label} `); lines.push( row( ` ${th.fg("accent", "pi token 用量")} ${tab("usage", "用量")}${tab("pricing", "定价")} ${th.fg("dim", "(p 切换)")}`, ), ); if (this.view === "usage") this.renderUsage(lines, innerW, row, rule); else this.renderPricing(lines, innerW, row, rule); lines.push(rule("╰", "╯")); return lines; } private renderUsage( lines: string[], innerW: number, row: (s: string) => string, rule: (l: string, r: string) => string, ): void { const th = this.theme; const scoped = this.scoped(); const plan = this.planColumns(innerW, hasCacheWrite(scoped)); lines.push(row(` ${this.renderOptions("time", "时间", RANGES.map(formatRange), this.rangeIndex())}`)); lines.push( row( ` ${this.renderOptions("group", "分组", GROUPS.map((g) => GROUP_LABELS[g]), GROUPS.indexOf(this.groupBy))}`, ), ); lines.push( row( ` ${this.renderOptions("sort", "排序", SORT_KEYS.map((k) => SORT_LABELS[k]), SORT_KEYS.indexOf(this.sortKey))}`, ), ); lines.push(rule("├", "┤")); lines.push(row(th.fg("dim", this.renderHeader(GROUP_LABELS[this.groupBy], plan)))); const totals = sumRecords(scoped, this.book); lines.push(row(this.renderCells("合计", totals, plan, false))); const visible = this.viewportRows(); if (this.flat.length === 0) { if (visible > 0) lines.push(row(` ${th.fg("dim", "该时间范围内没有用量记录")}`)); for (let i = 1; i < visible; i++) lines.push(row("")); } else { const end = Math.min(this.flat.length, this.scroll + visible); for (let i = this.scroll; i < end; i++) { lines.push(row(this.renderRow(this.flat[i]!, i === this.cursor, plan))); } for (let i = end - this.scroll; i < visible; i++) lines.push(row("")); } lines.push(rule("├", "┤")); this.renderNotices(lines, row); lines.push( row(` ${th.fg("dim", "↑↓ 移动 · ←→ 切换/展开 · Tab 分组 · t 时间 · s 排序 · p 定价 · Esc 关闭")}`), ); } private renderNotices(lines: string[], row: (s: string) => string): void { const th = this.theme; if (this.book.errors.length > 0) { lines.push( row(` ${th.fg("error", `定价配置有 ${this.book.errors.length} 处问题,按 p 查看详情`)}`), ); } const borrowed = this.scopedChannels.filter((c) => c.source === "borrowed"); if (borrowed.length > 0) { lines.push( row( ` ${th.fg("dim", `${borrowed.length} 个渠道借用了同名模型的官方价,金额仅供参考:${borrowed.map((c) => c.channel).join("、")}`)}`, ), ); } const unpriced = this.scopedChannels.filter((c) => c.source === null && c.tokens > 0); if (unpriced.length > 0) { lines.push( row( ` ${th.fg("dim", `${unpriced.length} 个渠道无单价,其 token 未计入金额:${unpriced.map((c) => c.channel).join("、")}`)}`, ), ); } } private renderPricing( lines: string[], innerW: number, row: (s: string) => string, rule: (l: string, r: string) => string, ): void { const th = this.theme; lines.push(rule("├", "┤")); // 与用量视图同样的降级思路:先砍 cacheW 列,再缩短来源列,渠道名列优先保住 let showCacheWrite = true; let srcW = 22; const used = () => 1 + NUM_COL * (showCacheWrite ? 4 : 3) + srcW; if (innerW - used() < 26) showCacheWrite = false; if (innerW - used() < 26) srcW = 10; const nameW = Math.max(MIN_NAME, innerW - used()); let head = ` 渠道${" ".repeat(Math.max(0, nameW - 4))}`; const cols = showCacheWrite ? ["input", "output", "cacheR", "cacheW"] : ["input", "output", "cacheR"]; for (const h of cols) head += this.alignRight(h, NUM_COL); head += this.alignRight("来源", srcW); lines.push(row(th.fg("dim", head))); const visible = this.viewportRows(); if (this.channels.length === 0) { if (visible > 0) lines.push(row(` ${th.fg("dim", "还没有任何渠道的用量记录")}`)); for (let i = 1; i < visible; i++) lines.push(row("")); } else { const end = Math.min(this.channels.length, this.scroll + visible); for (let i = this.scroll; i < end; i++) { lines.push( row(this.renderChannel(this.channels[i]!, i === this.cursor, nameW, srcW, showCacheWrite)), ); } for (let i = end - this.scroll; i < visible; i++) lines.push(row("")); } lines.push(rule("├", "┤")); if (this.book.errors.length > 0) { lines.push(row(` ${th.fg("error", `配置问题:${this.book.errors.join(";")}`)}`)); } const notice = this.pricingNotice(); if (notice) lines.push(row(` ${th.fg("error", notice)}`)); lines.push(row(` ${th.fg("dim", `单价单位 $/百万 token · 配置文件 ${this.book.configPath}`)}`)); if (this.editing) { const tierWarn = this.editing.hadTiers ? `${th.fg("warning", "保存后该渠道的阶梯定价将失效")} · ` : ""; lines.push(row(` ${tierWarn}${th.fg("dim", "输入数字 · Tab/←→ 切字段 · Enter 保存 · Esc 取消编辑")}`)); } else { lines.push( row(` ${th.fg("dim", "↑↓ 移动 · e 编辑 · d 清除手工价 · r 重读配置 · p 返回用量 · Esc 关闭")}`), ); } } private renderChannel( info: ChannelInfo, selected: boolean, nameW: number, srcW: number, showCacheWrite: boolean, ): string { const th = this.theme; const editing = this.editing?.channel === info.channel ? this.editing : null; const active = editing !== null || (selected && this.focus === "table"); // 名称区宽度必须与表头一致:marker 占 1 列,其余 nameW 列给渠道名 // 阶梯的存在只在编辑时以底部警告的形式提示,列表里不占位 let name = truncateToWidth(info.channel, nameW); name += " ".repeat(Math.max(0, nameW - visibleWidth(name))); let line = `${active ? "▶" : " "}${name}`; const cells = editing ? editing.fields.map((text, i) => (i === editing.index ? `[${text}█]` : text)) : info.rate ? [info.rate.input, info.rate.output, info.rate.cacheRead, info.rate.cacheWrite].map(formatRate) : ["—", "—", "—", "—"]; for (const [i, cell] of cells.entries()) { if (i === 3 && !showCacheWrite) break; line += this.alignRight(cell, NUM_COL); } const source = editing ? (editing.error ? "保存失败" : "编辑中") : this.sourceLabel(info, srcW); line += this.alignRight(source, srcW); return active ? th.fg("accent", line) : line; } /** 来源标签;列窄时退化成短名,否则整列会被截断成省略号 */ private sourceLabel(info: ChannelInfo, srcW: number): string { if (!info.source) return "无单价"; if (srcW < 16) return SHORT_SOURCE_LABELS[info.source]; if (info.source === "borrowed") return `${PRICE_SOURCE_LABELS.borrowed}(${info.borrowedFrom})`; return PRICE_SOURCE_LABELS[info.source]; } private rangeIndex(): number { return RANGES.findIndex((r) => r === this.range); } /** 一行选项:聚焦的行标签高亮,当前选中项加方括号 */ private renderOptions(area: FocusArea, label: string, options: string[], selectedIndex: number): string { const th = this.theme; const active = this.focus === area; const head = active ? th.fg("accent", `${label}:`) : th.fg("dim", `${label}:`); const custom = area === "time" && selectedIndex < 0 ? ` ${this.selectedChip(formatRange(this.range), active)}` : ""; const parts = options.map((opt, i) => i === selectedIndex ? this.selectedChip(opt, active) : th.fg("dim", ` ${opt} `), ); return `${active ? th.fg("accent", "▸") : " "}${head} ${parts.join("")}${custom}`; } /** * 选中项:方括号之外还要上强调色,否则和未选中项只差两个符号,扫一眼分辨不出。 * 所在行聚焦时再加粗,把「当前值」和「正在操作的值」区分开。 */ private selectedChip(label: string, focused: boolean): string { const text = this.theme.fg("accent", `[${label}]`); return focused ? this.theme.bold(text) : text; } private alignRight(s: string, len: number): string { return " ".repeat(Math.max(1, len - visibleWidth(s))) + s; } private renderHeader(nameLabel: string, plan: ColumnPlan): string { let head = ` ${nameLabel}${" ".repeat(Math.max(0, plan.nameW - visibleWidth(nameLabel)))}`; head += this.alignRight("input", NUM_COL); head += this.alignRight("output", NUM_COL); if (plan.cacheRead) head += this.alignRight("cacheRead", NUM_COL); if (plan.cacheWrite) head += this.alignRight("cacheW", NUM_COL); if (plan.hitRate) head += this.alignRight("命中率", RATE_COL); head += this.alignRight("金额", COST_COL); return head; } private renderCells( name: string, v: { input: number; output: number; cacheRead: number; cacheWrite: number; hitRate: number | null; cost: number | null }, plan: ColumnPlan, active: boolean, ): string { let line = `${active ? "▶" : " "}${name}${" ".repeat(Math.max(0, plan.nameW - visibleWidth(name)))}`; line += this.alignRight(formatTokens(v.input), NUM_COL); line += this.alignRight(formatTokens(v.output), NUM_COL); if (plan.cacheRead) line += this.alignRight(formatTokens(v.cacheRead), NUM_COL); if (plan.cacheWrite) line += this.alignRight(formatTokens(v.cacheWrite), NUM_COL); if (plan.hitRate) line += this.alignRight(formatPercent(v.hitRate), RATE_COL); line += this.alignRight(formatCost(v.cost), COST_COL); return line; } private renderRow(item: FlatRow, selected: boolean, plan: ColumnPlan): string { const th = this.theme; const { row, depth } = item; // 表格聚焦时才画光标,否则用户看不出焦点在顶部筛选栏 const active = selected && this.focus === "table"; const marker = row.children?.length ? (this.expanded.has(row.key) ? "▾" : "▸") : " "; const isCurrentProject = this.groupBy === "project" && depth === 0 && row.key === this.cwd; // 选中行整行统一着色,内部符号此时不单独上色:嵌套的颜色重置会把外层高亮截断 const paint = (color: "dim" | "success", text: string) => (active ? text : th.fg(color, text)); let name = `${" ".repeat(depth)}${marker} ${row.label}`; if (row.sub) name += ` ${paint("dim", `· ${row.sub}`)}`; if (isCurrentProject) name += ` ${paint("success", "●")}`; // 先按可见宽度裁剪再补齐,否则带 ANSI 的名字会撑破表格 name = truncateToWidth(name, plan.nameW); const line = this.renderCells(name, row, plan, active); return active ? th.fg("accent", line) : line; } invalidate(): void {} }