/** * turn-marker.ts — 回合结束后取代 spinner 的那一行「结束符」的纯文本逻辑。 * * 需求(用户 2026-10-07 定):一轮对话跑完,spinner 直接消失、不留痕迹;想让它原地变成 * 一条「这个回合跑了多久、几点结束」的静态行,形如 * * ✻ Churned for 3m 37s · done 11:52 PM * * 落点在 `working-indicator/index.ts`(它持有回合时钟 `turnStartedAt`);本文件只管 * **文本与条目契约**,不碰 UI、不 import 任何 pi / pi-tui 模块,所以 `node --test` 能直接跑: * * node --test clients/pi/extensions/working-indicator/turn-marker.test.ts * * 五个决定(都是用户选的,改之前先看这里): * - **词随机取**:十个词的表,每回合随机一个(不是轮换、不是固定); * - **行首 `✻`**:Claude Code 回合结束行同款六角星(不是字面 `*`、不是 recap 的 `✦`); * - **颜色 dim**:整行(含 `✻`)走 `dim`,与 recap 行里 `Recap:` 标签同档 —— 比摘要 * 正文(`text`)与 `✦`(`accent`)都安静,符合「痕迹」而不是「结论」的定位; * - **落在会话日志里,不是 widget**(用户 2026-10-26 定,替换 10-07 的 widget 方案): * 结束符通过 `pi.appendEntry(TURN_MARKER_ENTRY_TYPE, …)` 写进会话日志、由 * `registerEntryRenderer` 画出来。原来的 `ui.setWidget("turn-end", …)` 在下一回合 * 的 `agent_start` 必须被清掉(它占的是编辑器上方那一格,不清就会盖在 spinner 上) * ⇒ 用户敲下一句就消失。会话条目活在 chat 流里,滚动仍在、`/resume` 与重开也仍在。 * `type: "custom"` 的条目**不进模型上下文**(pi 的 `convertToLlm` 直接丢弃),所以 * 没有 token 成本;顺带也不需要「行尾补空行」那套 widget 间隔补偿了。 * - **只在正常完成时写**:`outcome === "completed"` 才写,ESC / 报错保持现状; * 并且**还有子代理在跑时也不写**(用户 2026-10-08 定)。后者不是文本逻辑,落点在 * `index.ts`(它拿得到 pi 的事件总线)、探测用 `recap/subagents.ts` 的 * `hasActiveSubagentWork`,本文件不参与 —— 记在这里只是因为「什么时候才有一行 * 结束符」是读本文开头必须知道的前提。 * * 时长不在这里格式化:复用 index.ts 的 `formatDuration`(与 spinner 上的读秒同一套文案, * `<60s`→`42s`、`≥60s`→`1m 23s`、`≥1h`→`1h 23m 32s`),由调用方算好传进来 —— 两个 * 数字口径同源,用户看到的结束时长不会和 spinner 最后一帧对不上。 */ /** * 结束符词表。用户给的十个词,按原文顺序(含 `Ping-ponging` 的连字符写法); * 大小写、词形(`Churned` 与 `Grinding` 混用)都照抄,不做归一。 */ export const TURN_MARKER_VERBS = [ "Churned", "Spun", "Thrashed", "Grinding", "Chugging", "Ping-ponging", "Looping", "Fizzling", "Flickering", "Stalled", ] as const; /** 行首标记:Claude Code 回合结束行同款的六角星(用户 2026-10-07 定)。 */ export const TURN_MARKER_GLYPH = "✻"; /** * 结束符会话条目的 `customType`(`pi.appendEntry` / `pi.registerEntryRenderer` 的键)。 * * 落在 chat 流里(不再占编辑器上方的 widget 格),与 recap 摘要 / 任务清单那些 widget 无关。 * 这串字符串同时是**已写入会话日志的旧条目的识别符**,改名会让历史结束符在 resume 后 * 不再渲染(条目还在,只是没有渲染器)—— 所以它不是个内部细节,改之前先想清楚。 */ export const TURN_MARKER_ENTRY_TYPE = "turn-end"; /** * 写进条目 `data` 的形状。只存**拼好的整行文案**:词、时长、时刻都已经是成品; * 留着结构化字段会诱使将来在渲染器里「重算」,而重算就需要那些上下文(时长、随机种子), * 那是回合级的真相,条目里不该二次推导。 */ export interface TurnMarkerEntryData { /** 整行文本,已经过 `formatTurnMarker`(**不含**前导空格与颜色转义)。 */ text: string; } /** * 从会话条目里取出要画的文本;不是本扩展的条目 / 形状不对时返回 `null`。 * * 渲染器是 pi 按 `customType` 分派进来的,理论上形状总是对;但会话文件是**磁盘上的历史** * (手改、旧版本、将来改过字段),所以这里仍然校验一遍并拒绝垃圾 —— 宁可不画, * 也不能在渲染器里抛错(`CustomEntryComponent` 会把异常渲染成一条红色报错)。 */ export function turnMarkerTextFromEntry(entry: { customType?: unknown; data?: unknown }): string | null { if (entry.customType !== TURN_MARKER_ENTRY_TYPE) return null; const data = entry.data as Partial | undefined; const text = data?.text; return typeof text === "string" && text.trim() !== "" ? text : null; } /** `Math.random` 兼容的取数函数;单测里喂固定值把边界钉死。 */ export type RandomFn = () => number; /** * 随机取一个词。`random()` 按约定返回 `[0, 1)`,但注入的假函数(或将来有人换实现)可能 * 给出 1 —— 下标夹住,绝不让它越界成 `undefined`:结束符是一个回合的最后一笔,不该 * 因为一个随机数在渲染层炸掉。 */ export function pickTurnVerb(random: RandomFn = Math.random): string { const raw = Math.floor(random() * TURN_MARKER_VERBS.length); const index = Math.min(TURN_MARKER_VERBS.length - 1, Math.max(0, raw)); return TURN_MARKER_VERBS[index]; } /** * 结束时刻:`11:52 PM`(12 小时制、分钟补零、`AM`/`PM` 大写)。 * * 刻意手写而不走 `toLocaleTimeString`:需求就这一个形状,手写不受 ICU / 语言环境影响 * (本机 `zh-CN` 会给出 `23:52`,而状态行的其余文案全是英文)。0 点 / 12 点按惯例显示 * `12:00 AM` / `12:00 PM`(`hours % 12 === 0` 特判成 12)。 */ export function formatEndClock(date: Date): string { const hours = date.getHours(); const minutes = date.getMinutes(); const period = hours < 12 ? "AM" : "PM"; const hour12 = hours % 12 === 0 ? 12 : hours % 12; return `${hour12}:${String(minutes).padStart(2, "0")} ${period}`; } /** * 整行形状:`✻ <词> for <时长> · done <时刻>`。 * 三段都是调用方算好的现成文本(词来自 `pickTurnVerb`、时长来自 `formatDuration`、 * 时刻来自 `formatEndClock`),这里只负责把它们按固定形状拼起来 —— 形状本身有单测。 */ export function formatTurnMarker(verb: string, duration: string, clock: string): string { return `${TURN_MARKER_GLYPH} ${verb} for ${duration} · done ${clock}`; }