/** * 语音输入去抖合并扩展 * * 场景:手机 Termius SSH + tmux 中使用语音转文字(如 Typeless), * 多段文本粘贴进 TUI 后每个换行符被当成 Enter,拆成多条独立消息发送。 * 本扩展对快速连续到达的输入做 debounce: * - 单行输入:300ms 后自动发出,用户几乎无感知 * - 多行输入:合并后放回编辑器(含末尾没有换行符、仍停留在编辑器里的那行), * 由用户审阅后手动按 Enter 发送 */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; // 去抖窗口:足够覆盖语音分段粘贴的间隔 const DEBOUNCE_MS = 300; export default function (pi: ExtensionAPI) { // 待合并的输入片段 let buffer: string[] = []; let timer: ReturnType | undefined; pi.on("input", async (event, ctx) => { // 非 TUI 模式(rpc/print/json)没有编辑器,直接放行, // 否则会拦截子 agent 的输入导致返回空 if (ctx.mode !== "tui") return { action: "continue" }; // 只处理键盘输入: // - "extension" 直接放行(含本扩展 sendUserMessage 发出的消息,避免递归) // - "rpc" 不涉及 TUI 粘贴问题,同样放行 if (event.source !== "interactive") return { action: "continue" }; // 带图片的输入无法按文本合并,直接放行 if (event.images?.length) return { action: "continue" }; buffer.push(event.text); if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = undefined; // 无论 buffer 长度多少都读取编辑器, // 捕获末尾没有 \n、停留在编辑器里的那行,避免单行路径丢尾 const tail = ctx.ui.getEditorText(); const merged = [...buffer, tail].filter((line) => line !== ""); buffer = []; if (merged.length === 0) return; if (merged.length === 1) { // 单行输入:直接自动发出;agent 忙碌时转为 followUp 避免抛错 if (ctx.isIdle()) { pi.sendUserMessage(merged[0]); } else { pi.sendUserMessage(merged[0], { deliverAs: "followUp" }); } return; } // 多行输入:合并后放回编辑器,不自动发送 ctx.ui.setEditorText(merged.join("\n")); ctx.ui.notify(`已合并 ${merged.length} 行,检查后按 Enter 发送`, "info"); }, DEBOUNCE_MS); // 拦截原始输入,等去抖窗口结束后统一处理 return { action: "handled" }; }); // 会话结束时清理悬挂的定时器与缓冲 pi.on("session_shutdown", async () => { if (timer) clearTimeout(timer); timer = undefined; buffer = []; }); }