@p8n.ai/pi-listens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+ import { Type } from "typebox";
7
+ import type { AudioRuntime } from "./audio.js";
8
+ import { audioExtensionForCodec, type PiListensConfig } from "./config.js";
9
+ import type { SarvamSpeechClient, TranscriptionResult } from "./sarvam.js";
10
+ import { conciseTranscript } from "./text.js";
11
+
12
+ export interface VoiceToolServices {
13
+ getConfig: () => PiListensConfig;
14
+ getAudio: () => AudioRuntime;
15
+ getSpeech: () => SarvamSpeechClient;
16
+ }
17
+
18
+ const VoiceOutputParams = Type.Object({
19
+ text: Type.String({ description: "Short text to speak to the user. Keep it concise; do not speak code blocks or long logs." }),
20
+ wait_for_playback: Type.Optional(Type.Boolean({ description: "Wait until audio playback completes before returning. Default true." })),
21
+ });
22
+
23
+ const VoiceInputParams = Type.Object({
24
+ seconds: Type.Optional(Type.Number({ description: "Maximum listening time in seconds. Streaming STT stops earlier after a sustained pause.", minimum: 1, maximum: 3600 })),
25
+ text_fallback: Type.Optional(Type.Boolean({ description: "If speech is not recognized and UI is available, ask the user to type. Default true." })),
26
+ });
27
+
28
+ const VoiceAskParams = Type.Object({
29
+ question: Type.String({ description: "Short question to speak to the user before listening." }),
30
+ seconds: Type.Optional(Type.Number({ description: "Maximum listening time in seconds. Streaming STT stops earlier after a sustained pause.", minimum: 1, maximum: 3600 })),
31
+ text_fallback: Type.Optional(Type.Boolean({ description: "If speech is not recognized and UI is available, ask the user to type. Default true." })),
32
+ });
33
+
34
+ const VoiceTranscribeParams = Type.Object({
35
+ path: Type.String({ description: "Path to an audio file to transcribe with Sarvam AI." }),
36
+ });
37
+
38
+ const SetupCheckParams = Type.Object({});
39
+
40
+ type VoiceOutputInput = { text: string; wait_for_playback?: boolean };
41
+ type VoiceInputInput = { seconds?: number; text_fallback?: boolean };
42
+ type VoiceAskInput = { question: string; seconds?: number; text_fallback?: boolean };
43
+ type VoiceTranscribeInput = { path: string };
44
+
45
+ export function registerVoiceTools(pi: ExtensionAPI, services: VoiceToolServices) {
46
+ pi.registerTool({
47
+ name: "voice_output",
48
+ label: "Voice Output",
49
+ description: "Speak a short message to the user using Sarvam AI text-to-speech and local audio playback.",
50
+ promptSnippet: "Speak short user-facing messages with Sarvam AI TTS",
51
+ promptGuidelines: [
52
+ "Use voice_output when a spoken user-facing message matters, especially before waiting for voice input.",
53
+ "Keep voice_output text brief and conversational; do not speak code blocks, command output, stack traces, or long explanations.",
54
+ ],
55
+ parameters: VoiceOutputParams,
56
+ async execute(_toolCallId, params: VoiceOutputInput, signal, onUpdate) {
57
+ onUpdate?.({ content: [{ type: "text", text: "Synthesizing speech with Sarvam AI…" }], details: {} });
58
+ const result = await speak(params.text, services, signal);
59
+ const playback = services.getAudio().play(result.path, signal).finally(() => services.getAudio().cleanup(result.path));
60
+ if (params.wait_for_playback === false) {
61
+ void playback.catch(() => undefined);
62
+ return {
63
+ content: [{ type: "text", text: `Started speaking to user: ${params.text}` }],
64
+ details: { ...result, played: "started", text: params.text },
65
+ };
66
+ }
67
+ onUpdate?.({ content: [{ type: "text", text: "Playing audio…" }], details: {} });
68
+ await playback;
69
+ return {
70
+ content: [{ type: "text", text: `Spoke to user: ${params.text}` }],
71
+ details: { ...result, played: true, text: params.text },
72
+ };
73
+ },
74
+ renderCall(args: VoiceOutputInput, theme) {
75
+ return new Text(`${theme.fg("toolTitle", theme.bold("voice_output "))}${theme.fg("muted", quote(args.text))}`, 0, 0);
76
+ },
77
+ renderResult(result, _options, theme) {
78
+ const details = result.details as { text?: string; played?: boolean | "started" } | undefined;
79
+ const label = details?.played === "started" ? "speaking" : details?.played === false ? "prepared" : "spoke";
80
+ return new Text(`${theme.fg("success", "✓")} ${label}${details?.text ? ` ${theme.fg("dim", quote(details.text))}` : ""}`, 0, 0);
81
+ },
82
+ });
83
+
84
+ pi.registerTool({
85
+ name: "voice_input",
86
+ label: "Voice Input",
87
+ description: "Listen to the user's microphone, transcribe speech with Sarvam AI, and return the transcript. Use only after the user knows you are listening.",
88
+ promptSnippet: "Listen to microphone and transcribe user speech with Sarvam AI STT",
89
+ promptGuidelines: [
90
+ "Use voice_input only after the user has been told you are listening; if you need to ask a question, prefer voice_ask.",
91
+ "Treat voice_input transcripts as user input. If the transcript is empty, ask again or provide a text fallback.",
92
+ ],
93
+ parameters: VoiceInputParams,
94
+ async execute(_toolCallId, params: VoiceInputInput, signal, onUpdate, ctx) {
95
+ const answer = await listenAndMaybeFallback(params, services, signal, onUpdate, ctx, "I did not catch that. Type your response:");
96
+ return transcriptResult(answer, "User said");
97
+ },
98
+ renderCall(args: VoiceInputInput, theme) {
99
+ return new Text(`${theme.fg("toolTitle", theme.bold("voice_input "))}${theme.fg("muted", `${args.seconds ?? services.getConfig().recordSeconds}s`)}`, 0, 0);
100
+ },
101
+ renderResult(result, _options, theme) {
102
+ const details = result.details as { transcript?: string; fromTextFallback?: boolean } | undefined;
103
+ const prefix = details?.fromTextFallback ? "typed" : "heard";
104
+ return new Text(`${theme.fg("success", "✓")} ${prefix}: ${theme.fg("accent", details?.transcript ?? "")}`, 0, 0);
105
+ },
106
+ });
107
+
108
+ pi.registerTool({
109
+ name: "voice_ask",
110
+ label: "Voice Ask",
111
+ description: "Speak a question with Sarvam AI TTS, then listen to the microphone and transcribe the user's answer with Sarvam AI STT.",
112
+ promptSnippet: "Ask the user a spoken question and listen for the answer",
113
+ promptGuidelines: [
114
+ "Use voice_ask whenever you need clarification, confirmation, or any user input in a voice-first session; do not ask only in text.",
115
+ "Make voice_ask questions concise and answerable in one short spoken response.",
116
+ ],
117
+ parameters: VoiceAskParams,
118
+ async execute(_toolCallId, params: VoiceAskInput, signal, onUpdate, ctx) {
119
+ onUpdate?.({ content: [{ type: "text", text: "Speaking question…" }], details: {} });
120
+ const spoken = await speak(params.question, services, signal);
121
+ try {
122
+ await services.getAudio().play(spoken.path, signal);
123
+ } finally {
124
+ await services.getAudio().cleanup(spoken.path);
125
+ }
126
+ const answer = await listenAndMaybeFallback(
127
+ params,
128
+ services,
129
+ signal,
130
+ onUpdate,
131
+ ctx,
132
+ "I did not catch your spoken answer. Type your response:",
133
+ );
134
+ return transcriptResult({ ...answer, question: params.question }, "User answered");
135
+ },
136
+ renderCall(args: VoiceAskInput, theme) {
137
+ return new Text(`${theme.fg("toolTitle", theme.bold("voice_ask "))}${theme.fg("muted", quote(args.question))}`, 0, 0);
138
+ },
139
+ renderResult(result, _options, theme) {
140
+ const details = result.details as { transcript?: string; fromTextFallback?: boolean } | undefined;
141
+ const prefix = details?.fromTextFallback ? "typed" : "answered";
142
+ return new Text(`${theme.fg("success", "✓")} ${prefix}: ${theme.fg("accent", details?.transcript ?? "")}`, 0, 0);
143
+ },
144
+ });
145
+
146
+ pi.registerTool({
147
+ name: "voice_transcribe_file",
148
+ label: "Voice Transcribe File",
149
+ description: "Transcribe an existing audio file with Sarvam AI speech-to-text.",
150
+ parameters: VoiceTranscribeParams,
151
+ async execute(_toolCallId, params: VoiceTranscribeInput, signal) {
152
+ const path = params.path.startsWith("@") ? params.path.slice(1) : params.path;
153
+ const result = await services.getSpeech().transcribeFile(path, signal);
154
+ return transcriptResult({ ...result, audioPath: path, fromTextFallback: false }, "Transcript");
155
+ },
156
+ });
157
+
158
+ pi.registerTool({
159
+ name: "voice_setup_check",
160
+ label: "Voice Setup Check",
161
+ description: "Check pi-listens Sarvam AI key, microphone recorder, audio player, and default voice settings.",
162
+ parameters: SetupCheckParams,
163
+ async execute() {
164
+ const config = services.getConfig();
165
+ const audio = services.getAudio().describe();
166
+ const ok = Boolean(config.apiKey) && audio.recorder !== "missing" && audio.player !== "missing";
167
+ return {
168
+ content: [
169
+ {
170
+ type: "text",
171
+ text: [
172
+ ok ? "pi-listens setup looks ready." : "pi-listens setup needs attention.",
173
+ `Sarvam API key: ${config.apiKey ? "set" : "missing"}`,
174
+ `Recorder: ${audio.recorder}`,
175
+ `Player: ${audio.player}`,
176
+ `STT: ${config.sttModel} (${config.translateInputToEnglish ? "translate→English" : config.sttMode}, ${config.sttLanguageCode})`,
177
+ `TTS: ${config.ttsModel} (${config.ttsLanguageCode}, speaker ${config.ttsSpeaker})`,
178
+ ].join("\n"),
179
+ },
180
+ ],
181
+ details: { ok, config: { ...config, apiKey: config.apiKey ? "set" : "missing" }, audio },
182
+ };
183
+ },
184
+ });
185
+ }
186
+
187
+ async function speak(text: string, services: VoiceToolServices, signal?: AbortSignal) {
188
+ const config = services.getConfig();
189
+ await mkdir(config.audioDir, { recursive: true });
190
+ const path = join(config.audioDir, `pi-listens-output-${Date.now()}-${randomUUID()}.${audioExtensionForCodec(config.ttsOutputCodec)}`);
191
+ return services.getSpeech().synthesizeToFile(text, path, signal);
192
+ }
193
+
194
+ async function listenAndMaybeFallback(
195
+ params: { seconds?: number; text_fallback?: boolean },
196
+ services: VoiceToolServices,
197
+ signal: AbortSignal | undefined,
198
+ onUpdate: AgentToolUpdateCallback<unknown> | undefined,
199
+ ctx: ExtensionContext,
200
+ fallbackPrompt: string,
201
+ ): Promise<TranscriptionResult & { audioPath?: string; fromTextFallback: boolean }> {
202
+ const config = services.getConfig();
203
+ const seconds = clampSeconds(params.seconds ?? config.recordSeconds);
204
+ onUpdate?.({ content: [{ type: "text", text: `Streaming microphone audio to Sarvam for up to ${seconds}s…` }], details: {} });
205
+ const result = await services.getSpeech().transcribeMicrophone(services.getAudio(), signal, {
206
+ seconds,
207
+ mode: config.translateInputToEnglish ? "translate" : config.sttMode,
208
+ });
209
+ if (result.transcript.trim()) return { ...result, fromTextFallback: false };
210
+
211
+ const shouldFallback = params.text_fallback ?? config.textFallback;
212
+ if (shouldFallback && ctx.hasUI) {
213
+ const typed = await ctx.ui.input(fallbackPrompt, "Type here if speech was not recognized");
214
+ if (typed?.trim()) return { transcript: typed.trim(), fromTextFallback: true };
215
+ }
216
+
217
+ return { ...result, fromTextFallback: false };
218
+ }
219
+
220
+ function transcriptResult(
221
+ result: TranscriptionResult & { audioPath?: string; fromTextFallback?: boolean; question?: string },
222
+ label: string,
223
+ ) {
224
+ return {
225
+ content: [
226
+ {
227
+ type: "text" as const,
228
+ text: `${label}: ${conciseTranscript(result.transcript)}`,
229
+ },
230
+ ],
231
+ details: {
232
+ transcript: result.transcript,
233
+ languageCode: result.languageCode,
234
+ languageProbability: result.languageProbability,
235
+ requestId: result.requestId,
236
+ audioPath: result.audioPath,
237
+ fromTextFallback: result.fromTextFallback ?? false,
238
+ question: result.question,
239
+ },
240
+ };
241
+ }
242
+
243
+ function clampSeconds(seconds: number): number {
244
+ if (!Number.isFinite(seconds)) return 300;
245
+ return Math.max(1, Math.min(3600, Math.round(seconds)));
246
+ }
247
+
248
+ function quote(value: string | undefined): string {
249
+ if (!value) return "";
250
+ const singleLine = value.replace(/\s+/g, " ").trim();
251
+ return `“${singleLine.length > 120 ? `${singleLine.slice(0, 117)}…` : singleLine}”`;
252
+ }
@@ -0,0 +1,350 @@
1
+ import { CustomEditor, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { VoiceModeState } from "./commands.js";
4
+
5
+ type EditorFactory = ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
6
+
7
+ export interface VoiceUiCallbacks {
8
+ startListening: () => void;
9
+ disable: () => void;
10
+ toggleSpeak: () => void;
11
+ toggleAutoListen: () => void;
12
+ }
13
+
14
+ const ORB_WIDTH = 38;
15
+ const ORB_HEIGHT = 18;
16
+ const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
17
+ const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1006l";
18
+
19
+ type OrbShockwave = { frame: number; x: number; y: number; strength: number };
20
+ type SgrMouseEvent = { button: number; col: number; row: number; pressed: boolean };
21
+
22
+ export function installVoiceUi(ctx: ExtensionContext, state: VoiceModeState, callbacks: VoiceUiCallbacks) {
23
+ if (!ctx.hasUI || state.uiInstalled) return;
24
+ state.previousEditorFactory = ctx.ui.getEditorComponent() as EditorFactory;
25
+ state.uiInstalled = true;
26
+ ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => new VoiceLoopEditor(tui, editorTheme, keybindings, state, callbacks, ctx.ui.theme));
27
+ applyVoiceChrome(ctx, state);
28
+ }
29
+
30
+ export function uninstallVoiceUi(ctx: ExtensionContext, state: VoiceModeState) {
31
+ if (!ctx.hasUI) return;
32
+ disableTerminalMouseInput();
33
+ ctx.ui.setEditorComponent((state.previousEditorFactory as EditorFactory | undefined) ?? undefined);
34
+ ctx.ui.setWidget("pi-listens", undefined);
35
+ ctx.ui.setWorkingIndicator();
36
+ ctx.ui.setWorkingMessage();
37
+ ctx.ui.setStatus("pi-listens", undefined);
38
+ state.uiInstalled = false;
39
+ state.previousEditorFactory = undefined;
40
+ }
41
+
42
+ export function applyVoiceChrome(ctx: ExtensionContext, state: VoiceModeState) {
43
+ if (!ctx.hasUI) return;
44
+ const status = state.enabled
45
+ ? state.status === "listening"
46
+ ? "listening…"
47
+ : state.status === "agent"
48
+ ? "agent working"
49
+ : state.autoSpeakAssistant
50
+ ? "voice on + speak"
51
+ : "voice on"
52
+ : "voice ready";
53
+ ctx.ui.setStatus("pi-listens", status);
54
+ if (!state.enabled) return;
55
+ ctx.ui.setWorkingIndicator({
56
+ frames: state.status === "listening" ? [ctx.ui.theme.fg("accent", "●"), ctx.ui.theme.fg("muted", "•")] : [ctx.ui.theme.fg("accent", "◌")],
57
+ intervalMs: 250,
58
+ });
59
+ }
60
+
61
+ class VoiceLoopEditor extends CustomEditor {
62
+ private animationTimer?: ReturnType<typeof setInterval>;
63
+ private frame = 0;
64
+ private shockwaves: OrbShockwave[] = [];
65
+ private lastRenderWidth = 80;
66
+ private lastRenderLineCount = 0;
67
+ private mouseEnabled = false;
68
+
69
+ constructor(
70
+ tui: ConstructorParameters<typeof CustomEditor>[0],
71
+ theme: ConstructorParameters<typeof CustomEditor>[1],
72
+ keybindings: ConstructorParameters<typeof CustomEditor>[2],
73
+ private readonly loopState: VoiceModeState,
74
+ private readonly callbacks: VoiceUiCallbacks,
75
+ private readonly voiceTheme: any,
76
+ ) {
77
+ super(tui, theme, keybindings);
78
+ this.animationTimer = setInterval(() => {
79
+ this.frame++;
80
+ this.tui.requestRender();
81
+ }, frameIntervalForStatus(this.loopState.status));
82
+ this.enableMouseInput();
83
+ }
84
+
85
+ handleInput(data: string): void {
86
+ const mouse = parseSgrMouse(data);
87
+ if (mouse) {
88
+ if (mouse.pressed && mouse.button === 0) this.triggerMouseOrbClick(mouse);
89
+ return;
90
+ }
91
+ if (data.toLowerCase() === "r") {
92
+ this.triggerOrbClick(1);
93
+ this.callbacks.startListening();
94
+ return;
95
+ }
96
+ if (data.toLowerCase() === "s") {
97
+ this.triggerOrbClick(0.5, -0.18, 0.12);
98
+ this.callbacks.toggleSpeak();
99
+ return;
100
+ }
101
+ if (data.toLowerCase() === "a") {
102
+ this.triggerOrbClick(0.65, 0.18, 0.1);
103
+ this.callbacks.toggleAutoListen();
104
+ return;
105
+ }
106
+ if (data.toLowerCase() === "q") {
107
+ this.triggerOrbClick(0.4, 0, 0.18);
108
+ this.callbacks.disable();
109
+ return;
110
+ }
111
+ super.handleInput(data);
112
+ }
113
+
114
+ dispose(): void {
115
+ if (this.animationTimer) {
116
+ clearInterval(this.animationTimer);
117
+ this.animationTimer = undefined;
118
+ }
119
+ this.disableMouseInput();
120
+ }
121
+
122
+ render(width: number): string[] {
123
+ this.lastRenderWidth = width;
124
+ const lines: string[] = [];
125
+ const addCentered = (text = "") => lines.push(center(text, width));
126
+ const palette = paletteForStatus(this.loopState.status);
127
+ this.shockwaves = this.shockwaves.filter((wave) => this.frame - wave.frame < 40);
128
+
129
+ lines.push("");
130
+ for (const orbLine of animatedGlowingOrb(palette, this.loopState.status, this.frame, this.shockwaves)) addCentered(orbLine);
131
+ addCentered(compactStatus(this.loopState.status, palette, this.frame));
132
+ addCentered(color(palette.dim, "any language → English"));
133
+ lines.push("");
134
+ for (const line of controlRail(this.loopState, palette, width)) addCentered(line);
135
+ if (this.loopState.lastError) {
136
+ lines.push("");
137
+ addCentered(truncateToWidth(color(paletteForStatus("error").fg, this.loopState.lastError), Math.max(10, width - 4)));
138
+ }
139
+ this.lastRenderLineCount = lines.length;
140
+ return lines;
141
+ }
142
+
143
+ private triggerMouseOrbClick(mouse: SgrMouseEvent): void {
144
+ const centerCol = Math.max(1, this.lastRenderWidth / 2);
145
+ let x = clamp((mouse.col - centerCol) / (ORB_WIDTH / 2), -0.95, 0.95);
146
+
147
+ const terminalRows = process.stdout.rows ?? this.lastRenderLineCount;
148
+ const approximateTop = Math.max(1, terminalRows - this.lastRenderLineCount - 2);
149
+ const orbCenterRow = approximateTop + 1 + (ORB_HEIGHT - 1) / 2;
150
+ let y = clamp((mouse.row - orbCenterRow) / ((ORB_HEIGHT - 1) / 2), -0.95, 0.95);
151
+
152
+ // Terminal mouse coordinates are global, while the extension API does not expose
153
+ // the editor's exact row. If the estimate misses, still give a centered response.
154
+ if (!Number.isFinite(x)) x = 0;
155
+ if (!Number.isFinite(y) || Math.abs(mouse.row - orbCenterRow) > ORB_HEIGHT) y = 0;
156
+
157
+ this.triggerOrbClick(1.25, x, y, true);
158
+ }
159
+
160
+ private enableMouseInput(): void {
161
+ if (this.mouseEnabled || !process.stdout.isTTY) return;
162
+ process.stdout.write(MOUSE_ENABLE);
163
+ this.mouseEnabled = true;
164
+ }
165
+
166
+ private disableMouseInput(): void {
167
+ if (!this.mouseEnabled) return;
168
+ disableTerminalMouseInput();
169
+ this.mouseEnabled = false;
170
+ }
171
+
172
+ private triggerOrbClick(strength: number, x = 0, y = 0, burst = false): void {
173
+ this.shockwaves.push({ frame: this.frame, x, y, strength });
174
+ if (burst) {
175
+ this.shockwaves.push({ frame: this.frame - 3, x: x * 0.45, y: y * 0.45, strength: strength * 0.55 });
176
+ this.shockwaves.push({ frame: this.frame - 7, x: -x * 0.28, y: -y * 0.28, strength: strength * 0.32 });
177
+ }
178
+ this.shockwaves = this.shockwaves.slice(-8);
179
+ this.tui.requestRender();
180
+ }
181
+ }
182
+
183
+ function compactStatus(status: VoiceModeState["status"], palette: OrbPalette, frame = 0): string {
184
+ const labels: Record<VoiceModeState["status"], string> = {
185
+ idle: "ready",
186
+ listening: "listening",
187
+ transcribing: "English",
188
+ agent: "working",
189
+ speaking: "speaking",
190
+ error: "attention",
191
+ };
192
+ return shimmer(labels[status], palette, frame);
193
+ }
194
+
195
+ type OrbPalette = { fg: string; bright: string; soft: string; dim: string };
196
+
197
+ function paletteForStatus(status: VoiceModeState["status"]): OrbPalette {
198
+ switch (status) {
199
+ case "listening":
200
+ return { fg: "38;2;80;220;255", bright: "38;2;180;245;255", soft: "38;2;50;140;255", dim: "38;2;24;75;130" };
201
+ case "transcribing":
202
+ return { fg: "38;2;255;209;102", bright: "38;2;255;238;170", soft: "38;2;245;158;11", dim: "38;2;120;83;25" };
203
+ case "agent":
204
+ return { fg: "38;2;167;139;250", bright: "38;2;216;180;254", soft: "38;2;124;58;237", dim: "38;2;76;29;149" };
205
+ case "speaking":
206
+ return { fg: "38;2;255;120;210", bright: "38;2;255;200;240", soft: "38;2;219;39;119", dim: "38;2;131;24;67" };
207
+ case "error":
208
+ return { fg: "38;2;255;107;107", bright: "38;2;255;190;190", soft: "38;2;220;38;38", dim: "38;2;127;29;29" };
209
+ default:
210
+ return { fg: "38;2;94;234;212", bright: "38;2;204;251;241", soft: "38;2;20;184;166", dim: "38;2;19;78;74" };
211
+ }
212
+ }
213
+
214
+ function animatedGlowingOrb(palette: OrbPalette, status: VoiceModeState["status"], frame: number, shockwaves: OrbShockwave[] = []): string[] {
215
+ // Amp Neo-inspired dithered glow. The binary exposes its Neo glyph set as:
216
+ // [" ", ".", "·", "·", ":", ":", "•", "•", "●", "●"].
217
+ // We render the same style mathematically in Pi's simpler TUI component model.
218
+ const width = ORB_WIDTH;
219
+ const height = ORB_HEIGHT;
220
+ const chars = [" ", ".", "·", "·", ":", ":", "•", "•", "●", "●"];
221
+ const t = frame / 5;
222
+ const pulse = 0.08 * Math.sin(t);
223
+ const rows: string[] = [];
224
+
225
+ for (let y = 0; y < height; y++) {
226
+ let row = "";
227
+ const ny = (y - (height - 1) / 2) / ((height - 1) / 2);
228
+ for (let x = 0; x < width; x++) {
229
+ const nx = (x - (width - 1) / 2) / ((width - 1) / 2);
230
+ const ellipse = Math.sqrt((nx * nx) / (0.96 + pulse) + (ny * ny) / (0.82 + pulse));
231
+ if (ellipse > 1.18) {
232
+ row += " ";
233
+ continue;
234
+ }
235
+
236
+ const radial = Math.max(0, 1 - ellipse);
237
+ const rim = Math.max(0, 1 - Math.abs(ellipse - 0.74) * 5.0);
238
+ const sweep = status === "transcribing" ? Math.max(0, 1 - Math.abs(nx - ((frame % 28) / 14 - 1)) * 2.8) : 0;
239
+ const listeningRipple = status === "listening" ? 0.22 * Math.sin(18 * ellipse - t * 3.3) : 0;
240
+ const speakingWave = status === "speaking" ? 0.2 * Math.sin(x * 0.65 + t * 3.8) : 0;
241
+ const thinkingSwirl = status === "agent" ? 0.18 * Math.sin(Math.atan2(ny, nx) * 3 + t * 2.2) : 0;
242
+ const highlight = Math.max(0, 1 - Math.hypot(nx + 0.32 * Math.cos(t), ny - 0.28 * Math.sin(t * 0.8)) * 2.2);
243
+ const click = sampleClickEffect(nx, ny, frame, shockwaves);
244
+
245
+ let intensity = radial * 1.25 + rim * 0.5 + highlight * 0.42 + sweep * 0.45 + listeningRipple + speakingWave + thinkingSwirl;
246
+ intensity = clamp01(intensity + click.ring * 0.92 + click.bloom * 0.52 - click.dent * 0.22);
247
+ const charIndex = Math.max(0, Math.min(chars.length - 1, Math.round(intensity * (chars.length - 1))));
248
+ const ch = chars[charIndex] ?? " ";
249
+ const colorCode = click.ring > 0.28 || intensity > 0.78 ? palette.bright : intensity > 0.46 ? palette.fg : intensity > 0.22 ? palette.soft : palette.dim;
250
+ row += color(colorCode, ch);
251
+ }
252
+ rows.push(row);
253
+ }
254
+ return rows;
255
+ }
256
+
257
+ function sampleClickEffect(nx: number, ny: number, frame: number, shockwaves: OrbShockwave[]): { ring: number; bloom: number; dent: number } {
258
+ let ring = 0;
259
+ let bloom = 0;
260
+ let dent = 0;
261
+ for (const wave of shockwaves) {
262
+ const age = frame - wave.frame;
263
+ if (age < 0 || age > 38) continue;
264
+ const progress = age / 38;
265
+ const dx = nx - wave.x;
266
+ const dy = (ny - wave.y) * 1.18;
267
+ const dist = Math.hypot(dx, dy);
268
+ const eased = 1 - (1 - progress) ** 2;
269
+ const radius = 0.04 + eased * 1.22;
270
+ const life = (1 - progress) ** 0.64 * wave.strength;
271
+ ring = Math.max(ring, Math.max(0, 1 - Math.abs(dist - radius) * 10.5) * life);
272
+ bloom = Math.max(bloom, Math.exp(-(dist * dist) / 0.085) * Math.sin(Math.min(1, progress * 2.2) * Math.PI) * wave.strength);
273
+ dent = Math.max(dent, Math.exp(-(dist * dist) / 0.025) * Math.max(0, 1 - progress * 2.4) * wave.strength);
274
+ }
275
+ return { ring: clamp01(ring), bloom: clamp01(bloom), dent: clamp01(dent) };
276
+ }
277
+
278
+ function clamp01(value: number): number {
279
+ return Math.max(0, Math.min(1, value));
280
+ }
281
+
282
+ function disableTerminalMouseInput(): void {
283
+ if (process.stdout.isTTY) process.stdout.write(MOUSE_DISABLE);
284
+ }
285
+
286
+ function clamp(value: number, min: number, max: number): number {
287
+ return Math.max(min, Math.min(max, value));
288
+ }
289
+
290
+ function parseSgrMouse(data: string): SgrMouseEvent | undefined {
291
+ const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
292
+ if (!match) return undefined;
293
+ const code = Number(match[1]);
294
+ const col = Number(match[2]);
295
+ const row = Number(match[3]);
296
+ if (!Number.isFinite(code) || !Number.isFinite(col) || !Number.isFinite(row)) return undefined;
297
+ if (code >= 64) return undefined; // scroll wheel, not a click
298
+ return { button: code & 3, col, row, pressed: match[4] === "M" };
299
+ }
300
+
301
+ function shimmer(text: string, palette: OrbPalette, frame: number): string {
302
+ return [...text].map((ch, index) => color((index + frame) % 6 === 0 ? palette.bright : palette.fg, ch)).join("");
303
+ }
304
+
305
+ function frameIntervalForStatus(status: VoiceModeState["status"]): number {
306
+ return status === "listening" ? 80 : status === "speaking" ? 90 : status === "agent" ? 120 : 110;
307
+ }
308
+
309
+ function controlRail(state: VoiceModeState, palette: OrbPalette, width: number): string[] {
310
+ const listenLabel = state.isListening ? "stop" : "listen";
311
+ const pills = [
312
+ controlPill("R", listenLabel, state.isListening ? "active" : "primary", palette),
313
+ controlPill("A", state.autoListen ? "auto-listen on" : "auto-listen off", state.autoListen ? "active" : "muted", palette),
314
+ controlPill("S", state.autoSpeakAssistant ? "read aloud on" : "read aloud off", state.autoSpeakAssistant ? "active" : "muted", palette),
315
+ controlPill("Q", "close", "danger", palette),
316
+ ];
317
+ return wrapInline(pills, " ", Math.max(24, width - 2));
318
+ }
319
+
320
+ function controlPill(key: string, label: string, tone: "primary" | "active" | "muted" | "danger", palette: OrbPalette): string {
321
+ const bg = tone === "active" ? "48;2;17;83;91" : tone === "primary" ? "48;2;17;42;72" : tone === "danger" ? "48;2;54;24;36" : "48;2;15;23;42";
322
+ const keyFg = tone === "danger" ? "38;2;255;190;190" : tone === "muted" ? "38;2;203;213;225" : palette.bright;
323
+ const labelFg = tone === "muted" ? "38;2;148;163;184" : "38;2;226;232;240";
324
+ return `\x1b[${bg}m\x1b[1m\x1b[${keyFg}m ${key} \x1b[22m\x1b[${labelFg}m ${label} \x1b[0m`;
325
+ }
326
+
327
+ function wrapInline(items: string[], gap: string, maxWidth: number): string[] {
328
+ const lines: string[] = [];
329
+ let current = "";
330
+ for (const item of items) {
331
+ const candidate = current ? `${current}${gap}${item}` : item;
332
+ if (current && visibleWidth(candidate) > maxWidth) {
333
+ lines.push(current);
334
+ current = item;
335
+ } else {
336
+ current = candidate;
337
+ }
338
+ }
339
+ if (current) lines.push(current);
340
+ return lines;
341
+ }
342
+
343
+ function color(code: string, text: string): string {
344
+ return `\x1b[${code}m${text}\x1b[39m`;
345
+ }
346
+
347
+ function center(text: string, width: number): string {
348
+ const pad = Math.max(0, Math.floor((width - visibleWidth(text)) / 2));
349
+ return truncateToWidth(`${" ".repeat(pad)}${text}`, width);
350
+ }