/** * skill-invocation-reorder — `/skill:name 提示词` 的显示顺序反转:先画用户消息、再画 skill 调用行。 * * pi 的默认渲染(`interactive-mode.js` 的 `addMessageToChat`,`case "user"`)对一条 * skill-wrapped 用户消息的顺序是: * * [skill] commit (ctrl+o to expand) ← SkillInvocationMessageComponent(先) * (Spacer 1) * ▏ 提示词 ← UserMessageComponent(后) * * 用户想要的顺序反过来:**先**正常显示用户消息(`user-message-bar` 的 `▏` 竖线照常生效, * 内容就是用户敲的那条命令原文),**再**在下方显示 `[skill] name (ctrl+o to expand)` 折叠行: * * ▏ * ▏ /skill:commit 提交一下 * ▏ * * [skill] commit (ctrl+o to expand) * * 且 skill 折叠行**不带底色、不留尾部空行**(用户 2026-10-14 定):pi 给这个组件套的是 * `Box(outputPad, 1, t => theme.bg("customMessageBg", t))`,于是整块(含上下两条内边距行) * 都刷上 `customMessageBg`。本扩展构造后调 `setBgFn(undefined)` 把底色拿掉 —— 底色一去, * **下**内边距行就只是屏幕上一条多余的空行,所以再把它裁掉;**上**内边距行要留(它是用户 * 消息框与 `[skill]` 行之间的那条间隔,用户明确说这条是对的)。 * * ## 只改显示,不改发送 * * 发送给模型的仍是 pi 展开后的**一条** user 消息(`…\n\n提示词`), * 本扩展只接管「这条消息画到 chatContainer 里的顺序与组件」,session 记录、模型上下文、 * ctrl+o 全局展开(`setToolsExpanded` 遍历 children 调 `setExpanded`)全部不受影响。 * * ## 实现:原型补丁(与 user-message-bar 同一手法) * * pi 的扩展 API 没有「用户消息渲染」的注入点,只能包 `InteractiveMode.prototype.addMessageToChat`。 * 补丁打得中的原因:扩展加载器把 `@earendil-works/pi-coding-agent` 映射到 pi 自己那份 * (bundle 形态下是 `virtual-modules` 的同一命名空间),扩展 import 到的 `InteractiveMode` * 就是渲染用的那个类。补丁在**模块求值时**就装(此时还没有任何 InteractiveMode 实例), * 一定赶在第一条消息渲染之前。 * * 命中条件极窄:`message.role === "user"` 且 `parseSkillBlock(text)` 非空 —— 其余一切 * (bash / custom / assistant / 普通用户消息)原样委托给原函数,零行为变化。 * * 命中时**完整复刻** pi 原分支的每一步,只调换两个组件的先后: * - 前置 `Spacer(1)`(chatContainer 非空时,与原分支一致); * - `UserMessageComponent(重建的 /skill:name 提示词, …)` —— 只画命令那一行; * - `SkillInvocationMessageComponent(skillBlock, …)` + `setBgFn(undefined)`(去底色) * + 裁掉尾部空行 + `setExpanded(this.toolOutputExpanded)` * —— 折叠行本体,点击切换展开、ctrl+o 全局展开都保留; * - `options.populateHistory` 时 `this.editor.addToHistory?.(textContent)`(resume 回放历史用)。 * * 取不到私有字段(`chatContainer` / `outputPad` / `getMarkdownThemeWithSettings` / * `getMarkdownTransformers`)时退回原函数 —— pi 改了内部结构时回到默认顺序,而不是崩。 * * `Symbol.for` 全局注册表做幂等:`/reload` 后模块重新求值,补丁已在就只刷新状态、不再包一层。 * * `PI_SKILL_INVOCATION_REORDER=off` 启动时关闭。 */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { InteractiveMode, parseSkillBlock, SkillInvocationMessageComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent"; import { Spacer } from "@earendil-works/pi-tui"; const PATCH_KEY = Symbol.for("pi-skill-invocation-reorder.patched"); /** * 从 skill 块**重建**用户敲的那条命令:`/skill: <提示词>`。 * * 为什么要重建:pi 把 `/skill:name 提示词` 展开成 `…\n\n提示词` 后才交给渲染, * 展开后的消息里只剩提示词,`/skill:name` 前缀已经没了。而用户要的消息框内容是「跟用户输入的 * 完全一样」—— 包含 `/skill:name` 前缀的完整命令。 * * 为什么不在 `input` 钩子里记下原文:那是**跳钩子的共享状态**,而输入可能被排队(streaming 时 * 的 steer / followUp)延后渲染,期间再敲一条 `/skill:` 就会把前一条的原文覆盖掉 —— 画错比画少 * 更糟。重建则无状态、无时序依赖,且 **resume 回放也能用**(回放没有 input 钩子,记原文那条路 * 在回放时必然退化成只显示提示词)。重建与原文的差异只剩多余空白(pi 展开前已 `trim()` 过 args)。 */ function reconstructSkillCommand(name: string, userMessage: string): string { return `/skill:${name} ${userMessage}`; } /** pi 的 user 消息(只用到这里需要的字段)。 */ interface UserMessageLike { role: "user"; content: unknown; } /** `addMessageToChat` 的第二参数(`populateHistory` 等)。 */ type AddMessageOptions = { populateHistory?: boolean } | undefined; /** 从 user 消息里取纯文本 —— 与 pi 的 `getUserMessageText` 同逻辑。 */ function getUserMessageText(message: UserMessageLike): string { const content = message.content; const textBlocks = typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content.filter((c) => (c as { type?: string }).type === "text") : []; return textBlocks.map((c) => (c as { text?: string }).text ?? "").join(""); } /** * 「视觉上空白」的一行:只有空白字符与零宽转义序列。 * Box 的内边距行在**没有 bgFn** 时就是 `width` 个空格(`applyBg` 把空行补白到整宽后直接返回), * 有 bgFn 时则裹着 `\x1b[48;…m` / `\x1b[49m` —— 两种形态都要认得。 */ const BLANK_ROW = /^(?:\s|\u001b\[[0-9;:?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\))*$/; /** 裁掉末尾的空白行(Box 的下内边距行)。全是空白时返回空数组 —— 那时确实没东西可画。 */ function trimTrailingBlankRows(lines: string[]): string[] { let end = lines.length; while (end > 0 && BLANK_ROW.test(lines[end - 1]!)) end -= 1; return end === lines.length ? lines : lines.slice(0, end); } /** * 去掉 skill 折叠行的**下内边距行**(底色拿掉之后它就是一条多余空行)。 * * 为什么从渲染结果上裁、而不是把 `paddingY` 改成 0:`Box` 只有公开的 `setPaddingX`, * **没有** `setPaddingY`(`paddingY` 是 TS 私有字段)。写私有字段能在运行期生效,但 pi 一改名 * 就静默失效;而裁剪是纯输出层的,不碰任何内部状态,pi 哪天把 `paddingY` 改成 0 也只是 * 让这次裁剪变成空操作。上内边距行因此原样保留(用户要的那条间隔)。 * * 点击展开不受影响:父容器按 `render()` 的行数算鼠标布局,`Box.handleMouse` 仍按 * `paddingY = 1` 换算坐标 —— 裁掉的是**末尾**那行,`[skill]` 正文行的 y 坐标没变,点它照样切换。 */ function dropTrailingPaddingRow(component: { render: (width: number) => string[] }): void { const innerRender = component.render.bind(component) as (width: number) => string[]; component.render = (width: number): string[] => trimTrailingBlankRows(innerRender(width)); } /** * 装补丁。返回本次是否真的装了(`/reload` 后为 false —— 已在,不重复包)。 * `InteractiveMode` 形状不认识(没有 `addMessageToChat`)时返回 false 且什么都不做。 */ function installReorderPatch(): boolean { const prototype = InteractiveMode.prototype as unknown as Record; if (prototype[PATCH_KEY]) return false; const original = prototype.addMessageToChat; if (typeof original !== "function") return false; prototype[PATCH_KEY] = true; prototype.addMessageToChat = function ( this: Record, message: { role?: string }, options?: AddMessageOptions, ): void { if (message?.role !== "user") { return (original as (m: unknown, o?: AddMessageOptions) => void).call(this, message, options); } const textContent = getUserMessageText(message as UserMessageLike); const skillBlock = textContent ? parseSkillBlock(textContent) : null; if (!skillBlock || !skillBlock.userMessage) { // 没有 skill 块(或 skill 没跟提示词):原样走 pi 的渲染。 return (original as (m: unknown, o?: AddMessageOptions) => void).call(this, message, options); } // 取 pi 的私有渲染上下文;任何一个拿不到就退回原函数(回到默认顺序,不崩)。 const chatContainer = this.chatContainer as { children?: unknown[]; addChild?: (c: unknown) => void } | undefined; const outputPad = typeof this.outputPad === "number" ? this.outputPad : undefined; const getMarkdownThemeWithSettings = this.getMarkdownThemeWithSettings; const getMarkdownTransformers = this.getMarkdownTransformers; if ( !chatContainer || !Array.isArray(chatContainer.children) || typeof chatContainer.addChild !== "function" || outputPad === undefined || typeof getMarkdownThemeWithSettings !== "function" || typeof getMarkdownTransformers !== "function" ) { return (original as (m: unknown, o?: AddMessageOptions) => void).call(this, message, options); } const markdownTheme = (getMarkdownThemeWithSettings as () => unknown).call(this); const transformers = (getMarkdownTransformers as () => unknown).call(this); // 前置空行(与 pi 原分支一致:chatContainer 已有内容时才加)。 if (chatContainer.children.length > 0) { chatContainer.addChild(new Spacer(1)); } // 1) 用户消息在前(user-message-bar 的竖线补丁照常作用于它)。 // 内容是**重建出的完整命令**(`/skill:name 提示词`),而不是展开后剩下的那截提示词。 const userComponent = new UserMessageComponent( reconstructSkillCommand(skillBlock.name, skillBlock.userMessage), markdownTheme as never, outputPad, transformers as never, ); chatContainer.addChild(userComponent); // 2) skill 折叠行在后。 const skillComponent = new SkillInvocationMessageComponent( skillBlock as never, markdownTheme as never, outputPad, ); // 去掉整块底色:pi 构造时传的是 `t => theme.bg("customMessageBg", t)`,`Box` 的公开 // `setBgFn(undefined)` 让 `applyBg` 直接返回补白行、不再刷背景。比换掉整个组件安全: // 折叠/展开两态、点击切换、ctrl+o 全局展开、展开态的 Markdown 渲染全部原样保留。 // `setExpanded` / `invalidate` 只重建 children,不会把 bgFn 装回去。 if (typeof skillComponent.setBgFn === "function") skillComponent.setBgFn(undefined); // 底色没了,下内边距行就是一条多余空行 —— 裁掉(上内边距行保留,见 dropTrailingPaddingRow)。 dropTrailingPaddingRow(skillComponent); const toolOutputExpanded = this.toolOutputExpanded; skillComponent.setExpanded(typeof toolOutputExpanded === "boolean" ? toolOutputExpanded : false); chatContainer.addChild(skillComponent); // 3) resume 回放时把原文塞回编辑器历史(与 pi 原分支一致)。 if (options?.populateHistory) { const editor = this.editor as { addToHistory?: (text: string) => void } | undefined; editor?.addToHistory?.(textContent); } }; return true; } export default function (_pi: ExtensionAPI): void { if ((process.env.PI_SKILL_INVOCATION_REORDER ?? "").trim().toLowerCase() === "off") return; installReorderPatch(); }