/** * 「当前问题」置顶扩展 (current-question) * * 作者: jenson * * 功能:在编辑器上方固定显示「最近 n 条用户问题」,并支持快捷键选择/滚动。 * * - 显示最近 n 条用户问题(默认 5 条,可用 /current-question <数字> 设置) * - 每条只展示一行,超长自动截断并加 "..." * - 标题带「第 N 轮」计数:❯ 当前问题(N),N 为当前提问轮数 * - 默认收起;选中条可用 Ctrl+Alt+L 展开看问题全文 + 该轮回答,Ctrl+Alt+H 收起 * - 收起态下 Ctrl+Alt+H 隐藏当前问题;隐藏态下 Ctrl+Alt+L 重新显示 * - 展开态下 Ctrl+Alt+J/K 滚动回答内容;收起态下 Ctrl+Alt+J/K 切换问题条目 * (vim 风格 j=下/k=上;展开时 j=往后看回答,收起时 j=更早的问题) * 注:会覆盖编辑器原 ctrl+alt+j/ctrl+alt+k 光标移动 * - 选择可超出显示窗口:列表含全部历史问题,窗口固定显示 maxLines 条, * 选中移出窗口时窗口随之滚动;到顶/到底静默停止 * - 展开后回答以固定行数窗口显示(ANSWER_VIEW_LINES),可滚动,到顶/到底静默停止 * - 展开态高度自适应终端:问题全文超 MAX_QUESTION_LINES 截断,回答窗口按终端高度 * 动态压缩,整体不超过 termRows - WIDGET_HEIGHT_RESERVED,避免触碰顶部引发流式抖动 * - 选中条可用 Ctrl+Alt+B 同时复制完整问题与回答 * - 发新消息后选中自动归位到最新一条、收起 * - 以 sessionManager 为唯一真相源,监听 message_end / session_start / * session_tree / session_compact / agent_end 事件重建,确保 /undo、/redo、/tree、压缩后一致。 * 其中 agent_end 在 AI 回答完全结束后刷新一次(回答过程中不刷新,避免展开态高频抖动) * - /current-question [on|off|toggle|<数字>] 切换开关或设置条数 */ import { copyToClipboard, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; /** widget 标识 */ const WIDGET_ID = "current-question"; /** 展开时回答区固定显示的行数,超出靠 Ctrl+Alt+J/K 滚动 */ const ANSWER_VIEW_LINES = 8; /** 展开时问题全文最多显示的行数,超出截断(避免长问题撑爆 widget 高度触碰终端顶部) */ const MAX_QUESTION_LINES = 6; /** 终端底部预留给 editor/footer/status/chat 的行数,widget 高度不超过 termRows - 此值。 * 根因:流式回答时 chat 每帧重建 Markdown,行数波动;若 widget 过高挤压 chat,使正在输出的 * assistant 消息起始行滚出视口,pi 的 firstChanged> 回复” 标记行及以上区域设为只读, * 防止误改历史问答;解析时仍以最后一个 “>> 回复” 标记为准,双重保险。 * 实现:InsertEnter 阻止进入插入 + TextChanged 检测改动并 undo 恢复。 */ const NVIM_LOCK_SCRIPT = [ '" pi 外部编辑器上下文锁定脚本(nvim/vim 专属)', '" 作用:① 光标定位到最后一行行尾 ② 把 “>> 回复” 标记行及以上区域设为只读,', '" 防止误改历史问答;解析时仍以最后一个 “>> 回复” 标记为准,双重保险。', '', '" —— 1. 定位光标到文件末尾 ——', 'normal! G$', '', '" —— 2. 找到最后一个 “>> 回复” 标记行 ——', 'let g:pi_ctx_locked_end = 0', 'for s:i in range(1, line(\'$\'))', ' if getline(s:i) =~# \'^>>\\s*Reply\\s*$\'', ' let g:pi_ctx_locked_end = s:i', ' endif', 'endfor', 'if g:pi_ctx_locked_end == 0', ' finish', 'endif', '', '" —— 3. 快照 + 阻止改动 ——', 'let g:pi_ctx_snapshot = copy(getline(1, g:pi_ctx_locked_end))', '', 'function! PiCtxGuardInsert() abort', ' if line(\'.\') <= g:pi_ctx_locked_end', ' call feedkeys("\\", \'n\')', ' echohl WarningMsg | echo "The question and answer history is read-only. Edit below \'>> Reply\'." | echohl None', ' endif', 'endfunction', 'autocmd InsertEnter call PiCtxGuardInsert()', '', 'function! PiCtxCheckLocked() abort', ' if exists(\'g:pi_ctx_restoring\') | return | endif', ' if getline(1, g:pi_ctx_locked_end) != g:pi_ctx_snapshot', ' let g:pi_ctx_restoring = 1', ' undo', ' unlet g:pi_ctx_restoring', ' echohl WarningMsg | echo "Restored the modified question and answer history." | echohl None', ' endif', 'endfunction', 'autocmd TextChanged,TextChangedI call PiCtxCheckLocked()', ].join("\n") + "\n"; interface Question { text: string; round: number; answer: string; } /** * 从消息 content 中提取可显示文本。 * content 可能是 string 或 ContentBlock 数组(text / image 等), * 纯图片消息返回 "[图片]" 占位,空消息返回空串。 * 对 assistant 消息,thinking / tool_use 等非 text 块会被忽略,只取 text。 */ function extractText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; let hasImage = false; for (const block of content) { if (!block || typeof block !== "object") continue; const b = block as { type?: string; text?: string }; if (b.type === "text" && typeof b.text === "string") { parts.push(b.text); } else if (b.type === "image") { hasImage = true; } } const text = parts.join("").trim(); if (text) return text; return hasImage ? "[Image]" : ""; } /** * 单行截断:只取第一行,超长加 "...",保证可见宽度 <= maxWidth。 * 正确处理中文等双宽字符。 */ function truncateLine(text: string, maxWidth: number): string { if (maxWidth <= 0) return ""; const firstLine = text.split("\n", 1)[0] ?? ""; const ellipsis = "..."; const ellipsisWidth = visibleWidth(ellipsis); const firstLineWidth = visibleWidth(firstLine); const hasMoreLines = text.includes("\n"); if (firstLineWidth <= maxWidth && !hasMoreLines) { return firstLine; } const contentMax = Math.max(0, maxWidth - ellipsisWidth); let w = 0; let out = ""; for (const ch of firstLine) { const cw = visibleWidth(ch); if (w + cw > contentMax) break; out += ch; w += cw; } return out + ellipsis; } /** * 多行文本处理:保留原始换行,每行按 maxWidth 截断(超长加 "...")。 * 不限制总行数(由调用方 slice 滚动窗口)。 */ function wrapText(text: string, maxWidth: number): string[] { if (!text) return []; const rawLines = text.split("\n"); const result: string[] = []; for (const raw of rawLines) { result.push(truncateLine(raw, maxWidth)); } return result; } /** * 把编辑器命令字符串拆成 [command, ...args],简单处理双引号包裹的 token。 * 例:"code --wait" -> ["code","--wait"];'"C:\\Program Files\\nvim.exe"' -> ["C:\\Program Files\\nvim.exe"] */ function parseEditorCommand(cmd: string): { command: string; args: string[] } { const tokens = cmd.trim().match(/"[^"]+"|\S+/g) ?? []; if (tokens.length === 0) return { command: cmd, args: [] }; const strip = (s: string) => s.replace(/^"|"$/g, ""); return { command: strip(tokens[0]!), args: tokens.slice(1).map(strip) }; } /** * 解析外部编辑器命令。优先级与 pi 内置 openExternalEditor 一致: * settings.json 的 externalEditor > $VISUAL > $EDITOR > 平台默认(Win: notepad, Unix: nano)。 */ function resolveEditorCommand(): { command: string; args: string[] } { try { const p = path.join(os.homedir(), ".pi", "agent", "settings.json"); if (fs.existsSync(p)) { const settings = JSON.parse(fs.readFileSync(p, "utf8")) as { externalEditor?: string }; if (typeof settings.externalEditor === "string" && settings.externalEditor.trim()) { return parseEditorCommand(settings.externalEditor); } } } catch { /* 忽略,回退到环境变量 */ } if (process.env.VISUAL) return parseEditorCommand(process.env.VISUAL); if (process.env.EDITOR) return parseEditorCommand(process.env.EDITOR); return process.platform === "win32" ? { command: "notepad", args: [] } : { command: "nano", args: [] }; } /** * 构造带历史上下文的编辑器文件内容: * >> 问题 / >> 回答 / >> 回复 * 回复区放当前编辑器文本,前两节作为只读参考。 */ function buildContextFileContent(q: { text: string; answer: string }, replyText: string): string { const answer = q.answer?.trim() || "(No answer yet)"; return `${MARKER_QUESTION}\n\n${q.text}\n\n${MARKER_ANSWER}\n\n${answer}\n\n${MARKER_REPLY}\n\n${replyText}\n`; } /** * 从编辑器保存的文件内容中解析"回复"部分。 * * 规则:取【最后一个】行首严格匹配 ">> 回复" 的标记行之后的内容。 * 为何取最后一个:回复区在文件末尾,正常只有一处标记;即使历史问答里意外出现 * ">> 回复" 行,最后一个也最可能是真正的回复区分隔符,避免被历史内容误导。 * * 容错:若找不到标记(被删除/改写),返回整个内容并标记 markerFound=false, * 由调用方提示用户"已将全部内容作为回复",确保不丢内容。 */ function parseReplyFromContent(content: string): { reply: string; markerFound: boolean } { const lines = content.replace(/\r\n/g, "\n").split("\n"); let markerIdx = -1; for (let i = lines.length - 1; i >= 0; i--) { if (/^>>\s*Reply\s*$/.test(lines[i]!)) { markerIdx = i; break; } } const trimEnds = (s: string) => s.replace(/^\n+/, "").replace(/\n+$/, ""); if (markerIdx === -1) { return { reply: trimEnds(content), markerFound: false }; } return { reply: trimEnds(lines.slice(markerIdx + 1).join("\n")), markerFound: true }; } export default function (pi: ExtensionAPI) { /** 最近的问题列表(按时间正序,末尾为最新) */ let recentQuestions: Question[] = []; /** 显示最近多少条 */ let maxLines = 5; /** 是否启用置顶显示 */ let enabled = true; /** 当前轮数(已提问的 user 消息计数) */ let currentRound = 0; /** 选中索引(0=最旧,length-1=最新),默认最新 */ let selectedIndex = 0; /** 窗口起始(最旧的可见索引),窗口固定显示 maxLines 条 */ let windowStart = 0; /** 选中条是否展开(显示问题全文 + 回答) */ let expanded = false; /** 展开态下回答的滚动偏移(行) */ let answerScroll = 0; /** 缓存最近一次 render 计算的选中条回答分行,供 handler 做滚动边界判断 */ let cachedAnswerLines: string[] = []; /** 缓存最近一次 render 计算的回答窗口行数(可能因终端高度不足被压缩),供 handler 边界判断保持一致 */ let cachedAnswerView = ANSWER_VIEW_LINES; // render 指纹缓存:输入不变则返回同一数组引用,避免 pi 流式重绘时反复重算导致抖动 let lastFingerprint = ""; let cachedOutput: string[] | null = null; /** 缓存 widget 回调拿到的 TUI 实例,供 Ctrl+Alt+G 外部编辑器流程 stop/start 终端 */ let tuiRef: TuiHandle | undefined; /** 选中归位到最新一条,窗口跟随,并收起 */ const resetSelectionToNewest = () => { const n = recentQuestions.length; selectedIndex = n > 0 ? n - 1 : 0; windowStart = Math.max(0, n - maxLines); expanded = false; answerScroll = 0; }; /** 确保选中在合法范围内且在可见窗口内,必要时滚动窗口 */ const ensureVisible = () => { const n = recentQuestions.length; if (n === 0) { selectedIndex = 0; windowStart = 0; return; } if (selectedIndex < 0) selectedIndex = 0; if (selectedIndex > n - 1) selectedIndex = n - 1; if (selectedIndex < windowStart) windowStart = selectedIndex; if (selectedIndex > windowStart + maxLines - 1) { windowStart = selectedIndex - maxLines + 1; } const maxWin = Math.max(0, n - maxLines); if (windowStart < 0) windowStart = 0; if (windowStart > maxWin) windowStart = maxWin; }; /** 切换选中索引,并重置回答滚动到顶部 */ const setSelected = (idx: number) => { selectedIndex = idx; answerScroll = 0; }; /** 获取当前选中问题;会先修正越界索引,避免历史变更后复制到错误条目 */ const getSelectedQuestion = (): Question | undefined => { if (recentQuestions.length === 0) return undefined; ensureVisible(); return recentQuestions[selectedIndex]; }; /** 同时复制当前选中条的完整问题与回答 */ const copyQuestionAndAnswer = async (ctx: ExtensionContext) => { const q = getSelectedQuestion(); if (!q) { ctx.ui.notify("No question history to copy", "warning"); return; } const text = `>> Question\n\n${q.text}\n\n>> Answer\n\n${q.answer || "(No answer yet)"}`; try { await copyToClipboard(text); ctx.ui.notify(`Copied question and answer from round ${q.round}`, "info"); } catch (error) { console.error("[current-question] Failed to copy to clipboard", error); ctx.ui.notify(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error"); } }; /** 从指定分支重建 recentQuestions 与 currentRound(纯数据,不触发渲染) */ const rebuildFromBranch = (branch: unknown) => { const list: Question[] = []; let round = 0; let currentAnswer = ""; for (const entry of branch as Array< { type?: string; message?: { role?: string; content?: unknown } } | undefined >) { if (!entry || entry.type !== "message") continue; const msg = entry.message; if (!msg) continue; if (msg.role === "user") { // 新一轮开始:先把上一轮的回答存入 if (list.length > 0) { list[list.length - 1].answer = currentAnswer.trim(); } currentAnswer = ""; const text = extractText(msg.content); if (text === "") continue; round++; list.push({ text, round, answer: "" }); } else if (msg.role === "assistant") { // 累积本轮回答文本(thinking / tool_use 等非 text 块会被 extractText 忽略) const text = extractText(msg.content); if (text) { currentAnswer += (currentAnswer ? "\n" : "") + text; } } } // 存入最后一轮的回答 if (list.length > 0) { list[list.length - 1].answer = currentAnswer.trim(); } recentQuestions = list; currentRound = round; }; /** 以 sessionManager 为真相源重建,并归位选中 */ const rebuild = (ctx: ExtensionContext) => { rebuildFromBranch(ctx.sessionManager.getBranch()); resetSelectionToNewest(); refresh(ctx); }; /** 根据当前状态刷新(或清除)widget */ const refresh = (ctx: ExtensionContext) => { if (!ctx.hasUI) return; if (!enabled || recentQuestions.length === 0) { ctx.ui.setWidget(WIDGET_ID, undefined); return; } ctx.ui.setWidget(WIDGET_ID, (tui, theme) => { tuiRef = tui as unknown as TuiHandle; return { render(width: number) { // 指纹缓存:输入不变则返回同一数组引用,避免 pi 流式重绘时反复重算导致抖动。 // 含 termRows:终端 resize 时缓存失效,避免高度限制按旧高度计算。 const termRows = tui.terminal.rows; const sel = recentQuestions[selectedIndex]; const fp = `${width}|${termRows}|${currentRound}|${recentQuestions.length}|${windowStart}|${maxLines}|${selectedIndex}|${expanded}|${answerScroll}|${sel?.text ?? ""}|${expanded ? (sel?.answer ?? "") : ""}`; if (fp === lastFingerprint && cachedOutput) { return cachedOutput; } lastFingerprint = fp; // widget 高度上限:留出底部 editor/footer/status/chat 空间,避免触碰终端顶部。 const maxWidgetRows = Math.max(8, termRows - WIDGET_HEIGHT_RESERVED); const indent = " "; const indentWidth = visibleWidth(indent); const n = recentQuestions.length; const out: string[] = []; out.push(theme.fg("accent", theme.bold(`❯ Current Questions (${currentRound})`))); // 顶部指示:窗口上方还有更新的问题 const newerCount = n - (windowStart + maxLines); if (newerCount > 0) { out.push(`${theme.fg("dim", indent)}${theme.fg("dim", `↑ ${newerCount} newer`)}`); } // 可见窗口 [windowStart, windowStart+maxLines),反转使最新在上 const end = Math.min(n, windowStart + maxLines); const shown = recentQuestions.slice(windowStart, end); for (let i = shown.length - 1; i >= 0; i--) { const q = shown[i]; const chronIdx = windowStart + i; // 该条在 recentQuestions 中的真实索引 const isSelected = chronIdx === selectedIndex; if (isSelected) { const marker = "▸ "; if (!expanded) { // 收起:单行摘要(用 text 正常色,比未选中的 dim 略亮) const contentWidth = Math.max(1, width - indentWidth - visibleWidth(marker)); const line = truncateLine(q.text, contentWidth); out.push(`${theme.fg("dim", indent)}${theme.fg("accent", marker)}${theme.fg("text", line)}`); } else { // 展开:问题全文 + 回答滚动窗口 // 问题全文用 text(正常前景色),回答用 muted,与未选中的 dim 拉开层次 const bodyIndent = " "; const bodyWidth = Math.max(1, width - visibleWidth(bodyIndent)); // 问题全文限制最大行数,超出截断并提示,避免长问题撑爆 widget 高度 const rawQLines = wrapText(q.text, bodyWidth); const qTruncated = rawQLines.length > MAX_QUESTION_LINES; const qLines = qTruncated ? rawQLines.slice(0, MAX_QUESTION_LINES) : rawQLines; qLines.forEach((ln, idx) => { if (idx === 0) { out.push(`${theme.fg("dim", indent)}${theme.fg("accent", marker)}${theme.fg("text", ln)}`); } else { out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("text", ln)}`); } }); if (qTruncated) { out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", `… Question has ${rawQLines.length} lines; showing first ${MAX_QUESTION_LINES} (Ctrl+Alt+B copies all)`)}`); } out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("accent", "─ Answer ─")}`); if (q.answer) { // 全部分行并缓存,供滚动 handler 做边界判断 cachedAnswerLines = wrapText(q.answer, bodyWidth); const total = cachedAnswerLines.length; // 动态回答窗口行数:按终端高度限制 widget 总高,避免触碰顶部导致流式抖动。 // 预估除回答外的固定行数,剩余预算分配给回答窗口。 const topHintLines = newerCount > 0 ? 1 : 0; const bottomHintLines = windowStart > 0 ? 1 : 0; const otherItemLines = end - windowStart - 1; // 除选中条外其他可见条目各占 1 行 const questionLines = qLines.length + (qTruncated ? 1 : 0); const scrollHintReserve = 2; // 上下滚提示各预留 1 行(最坏情况) const nonAnswerLines = 1 /*标题*/ + topHintLines + questionLines + 1 /*回答分隔*/ + otherItemLines + bottomHintLines + scrollHintReserve; let answerView = ANSWER_VIEW_LINES; if (nonAnswerLines + answerView > maxWidgetRows) { answerView = Math.max(0, maxWidgetRows - nonAnswerLines); } cachedAnswerView = answerView; const maxScroll = Math.max(0, total - answerView); if (answerScroll > maxScroll) answerScroll = maxScroll; const viewEnd = Math.min(total, answerScroll + answerView); // 回答上方滚动提示 if (answerScroll > 0) { out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", `↑ ${answerScroll} lines above (Ctrl+Alt+K)`)}`); } for (const ln of cachedAnswerLines.slice(answerScroll, viewEnd)) { out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("muted", ln)}`); } // 回答下方滚动提示 const remain = total - viewEnd; if (remain > 0) { out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", `↓ ${remain} lines below (Ctrl+Alt+J)`)}`); } } else { out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", "(No answer yet)")}`); } } } else { const marker = "· "; const contentWidth = Math.max(1, width - indentWidth - visibleWidth(marker)); const line = truncateLine(q.text, contentWidth); out.push(`${theme.fg("dim", indent)}${theme.fg("dim", marker)}${theme.fg("dim", line)}`); } } // 底部指示:窗口下方还有更早的问题 const olderCount = windowStart; if (olderCount > 0) { out.push(`${theme.fg("dim", indent)}${theme.fg("dim", `↓ ${olderCount} older`)}`); } // 兜底:极端配置或小终端下,若 widget 总行数仍超上限,从底部截断并提示, // 确保不触碰终端顶部引发流式抖动。 if (out.length > maxWidgetRows) { const overflow = out.length - maxWidgetRows; out.length = maxWidgetRows; out.push(`${theme.fg("dim", indent)}${theme.fg("dim", `↓ ${overflow} lines hidden due to terminal height`)}`); } cachedOutput = out; return cachedOutput; }, invalidate() {}, }; }); }; // 用户发新消息:增量追加,选中归位最新并收起 // 注意:assistant 回答不在 message_end 里更新数据,统一延后到 agent_end 重建。 // 否则回答期间 last.answer 不断变化,pi 流式重绘时 render 重算会导致展开态抖动。 pi.on("message_end", async (event, ctx) => { if (event.message.role !== "user") return; const text = extractText(event.message.content); if (text === "") return; currentRound++; recentQuestions.push({ text, round: currentRound, answer: "" }); resetSelectionToNewest(); refresh(ctx); }); // 会话开始/恢复时重建 pi.on("session_start", async (_event, ctx) => { rebuild(ctx); }); // 会话树跳转:覆盖 /undo、/redo、/tree 导航 pi.on("session_tree", async (_event, ctx) => { rebuild(ctx); }); // 压缩后重建 pi.on("session_compact", async (_event, ctx) => { rebuild(ctx); }); // AI 开始回答:自动收起展开态,避免流式输出整屏重排波及 widget 区造成抖动 // (回答中展开最新条本就看不到实时回答——数据冻结到 agent_end 才更新) let restoreExpandedAfterStream = false; pi.on("agent_start", async (_event, ctx) => { if (expanded) { restoreExpandedAfterStream = true; expanded = false; refresh(ctx); } else { restoreExpandedAfterStream = false; } }); // AI 回答完全结束:恢复用户之前的展开态,并以 sessionManager 为准刷新数据 pi.on("agent_end", async (_event, ctx) => { if (recentQuestions.length === 0) return; rebuildFromBranch(ctx.sessionManager.getBranch()); if (selectedIndex > recentQuestions.length - 1) { selectedIndex = Math.max(0, recentQuestions.length - 1); } if (restoreExpandedAfterStream) { expanded = true; answerScroll = 0; restoreExpandedAfterStream = false; } refresh(ctx); }); // Ctrl+Alt+J:收起态=选更早问题;展开态=回答下滚 pi.registerShortcut("ctrl+alt+j", { description: "Current Questions: select an older question when collapsed / scroll the answer down when expanded", handler: async (ctx) => { if (recentQuestions.length === 0 || !enabled) return; if (expanded) { // 回答下滚 const total = cachedAnswerLines.length; const maxScroll = Math.max(0, total - cachedAnswerView); if (answerScroll >= maxScroll) return; // 已到底,静默停止 answerScroll++; refresh(ctx); } else { if (selectedIndex <= 0) return; // 已到最早,静默停止 setSelected(selectedIndex - 1); ensureVisible(); refresh(ctx); } }, }); // Ctrl+Alt+K:收起态=选更新问题;展开态=回答上滚 pi.registerShortcut("ctrl+alt+k", { description: "Current Questions: select a newer question when collapsed / scroll the answer up when expanded", handler: async (ctx) => { if (recentQuestions.length === 0 || !enabled) return; if (expanded) { // 回答上滚 if (answerScroll <= 0) return; // 已到顶,静默停止 answerScroll--; refresh(ctx); } else { if (selectedIndex >= recentQuestions.length - 1) return; // 已到最新,静默停止 setSelected(selectedIndex + 1); ensureVisible(); refresh(ctx); } }, }); // Ctrl+Alt+C:同时复制当前选中条的完整问题与回答 pi.registerShortcut("ctrl+alt+b", { description: "Current Questions: copy the selected question and answer", handler: async (ctx) => { await copyQuestionAndAnswer(ctx); }, }); // Ctrl+Alt+L:隐藏态=重新显示;显示态=展开当前选中条(重置回答滚动到顶部) pi.registerShortcut("ctrl+alt+l", { description: "Current Questions: show when hidden / expand the selected question and answer when visible", handler: async (ctx) => { if (recentQuestions.length === 0) return; if (!enabled) { enabled = true; expanded = false; ensureVisible(); refresh(ctx); return; } expanded = true; answerScroll = 0; refresh(ctx); }, }); // Ctrl+Alt+H:展开态=收起当前选中条;收起态=隐藏当前问题 pi.registerShortcut("ctrl+alt+h", { description: "Current Questions: collapse the selected item when expanded / hide the widget when collapsed", handler: async (ctx) => { if (expanded) { expanded = false; } else { enabled = false; } refresh(ctx); }, }); // Ctrl+Alt+G:带选中问答上下文打开外部编辑器编辑回复 // 选中条的问题+回答作为参考写入文件顶部(>> 问题 / >> 回答),当前输入框内容置于 >> 回复 下; // 编辑器保存退出后只把 ">> 回复" 之后的内容回填输入框。 // 终端管理复用 pi 内置 openExternalEditor 的方式:tui.stop() 释放终端、异步 spawn 编辑器、 // 退出后 tui.start() 恢复。用异步 spawn 而非 spawnSync,避免 Windows 上 console input 抢占。 pi.registerShortcut("ctrl+alt+g", { description: "Current Questions: open an external editor with selected question-and-answer context", handler: async (ctx) => { if (!enabled || recentQuestions.length === 0) { ctx.ui.notify("No question history available for context-aware editing", "warning"); return; } const q = getSelectedQuestion(); if (!q) { ctx.ui.notify("No history item is selected", "warning"); return; } const tui = tuiRef; if (!tui?.stop || !tui?.start) { ctx.ui.notify("External editor workflow is unavailable in this environment", "error"); return; } const replyText = ctx.ui.getEditorText(); const fileContent = buildContextFileContent(q, replyText); // 写临时文件(.md 触发 markdown 语法高亮) const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const tmpFile = path.join(os.tmpdir(), `pi-context-${stamp}.md`); const tempFiles: string[] = [tmpFile]; const cleanupTemp = () => { for (const f of tempFiles) { try { fs.unlinkSync(f); } catch { /* ignore */ } } }; try { fs.writeFileSync(tmpFile, fileContent, "utf8"); } catch (e) { ctx.ui.notify(`Failed to write temporary file: ${e instanceof Error ? e.message : String(e)}`, "error"); return; } const { command, args } = resolveEditorCommand(); // vi 系列编辑器(nvim/vim 等)专属增强:追加锁定脚本,把 “>> 回复” 及以上区域 // 设为只读防止误改历史,并把光标定位到文件末尾。非 vi 编辑器保持兼容。 const editorBase = path.basename(command).toLowerCase(); const isViFamily = /^(n?vim|gvim|vi|ex)(\.exe)?$/.test(editorBase); let editorArgs: string[]; if (isViFamily) { const lockFile = path.join(os.tmpdir(), `pi-context-lock-${stamp}.vim`); try { fs.writeFileSync(lockFile, NVIM_LOCK_SCRIPT, "utf8"); tempFiles.push(lockFile); editorArgs = ["-S", lockFile, tmpFile]; } catch { // 写锁定脚本失败:退化为仅定位光标(不加锁定) editorArgs = [...args, "+normal! G$", tmpFile]; } } else { editorArgs = [...args, tmpFile]; } // stop 前直接写 stdout 提示(TUI 停止后 notify 不可见) process.stdout.write(`Opening ${command} to edit the reply with context from round ${q.round}…\nPi will resume when the editor exits.\n`); let status: number | null = null; let spawnErr: Error | null = null; tui.stop(); try { status = await new Promise((resolve) => { const child = spawn(command, editorArgs, { stdio: "inherit", shell: process.platform === "win32", }); child.on("error", (err) => { spawnErr = err; resolve(null); }); child.on("close", (code) => resolve(code)); }); } finally { tui.start(); tui.requestRender(true); } if (spawnErr) { ctx.ui.notify(`Failed to launch editor: ${spawnErr.message}`, "error"); cleanupTemp(); return; } if (status !== 0) { // 非正常退出:保留原输入框内容不变 ctx.ui.notify(`Editor exited with code ${status}; editor contents were not changed`, "warning"); cleanupTemp(); return; } // 读取并解析回复 let newContent: string; try { newContent = fs.readFileSync(tmpFile, "utf8"); } catch (e) { ctx.ui.notify(`Failed to read temporary file: ${e instanceof Error ? e.message : String(e)}`, "error"); cleanupTemp(); return; } cleanupTemp(); const { reply, markerFound } = parseReplyFromContent(newContent); ctx.ui.setEditorText(reply); if (!markerFound) { ctx.ui.notify("⚠️ Could not find the '>> Reply' marker; used the entire file as the reply", "warning"); } else { ctx.ui.notify(`Updated the editor with context from round ${q.round}`, "info"); } }, }); // /current-question [on|off|toggle|<数字>] pi.registerCommand("current-question", { description: "Current Questions: use on/off/toggle to change visibility, or a number to set the displayed count (default: 5)", handler: async (args, ctx) => { const arg = args.trim().toLowerCase(); if (arg === "on") { enabled = true; } else if (arg === "off") { enabled = false; } else if (arg === "toggle" || arg === "") { enabled = !enabled; } else if (/^\d+$/.test(arg)) { const n = parseInt(arg, 10); maxLines = Math.max(1, n); enabled = true; ensureVisible(); } else { ctx.ui.notify("Usage: /current-question [on|off|toggle|]", "warning"); return; } refresh(ctx); ctx.ui.notify(`Current Questions: ${enabled ? "on" : "off"}; displaying ${maxLines} item(s)`, "info"); }, }); }