From 8d04971ac150692751248355b1677572a68af78b Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:13:49 -0600 Subject: [PATCH] feat: add /voice extension for real-time speech-to-text - macOS-only (SFSpeechRecognizer), no-op on other platforms - /voice command and Ctrl+Alt+V shortcut to toggle - Streams partial transcription results directly into editor input - Custom footer with flashing red dot + 'transcribing' indicator on row 1 - Enter to stop and keep text, Esc to cancel - Ships precompiled Swift binary (60KB) --- src/resources/extensions/voice/index.ts | 176 ++++++++++++++++++ .../extensions/voice/speech-recognizer | Bin 0 -> 60736 bytes .../extensions/voice/speech-recognizer.swift | 76 ++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/resources/extensions/voice/index.ts create mode 100755 src/resources/extensions/voice/speech-recognizer create mode 100644 src/resources/extensions/voice/speech-recognizer.swift diff --git a/src/resources/extensions/voice/index.ts b/src/resources/extensions/voice/index.ts new file mode 100644 index 000000000..c99400767 --- /dev/null +++ b/src/resources/extensions/voice/index.ts @@ -0,0 +1,176 @@ +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as path from "node:path"; +import * as readline from "node:readline"; + +const RECOGNIZER_BIN = path.join(__dirname, "speech-recognizer"); + +export default function (pi: ExtensionAPI) { + if (process.platform !== "darwin") return; + + let active = false; + let recognizerProcess: ChildProcess | null = null; + let finalized = ""; + let flashOn = true; + let flashTimer: ReturnType | null = null; + let footerTui: { requestRender: () => void } | null = null; + + function setVoiceFooter(ctx: ExtensionContext, on: boolean) { + if (!on) { + stopFlash(); + ctx.ui.setFooter(undefined); + return; + } + + flashOn = true; + flashTimer = setInterval(() => { + flashOn = !flashOn; + footerTui?.requestRender(); + }, 500); + + ctx.ui.setFooter((tui, theme, footerData) => { + footerTui = tui; + const branchUnsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + dispose: branchUnsub, + invalidate() {}, + render(width: number): string[] { + // --- Row 1: pwd (branch) ... ● transcribing --- + let pwd = process.cwd(); + const home = process.env.HOME || process.env.USERPROFILE; + if (home && pwd.startsWith(home)) pwd = `~${pwd.slice(home.length)}`; + const branch = footerData.getGitBranch(); + if (branch) pwd = `${pwd} (${branch})`; + + const dot = flashOn ? theme.fg("error", "●") : theme.fg("dim", "●"); + const voiceTag = `${dot} ${theme.fg("error", "transcribing")}`; + const voiceTagWidth = visibleWidth(voiceTag); + + const maxPwdWidth = width - voiceTagWidth - 2; + const pwdStr = truncateToWidth(theme.fg("dim", pwd), maxPwdWidth, theme.fg("dim", "...")); + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(pwdStr) - voiceTagWidth)); + const row1 = truncateToWidth(pwdStr + pad1 + voiceTag, width); + + // --- Row 2: stats ... model (replicate default) --- + let totalInput = 0, totalOutput = 0, totalCost = 0; + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + totalInput += m.usage.input; + totalOutput += m.usage.output; + totalCost += m.usage.cost.total; + } + } + + const fmt = (n: number) => n < 1000 ? `${n}` : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`; + const parts: string[] = []; + if (totalInput) parts.push(`↑${fmt(totalInput)}`); + if (totalOutput) parts.push(`↓${fmt(totalOutput)}`); + if (totalCost) parts.push(`$${totalCost.toFixed(3)}`); + + const usage = ctx.getContextUsage(); + const ctxPct = usage?.percent !== null && usage?.percent !== undefined ? `${usage.percent.toFixed(1)}%` : "?"; + const ctxWin = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0; + parts.push(`${ctxPct}/${fmt(ctxWin)}`); + + const statsLeft = theme.fg("dim", parts.join(" ")); + const modelRight = theme.fg("dim", ctx.model?.id || "no-model"); + const statsLeftW = visibleWidth(statsLeft); + const modelRightW = visibleWidth(modelRight); + const pad2 = " ".repeat(Math.max(2, width - statsLeftW - modelRightW)); + const row2 = truncateToWidth(statsLeft + pad2 + modelRight, width); + + return [row1, row2]; + }, + }; + }); + } + + function stopFlash() { + if (flashTimer) { clearInterval(flashTimer); flashTimer = null; } + footerTui = null; + } + + async function toggleVoice(ctx: ExtensionContext) { + if (active) { + killRecognizer(); + active = false; + setVoiceFooter(ctx, false); + return; + } + + active = true; + finalized = ""; + setVoiceFooter(ctx, true); + await runVoiceSession(ctx); + } + + pi.registerCommand("voice", { + description: "Toggle voice mode", + handler: async (_args, ctx) => toggleVoice(ctx), + }); + + pi.registerShortcut("ctrl+alt+v", { + description: "Toggle voice mode", + handler: async (ctx) => toggleVoice(ctx), + }); + + function killRecognizer() { + if (recognizerProcess) { recognizerProcess.kill("SIGTERM"); recognizerProcess = null; } + } + + function startRecognizer( + onPartial: (text: string) => void, + onFinal: (text: string) => void, + onError: (msg: string) => void, + onReady: () => void, + ) { + recognizerProcess = spawn(RECOGNIZER_BIN, [], { stdio: ["pipe", "pipe", "pipe"] }); + const rl = readline.createInterface({ input: recognizerProcess.stdout! }); + rl.on("line", (line: string) => { + if (line === "READY") { onReady(); return; } + if (line.startsWith("PARTIAL:")) onPartial(line.slice(8)); + else if (line.startsWith("FINAL:")) onFinal(line.slice(6)); + else if (line.startsWith("ERROR:")) onError(line.slice(6)); + }); + recognizerProcess.on("error", (err) => onError(err.message)); + recognizerProcess.on("exit", () => { recognizerProcess = null; }); + } + + async function runVoiceSession(ctx: ExtensionContext): Promise { + return new Promise((resolve) => { + startRecognizer( + (text) => { + const full = finalized + (finalized && text ? " " : "") + text; + ctx.ui.setEditorText(full); + }, + (text) => { + finalized = (finalized ? finalized + " " : "") + text; + ctx.ui.setEditorText(finalized); + }, + (msg) => ctx.ui.notify(`Voice: ${msg}`, "error"), + () => {}, + ); + + ctx.ui.custom( + (_tui, _theme, _kb, done) => ({ + render(): string[] { return []; }, + handleInput(data: string) { + if (isKeyRelease(data)) return; + if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) { + killRecognizer(); + active = false; + setVoiceFooter(ctx, false); + done(); + } + }, + invalidate() {}, + }), + { overlay: true, overlayOptions: { anchor: "bottom-center", width: "100%" } }, + ).then(() => resolve()); + }); + } +} diff --git a/src/resources/extensions/voice/speech-recognizer b/src/resources/extensions/voice/speech-recognizer new file mode 100755 index 0000000000000000000000000000000000000000..9251292d90f1b4aeb86382f43c884796c922046c GIT binary patch literal 60736 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