/** * explored-group / render —— `Explored` 分组块的画法。 * * 目标形态(用户 2026-10-06 定,第四轮修订后): * * • Explored * └ **Search** "bashOutput" in clients/pi ← 加粗 = 原生 grep / find / ls * **Read** …/ttt/CLAUDE.md, …/ttt/package.json ← 加粗 = 原生 read 工具 * Read …/bg-tasks/bg_3.log ← 不加粗 = bash(cat/tail) * Search "…" in … ← 不加粗 = bash(grep / rg) * List … ← 不加粗 = bash(ls / fd) * * ## 加粗 = 原生 **pi 工具**(用户 2026-10-06 定 `Read`,2026-10-07 扩到 grep / find / ls) * * `cat f` 与原生 `read({path: f})` 的短语都是 `Read f`;`grep -rn x` 与原生 * `grep({pattern: "x"})` 都是 `Search "x" …` —— 屏幕上分不出来,而它们是两件不同的事 *(一个是模型按结构化参数调的 pi 只读工具,一个是它自己拼出来的一条 shell)。所以**原生工具 *调用**那一行的动词加粗,bash 折叠出来的 Read / Search / Find / List **不加粗**。 * 一句话:**加粗 = 原生 pi 工具,不加粗 = Bash 工具的折叠形态**。 * * 四个动词由**工具名**决定(不是类别):`read` → `Read`、`grep` → `Search`、`find` → `Find`、 * `ls` → `List`;bash 里跑的 grep 类别同为 `search`,但工具名是 `bash`,所以不加粗。 * 见 `nativeVerbFor`。 * * **只加粗行首这一个词**,其后的路径 / 模式 / 行号区间一律不带粗体。 * * ## `Search` 句子里连接词 `in` 用 `dim`(用户 2026-10-07 定) * * `Search "pattern" in scope` 里只有 `in` 是虚词 —— 压暗它,模式与范围两个实词就跳出来了。 * 这是**句子层面**的排版,与来源无关:bash 折叠出来的 Search 同样适用(它只是动词不加粗)。 * `Find … in …` 暂不加(用户点名的只有 Search)。 * * 这条标记靠 `buildEntries` 的一条硬约束成立:**连续 read 合并时按来源切段** —— * 来源一变(原生↔bash)就另起一行,所以**同一行里不可能两种来源混居**, * 「这行加粗没有」就是「模型调的是不是原生工具」的完整答案。 * (2026-10-06 第二轮:分组不再按家族拆,所以这层保证从「不同组」下移到了「不同行」。) * * ## `└ ` 整块只出现一次(用户 2026-10-06 第三轮定) * * 只有**第一行**带 `└ `,其后的行(后续成员行,以及同一个 `read` 合并行折出来的续行) * 一律四格缩进、不再画拐角 —— 与 bash / codemode / web-search 结果树的 *「`└ ` 整块只出现一次」同一条语言。 * * ## 折行:先缩后折(用户 2026-10-27 定,两轮) * * 成员行超宽时**先缩、尽量不折行** —— 折行会看着像多了一条调用: * * └ Search "glass_block\|purple_stained_glass" in tools/cloudland-kit-fingerpri * nt.mjs ← 折行:像两条调用,而且续行没有动词可依靠 * * 缩略按**三个阶段**推进(实现见 `shrink.ts`),每一步都先试着能不能不折行: * * | 阶段 | 动什么 | 为什么 | * |---|---|---| * | A | **只丢目录层级**,文件名保持全称 | 目录是最不值钱的信息,文件名才是「看的是哪个」 | * | B | **只缩模式串**(省略中间、保头保尾),文件名仍全称 | 用户第二轮:「文件名最好保留全称,然后再去缩略搜索的字符串」 | * | C | 模式串缩到地板还不够,才连文件名一起缩(每轮砍当前最宽的) | 「实在不行…该缩略就缩略文件名」,多个可缩段之间保持平衡 | * * 两种缩法的方向是刻意相反的:**路径砍头保尾**(文件名在末尾,信息量最大), * **模式串省略中间**(正则的开头 `^` / `export ` 与结尾 `$` / `COLUMN_HEIGHT` 都承重)。 * 地板:路径 `…` + 8 列、模式串 12 列 —— 都不能缩没了。 * * 一行有多条路径时(`Diff a b`、合并的 `Read a, b`)每轮砍当前最宽的那条 —— 于是它们被压到 * 相近长度,而不是把第一条砍秃、第二条原样。 * * 缩到地板仍装不下(极窄终端、超长正则模式)才退回下面的 break-all 折行。 * * ## 重复行收成一行 + `× N`(用户 2026-10-29 定) * * 连续几个操作**渲染出来的那一行完全一样**时,不再追加显示,而是收成一行、在行尾标 `× N`: * * • Explored • Explored * └ Git show ⇒ └ Git show × 4 * Git show Git log ← 中间换了别的行就断开,各算各的 * Git show × 2 * Git log * * 判据是**渲染出来的那一行**(不是 args):`git show` 与 `git show --stat` 的短语都是 * `Git show`,屏幕上看不出差别,对读者就不是两条事实。`× N` 用 `muted` 槽压暗(它是注脚, * 不该抢动词的注意力),且**只算连续**——中间隔了一行别的操作就重新起算。 * * 重复的行收成**一行**,行尾带 `× N`(`GROUP_MAX_ROWS` / `… +N` 按收完之后的剩余行数算); * 带标记的行会按标记宽度多缩几个字符,免得标记自己撑出一行。 * * 同一个 `read` 合并行里重复出现的**同一个文件**(同路径、同行号区间)也收成一处 —— * 读者只需要知道「读过它」一次(读不同区间是不同的事实,照旧各算各的)。这是**静默**的, * 不带 `× N`:标记属于整行,不属于行里的某个名字。 * * ## 行合并:连续的 `read` 按来源分段合成一行 * * 用户明确要求:「一次性读两个文件」显示成 `└ Read a, b`。规则: * - 连续的 **read** 成员(不分来源)合并到同一行,用 `, ` 分隔 —— 但**来源一变就换行**: * `**Read** a, b` 与 `Read c` 各占一行(保住加粗语义,见文件头) * - 其他(search / list / git)**每次调用自成一行** * * 合并按**宽度**而不是固定个数:装满一行换下一行。装不下而折出的续行**重画 `Read `** *(`Read` 是这一行的动词,不重画就成了光秃秃一串路径)但**不重画 `└ `**;续行的 `Read` * 同样带粗体(它仍是那个原生调用的动词)。先缩后折:多个文件名缩过一轮仍装不进同一行的, * 才会按文件分行(短名并在一行、长名各自独占)。 * * ## 状态只在**组头那颗圆点**上表达 * * 用户 2026-10-06 明确:**行内不打勾、也不带圆点**。整组的状态由组头 `•` 一颗圆点表达: * * | 组的状态 | 圆点 | * |---|---| * | 还有成员在跑(`running`) | `text` 槽 —— 白 | * | 全部结束且无错 | `toolDiffAdded` —— 绿 | * | 任一成员出错 | `toolDiffRemoved` —— 红 | * * 这与「工具块不打勾」是同一条思路:状态光用颜色打,不落成字面标记。 * * ## 展开态 * * `ctrl+o` 展开时不折 `… +N`,显示全部行。**成员块的完整内容**在这一态由各成员自己的 * 渲染器负责 —— `renderCall` 里判断 `context.expanded`,展开的成员不再投 0 行 *(接线见 `bash-command-collapse.ts` / `read-path-collapse.ts`)。 * * 行数上限不是冗余:2026-10-20 先按「组里几条就画几行」改过一版(commit 40367a2), * 用户实际体验后判定更差、当天要求恢复 —— 别再把 `… +N` 当废弃逻辑删掉。 */ import { visibleWidth } from "@earendil-works/pi-tui"; import { phrasePiecesForToolCall } from "./phrases.ts"; import { groupOf, type Group, type GroupMember } from "./registry.ts"; import { joinPieces, shrinkPieces, type PhrasePiece } from "./shrink.ts"; /** 分组最多画几行成员(超出的折成一行 `… +N`);展开态不折。 */ export const GROUP_MAX_ROWS = 5; /** 成员行的前缀:`└ `(树拐角 + 空格 = 2 列)。**整块只出现一次**,挂在第一行上。 */ const ROW_PREFIX = "└ "; /** 第一行之后的行首缩进:`└ ` 同宽的空白,于是正文列与第一行对齐。 */ const ROW_INDENT = " "; /** 合并行里条目之间的分隔符。 */ const JOINER = ", "; /** 组头文字(用户定:就叫 Explored)。 */ const TITLE = "Explored"; /** 一个什么都不画的组件(成员块用)。 */ export const EMPTY_COMPONENT = { render(): string[] { return []; }, invalidate(): void {}, }; /** 组状态的圆点颜色槽。 */ export type GroupDotSlot = "text" | "toolDiffAdded" | "toolDiffRemoved"; /** * 组的合成状态 → 圆点色槽(用户 2026-10-06 定): * 只要有成员还在跑就是白(`text`),跑完且有错是红,否则绿。 */ export function groupDotSlot(group: Group): GroupDotSlot { if (group.members.some((m) => m.status === "error")) return "toolDiffRemoved"; if (group.members.some((m) => m.status === "running")) return "text"; return "toolDiffAdded"; } /** * 该成员此刻的短语片段(标了 `shrink` 的段超宽时可缩,见 `shrink.ts`)。 * * `read` 的路径**不再带 `Read ` 前缀** —— 合并行自己会画一次(`Read a, b`), * 每个条目再带一次就成了 `Read a, Read b`。别的类别每行独立,前缀由短语自带。 */ function labelPiecesFor(member: GroupMember, cwd: string | undefined): PhrasePiece[] { const pieces = phrasePiecesForToolCall(member.toolName, member.args, cwd) ?? [{ text: member.toolName }]; if (member.kind === "read" && pieces[0]?.text === "Read ") return pieces.slice(1); return pieces; } /** * 把成员排成「逻辑行」:连续 `read` 各自是一个条目,其余类别一个成员一个条目。 * * 返回的是**条目**而不是最终行 —— 宽度切分在渲染时做(条目可能被拆到多行)。 */ interface RowEntry { /** 这一行要画的内容(已含动词),按段存 —— 标了 `shrink` 的段可缩。 */ pieces: PhrasePiece[]; /** 该条目是否为 read 合并行(决定换行时是否重画 `Read ` 动词)。 */ isRead: boolean; /** * 该条目来自**原生 pi 工具**时,行首那个要加粗的动词(`Read` / `Search` / `Find` / `List`); * bash 折叠出来的条目是 `null`(见文件头「加粗 = 原生 pi 工具」)。 * * read 那一支按**该行自己的来源**算:连续 read 合并时来源一变就另起一行(见 `buildEntries`), * 所以同一行里只会有一种来源,用第一个成员的 `toolName` 判定就够。 */ boldVerb: string | null; /** * 与**紧邻的上一条**条目渲染结果完全相同、被收成一行时的次数(≥2 才写,缺省 = 一次)。 * * 见文件头「重复行收成一行 + `× N`」:只算**连续**的重复;行尾的 `× N` 由 `paintRow` 拼。 */ repeat?: number; } /** 折行后的最终行。 */ interface OutputRow { text: string; /** 行首动词是原生工具的动词 → 只把它加粗(其后的路径 / 模式不加粗)。 */ boldVerb: string | null; /** 来自 read 条目(原生或 bash)→ 行号区间要上色(见 `paintReadSpans`)。 */ isRead: boolean; /** 重复标记(` × N`,含前导空格);挂在条目的最后一行上,由 `paintRow` 压暗。 */ suffix?: string; } /** * 原生 pi 只读工具 → 它那一行行首要加粗的动词;bash 与其它工具一律 `null`。 * * 只认**工具名**,不认类别:bash 里跑的 `grep` / `ls` / `find` 折叠后类别与原生一致 *(search / list),但它们是模型自己拼的 shell,不该与原生调用长得一样。 */ export function nativeVerbFor(toolName: string): string | null { switch (toolName) { case "read": return "Read"; case "grep": return "Search"; case "find": return "Find"; // `glob` 也是原生工具(模型直调、非 bash 拼接)—— 行首动词加粗。 case "glob": return "Glob"; case "ls": return "List"; default: return null; } } /** * 收掉同一个 `read` 合并行里重复的**文件段**(同路径 + 同行号区间),保留首次出现的位置。 * * 静默合并、不带 `× N`:标记是整行级的注脚(见文件头),行里的某个名字重复了几次读者不需要数。 * 读**不同区间**是不同的事实(`Read a.ts 100` / `Read a.ts 200`),照旧各算各的。 */ function dedupeReadPieces(pieces: readonly PhrasePiece[]): PhrasePiece[] { const seen = new Set(); const out: PhrasePiece[] = []; let segment: PhrasePiece[] = []; const flushSegment = () => { if (segment.length === 0) return; const key = joinPieces(segment); if (!seen.has(key)) { seen.add(key); if (out.length > 0) out.push({ text: JOINER }); out.push(...segment); } segment = []; }; for (const piece of pieces) { if (isJoiner(piece)) flushSegment(); else segment.push(piece); } flushSegment(); return out; } /** * 条目的身份指纹:**渲染出来的那一行**(含来源,因为加粗与否只由它决定)。 * * 用渲染结果而不是 args 作判据:`git show` 与 `git show --stat` 是两条命令、同一行文字, * 屏幕上分不出来,重复显示就没有多给任何信息(用户 2026-10-29 的「展示的结果完全一样」)。 * 来源必须进指纹:原生 read 与 bash 转义 Read 短语一样,但一个是粗体一个不是 —— * 收成一行就等于让 bash 那次冒充原生调用。 */ function entryKey(entry: RowEntry): string { return `${entry.isRead ? "read" : "row"}|${entry.boldVerb ?? ""}|${entry.pieces .map((piece) => `${piece.shrink ?? "fixed"}:${piece.text}`) .join("\u0000")}`; } /** 把**连续**且指纹相同的条目收成一条,次数记在 `repeat` 上(见文件头)。 */ function collapseRepeats(entries: RowEntry[]): RowEntry[] { const out: RowEntry[] = []; let lastKey: string | undefined; for (const entry of entries) { const key = entryKey(entry); const previous = out[out.length - 1]; if (previous && key === lastKey) { previous.repeat = (previous.repeat ?? 1) + 1; continue; } out.push(entry); lastKey = key; } return out; } function buildEntries(group: Group, cwd: string | undefined): RowEntry[] { const entries: RowEntry[] = []; let reads: PhrasePiece[] = []; let readCount = 0; let readsAreNative = false; const flush = () => { if (readCount === 0) return; entries.push({ pieces: [{ text: "Read " }, ...dedupeReadPieces(reads)], isRead: true, boldVerb: readsAreNative ? "Read" : null }); reads = []; readCount = 0; readsAreNative = false; }; for (const member of group.members) { // 参数还没吐完的成员**不进树**(用户 2026-10-07 定:「bash 指令被吐完后…直接输出为 // 折叠后的 Search」)。不滤的话,行内文字会跟着模型的 token 一点点长: // `└ bash` → `Search "auth"` → `Search "authorization" in proxy/`。 // 置位见 `markMemberReady`;未 ready 的成员自己的块也投 0 行,所以屏幕上真的一行不出。 if (!member.ready) continue; if (member.kind === "read") { const isNative = member.toolName === "read"; // 来源一变就换行:同一行必须是同一种来源,否则「加粗 = 原生工具」立刻失真 if (readCount > 0 && isNative !== readsAreNative) flush(); if (readCount === 0) readsAreNative = isNative; if (readCount > 0) reads.push({ text: JOINER }); reads.push(...labelPiecesFor(member, cwd)); readCount++; continue; } flush(); entries.push({ pieces: labelPiecesFor(member, cwd), isRead: false, boldVerb: nativeVerbFor(member.toolName) }); } flush(); return collapseRepeats(entries); } /** * 折行后的中间形态:**保留「它属于哪个文件」**。 * * 直接把每块的折行结果卷平(早先的 `flatMap`)会丢掉归属,于是续块与「下一个文件」 * 在主循环里长得一模一样 —— 用户 2026-10-07 报的「第一个 Read 折行后,第二行又冒出一个 Read」 * 就是这个:`cat <77 字文件名>` 在 79 列下折成两块,第二块拿到了自己的 `Read ` 前缀。 * 同因的第二个症状更隐蔽:条目 A 的尾部碎块会被当成独立条目,与真实的 `b.ts` 用 `, ` 缝成 * `Read z, b.ts`(看着像读了两个文件,实际是 A 被切断 + 多出一个不存在的名字)。 * * 用户 2026-10-27 定「尽量不折行」之后,这一层只在**缩到地板仍装不下**时才会走到 *(路径与模式串都已经先被 `shrinkPieces` 缩过了),但它仍然必要 —— 极窄终端、 * 超长的模式串都还是折行的。 */ interface Chunk { text: string; /** 是不是它所属**文件**的第一块 —— 只有首块带 `Read ` 动词,续块只缩进。 */ startsItem: boolean; } /** * 把条目排成最终行:**先缩后折**。 * * 1. **能缩就缩**(用户 2026-10-27 定):条目整体超宽时先缩(`shrinkPieces`,规则见 * `shrink.ts` 的 A/B/C 三阶段 —— 先丢目录、再缩模式串、最后才连文件名一起缩), * 能一行装下就绝不折行 —— `Search "x" in tools/cloudland-kit-fingerpri` / `nt.mjs` * 这种看着像两条调用的折行就是被这一层消掉的。 * 2. 缩到地板仍超宽才退回 break-all 硬折(与 bash 侧 `hardWrapRows` 同一语义: * **装满到恰好放不下再断**,不提前折行)。 * * `read` 合并行(`Read a, b, c`)的路径是**分别的段**,所以缩略时每轮砍当前最宽的那条 —— * 多条路径因此被压到相近长度(用户 2026-10-27 定),而不是把第一条砍秃、第二条原样。 * 缩到装得下之后仍需换行的(多个文件本来就装不进一行),续行**重画** `Read ` *(`Read` 是这一行的动词,不重画就成了光秃秃一串路径)。`└ ` 不在这里画 —— 整块只有第一行带它。 * * **加粗在这里只传递标志、不拼 ANSI**:折行宽度按**剥掉颜色后的可见文本**算, * 拼在 `layoutRows` 里会把 ANSI 带进 `visibleWidth` 与 `hardWrap` 的字符循环, * 让宽度算法当场失真。上色统一在 `paintRow` 里做。 */ /** 分隔段(`buildEntries` 插的 `, `):不是可缩段,切分文件时按它断。 */ function isJoiner(piece: PhrasePiece): boolean { return piece.text === JOINER && piece.shrink === undefined; } function layoutRows(entries: RowEntry[], budget: number): OutputRow[] { const rows: OutputRow[] = []; for (const entry of entries) { const copies = entry.repeat ?? 1; // 重复标记占列,所以要按「预算减标记宽度」缩 —— 不预扣的话标记自己会把行撑宽。 const suffix = copies > 1 ? ` × ${copies}` : ""; const entryRows = layoutEntry(entry, Math.max(1, budget - visibleWidth(suffix))); // **只画一份**(这就是用户要的「不要再追加显示了」),标记挂在最后一行上。 entryRows.forEach((row, index) => { rows.push({ ...row, suffix: index === entryRows.length - 1 && suffix !== "" ? suffix : undefined }); }); } return rows; } /** 一个条目 → 它自己的行(不含重复标记;重复标记由 `layoutRows` 统一挂上)。 */ function layoutEntry(entry: RowEntry, budget: number): OutputRow[] { const rows: OutputRow[] = []; if (!entry.isRead) { const fitted = shrinkPieces(entry.pieces, budget, visibleWidth).pieces; // 折行后只有**第一行**还带着那个动词,续行没有词可加粗(与 read 的 `prefixed` 同义) hardWrap(joinPieces(fitted), budget).forEach((text, index) => { rows.push({ text, boldVerb: index === 0 ? entry.boldVerb : null, isRead: false }); }); return rows; } // read 合并行:整行先缩(多文件按宽度均摊),装不下再按文件分行。 // 缩的预算含 `Read `(它确实占列),但切分前要剥掉 —— 动词由下面的 `prefixed` 画。 const fitted = shrinkPieces(entry.pieces, budget, visibleWidth).pieces; const body = fitted[0]?.text === "Read " && !fitted[0].shrink ? fitted.slice(1) : fitted; const chunks: Chunk[] = splitReadPieces(body) .flatMap((piece) => wrapReadPiece(piece, Math.max(1, budget - "Read ".length), budget) .map((text, index) => ({ text, startsItem: index === 0 }))); let current = ""; // 当前行要不要画 `Read ` 前缀:只有「本行从一个新文件开始」时才画。 // 同一个文件的折行续块独占一行且不带前缀(用户 2026-10-07 定)。 let prefixed = false; const flush = () => { if (current === "") return; rows.push({ text: prefixed ? `Read ${current}` : current, boldVerb: prefixed ? entry.boldVerb : null, isRead: true, }); current = ""; prefixed = false; }; for (const chunk of chunks) { if (!chunk.startsItem) { // 续块:先把属于自己的首行收掉,自己独占一行、不带前缀 flush(); current = chunk.text; prefixed = false; continue; } if (prefixed) { const candidate = `${current}${JOINER}${chunk.text}`; if (visibleWidth(`Read ${candidate}`) <= budget) { current = candidate; continue; } } // 来的是新文件:续块行不让它搭车,一律从新的一行(带 Read)开始 flush(); current = chunk.text; prefixed = true; } flush(); return rows; } /** * 把 read 合并行的片段按 `, ` 切开 —— **每个文件一段**(各自的路径 + 行号区间拼好)。 * * 用片段而不是字符串切,是因为分隔符 `, ` 是 `buildEntries` 显式插的固定段,而文件自己的 * 路径里也可能有逗号(`Read a,b.ts`)—— 按分隔段识别就不会误切。 */ function splitReadPieces(pieces: readonly PhrasePiece[]): string[] { const out: string[] = []; let current = ""; for (const piece of pieces) { if (isJoiner(piece)) { out.push(current); current = ""; continue; } current += piece.text; } out.push(current); return out; } /** * 折一个 read 段,**行号区间整体不拆**(`100-169` 被劈成两行就既丢了区间、又只剩半边颜色)。 * * 做法:路径部分照旧 break-all,然后把区间贴到最后一段上;贴不下就给最后一段腾出区间宽度再重折 * 那一段。区间是这一行唯一的结构化信息,值得为它让出字符级填满的那点收益。 * * 说是「区间」靠行尾的 ` 数字[-数字]` 形状认,与 `paintReadSpans` 同一口径 —— 两处必须一致, * 否则会出现「折行认它是区间、上色不认」这种半边处理。代价是名为 `notes 2024`(带空格 + 纯数字、 * 无扩展名)的文件会被当成带区间(多上点色 / 多折一行,纯观感,实测全库 0 例)。 */ function wrapReadPiece(piece: string, budget: number, rowBudget: number): string[] { const match = piece.match(/ (\d+(?:-\d+)?)$/); if (!match || match.index === undefined) return hardWrap(piece, budget); const span = match[0]; const head = piece.slice(0, match.index); const room = budget - visibleWidth(span); // 终端窄到连「Read + 区间」都放不下(`room <= 0`)→ 退回字符级折行,没有更好的选择 if (room <= 0 || visibleWidth(`Read ${span}`) > rowBudget) return hardWrap(piece, budget); const chunks = hardWrap(head, budget); const last = chunks.pop() ?? ""; if (visibleWidth(last + span) <= budget) { chunks.push(last + span); return chunks; } // 最后一段太长、贴上就超宽 → 只把这一段按「留出区间宽度」重折,前面的不动 const tail = hardWrap(last, room); const tailLast = tail.pop() ?? ""; return [...chunks, ...tail, tailLast + span]; } /** break-all 硬折(按可见列填满再断)。 */ function hardWrap(text: string, budget: number): string[] { if (budget < 1) return [text]; const out: string[] = []; let current = ""; let used = 0; for (const ch of [...text]) { const width = visibleWidth(ch); if (used + width > budget && current !== "") { out.push(current); current = ""; used = 0; } current += ch; used += width; } if (current !== "" || out.length === 0) out.push(current); return out; } /** * read 行里的行号区间上色:数字 `warning`、中间的横线用**前景色**(用户 2026-10-07 定)。 * * 横线用 `text` 槽 —— 它就是主题的 `fg` 变量(`colors.text: "fg"`),也就是正文本来的颜色; * `theme.fg("fg", …)` 会抛 `Unknown theme color: fg`,槽名是 `text`。用户要求从 `dim` 改成前景色, * 为的是让那根横线的亮度与路径正文一致(`dim` 是刻意压暗的一档)。 * * 在**纯文本**上做(宽度与折行算法不能被 ANSI 污染,见 `layoutRows` 的注释), * 所以上色只发生在这里,不影响任何一行的宽度。 * * 按 `, ` 分段各自处理 —— 合并行 `Read a.ts 100-169, b.ts 1-45` 两段都要上色。 * * 为什么用「行尾模式」而不是把区间当结构化字段传下来:折行是 break-all 的, * 一个区间本来就可能被硬折在两行里(窄终端),精确位置在折行后本就不存在。 * 模式按「空格 + 纯数字」锚定在段尾,所以 `cat v2`(无空格)这类路径不会被误伤。 */ function paintReadSpans(text: string, theme: any): string { return text .split(JOINER) .map((piece) => { const match = piece.match(/ (\d+)(?:(-)(\d+))?$/); if (!match || match.index === undefined) return piece; const head = piece.slice(0, match.index); const start = theme.fg("warning", match[1]!); if (match[2] === undefined) return `${head} ${start}`; return `${head} ${start}${theme.fg("text", match[2])}${theme.fg("warning", match[3]!)}`; }) .join(JOINER); } /** * `Search "" in ` 里的连接词 —— 用 `dim` 压暗(用户 2026-10-07 定)。 * * 贪婪匹配 `"…"`,所以模式里自带 ` in `(`Search "foo in bar" in src`)不会认错分隔符; * 折行恰好断在 `in` 里(`… i` / `n …`)时匹配不上,就当普通文本 —— 与行号区间折行同一口径, * 宁可不处理也不猜位置。宽度不受影响:ANSI 只在 `paintRow` 里加(见 `layoutRows` 的注释)。 */ const SEARCH_SEPARATOR = /^Search (.*") in (.*)$/; function paintSearchSeparator(text: string, theme: any): string { const match = text.match(SEARCH_SEPARATOR); if (!match) return text; return `Search ${match[1]} ${theme.fg("dim", "in")} ${match[2]}`; } /** * 把一行拼成最终文本:原生 pi 工具那一行**只把行首动词**加粗(用户 2026-10-06 定 `Read`, * 2026-10-07 扩到 `Search` / `Find` / `List`),其后的路径 / 模式不加粗;行号区间按上面 * `paintReadSpans` 的口径上色(原生与 bash 的 Read 一视同仁)。 * * 重复标记(` × N`)走 `muted` 槽(用户 2026-10-29 定)—— 它是注脚,不该抢正文的注意力, * 所以最后贴上、不参与上色与加粗。 * * 上色走 `theme.bold` / `theme.fg`;这里也是整块唯一上色的地方 —— * `layoutRows` 只传标志不拼 ANSI(理由见那边的注释)。 */ function paintRow(row: OutputRow, theme: any): string { const withSpans = row.isRead ? paintReadSpans(row.text, theme) : row.text; const text = paintSearchSeparator(withSpans, theme); const suffix = row.suffix === undefined ? "" : theme.fg("muted", row.suffix); const verb = row.boldVerb; // 动词必然在行首;对不上(短语生不出来而退回工具名的兜底行)就不加粗:宁可不加,不能加错 if (!verb || !text.startsWith(verb)) return `${text}${suffix}`; return `${theme.bold(verb)}${text.slice(verb.length)}${suffix}`; } /** * `Explored` 树的组件。 * * 每帧按当前宽度重算(行列表短、纯字符串拼接),所以成员增减与状态变化立刻可见 —— * 缓存反而要多一套失效逻辑,不值得。 * * 宽度预算:整块左边距 2 列(`• ` 对齐各工具块),行前缀 `└ ` 再占 2 列。 */ export function createGroupTree(options: { groupId: string; theme: any; cwd: string | undefined; /** `ctrl+o` 展开态:不折 `… +N`。 */ expanded: boolean; }) { return { render(width: number): string[] { const group = groupOf(options.groupId); if (!group || group.members.length === 0) return []; // 一行都还没就绪(参数都在流)→ **连组头都不画**:用户 2026-10-07 要的是「指令吐完后 // 直接出现」,而不是先冒一个空壳 `• Explored` 再慢慢长行。成员 ready 时会 // `notifyLeader` 叫醒这里重渲,所以不会漏。 if (!group.members.some((m) => m.ready)) return []; const theme = options.theme; const dot = `${theme.getFgAnsi(groupDotSlot(group))}\u2022\u001b[39m`; const lines: string[] = [`${dot} ${theme.fg("toolTitle", theme.bold(TITLE))}`]; const rowBudget = Math.max(1, width - 4); // 左边距 2 + `└ ` 2 const rows = layoutRows(buildEntries(group, options.cwd), rowBudget); const shown = options.expanded ? rows : rows.slice(0, GROUP_MAX_ROWS); const hidden = rows.length - shown.length; for (const [index, row] of shown.entries()) { const prefix = index === 0 ? ROW_PREFIX : ROW_INDENT; lines.push(` ${theme.fg("dim", prefix)}${paintRow(row, theme)}`); } if (hidden > 0) { lines.push(` ${ROW_INDENT}${theme.fg("muted", `… +${hidden}`)}`); } return lines; }, invalidate(): void {}, }; }