@tychenjiajun/pi-voice 0.3.0 → 0.4.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/README.md CHANGED
@@ -27,7 +27,7 @@ The extension registers:
27
27
  - a configurable terminal shortcut (`Ctrl+Alt+Z` by default) to start and stop recording;
28
28
  - `/voice`, which does the same as the shortcut. While recording, `Space` also stops and transcribes, and `Escape` discards;
29
29
  - a `transcribe_file` tool that the agent can use to transcribe local audio or video files;
30
- - `/voice-settings` for preferred languages, model, transcription language, post-processing, microphone, and shortcut settings.
30
+ - `/voice-settings` for preferred languages, model, transcription backend, transcription language, post-processing, microphone, and shortcut settings.
31
31
 
32
32
  `/transcribe` remains available as a compatibility alias for `/voice-settings`.
33
33
 
@@ -35,7 +35,7 @@ The extension registers:
35
35
 
36
36
  Pi Voice can send audio to a remote ASR service instead of transcribing with the local model. It speaks the OpenAI chat-completions `input_audio` dialect: the recording is uploaded as a base64 WAV data URL and the transcript is read from `choices[0].message.content`. Any endpoint that accepts that shape works.
37
37
 
38
- Set it up in `/voice-settings` → **Transcription backend** → **Remote ASR (OpenAI-compatible)**. You are asked for:
38
+ Set it up either during first-run setup — `/voice-settings` starts by asking **Local model** or **Remote ASR (OpenAI-compatible)** — or later in `/voice-settings` → **Transcription backend** → **Remote ASR (OpenAI-compatible)**. You are asked for:
39
39
 
40
40
  1. **Base URL** — endpoint root, without `/chat/completions`;
41
41
  2. **Model** — the model name your endpoint expects;
@@ -54,7 +54,7 @@ Behavior:
54
54
  - **16 kHz mono WAV** is uploaded as a base64 data URL.
55
55
  - Switching back to **Local model** restores the original behavior; the local model is still required either way (it is the fallback and file transcription still needs it).
56
56
 
57
- Transcripts still go through the **Chinese output** script conversion preference.
57
+ Transcripts still go through the **Chinese output** script conversion preference; because a remote response carries no detected language, Han characters in the transcript are what trigger the conversion when the language is set to automatic.
58
58
 
59
59
  ## Post-processing (compaction-style refinement)
60
60
 
@@ -115,6 +115,25 @@ When FFmpeg is unavailable, `transcribe_file` reports platform-specific guidance
115
115
 
116
116
  ## Changelog
117
117
 
118
+ ### 0.4.0
119
+
120
+ **Added — Backend choice in first-run setup**
121
+
122
+ - `/voice-settings` (and the first shortcut press) now starts by asking **Local model** or **Remote ASR (OpenAI-compatible)**, so the remote backend no longer requires finishing local setup first. A local model is still downloaded as the fallback.
123
+ - The remote endpoint prompts (base URL, model, API-key env var name) are shared by first-run setup and the settings menu.
124
+
125
+ **Fixed — Settings reachability and remote consistency**
126
+
127
+ - `/voice-settings` opens the settings menu after recovering a missing model file, instead of returning and making you run the command a second time.
128
+ - **Chinese output** is shown for detecting models that support Chinese, so it is reachable when the transcription language is automatic.
129
+ - Remote transcripts apply the Chinese output preference when the transcript contains Han characters, not only when the configured language is Chinese.
130
+ - Settings and errors no longer assume local-only transcription: the **Transcription language** row explains that a remote endpoint detects the language itself, and the transcription error, `transcribe_file` setup guidance, and Try-it model row describe the remote backend.
131
+
132
+ **Changed**
133
+
134
+ - Onboarding progress counts the backend step (1 backend → 2 languages → 3 model → 4 Try it).
135
+ - Try it's **change model** keeps the configured transcription language for the new model.
136
+
118
137
  ### 0.3.0
119
138
 
120
139
  **Added — Post-processing (compaction-style refinement)**
@@ -133,7 +152,7 @@ First release of `@tychenjiajun/pi-voice` — a fork of [`@earendil-works/pi-voi
133
152
  **Added — Remote ASR backend**
134
153
 
135
154
  - Recordings can be transcribed by any OpenAI-compatible chat-completions ASR endpoint via the `input_audio` content block.
136
- - Configure in `/voice-settings` → **Transcription backend** → **Remote ASR (OpenAI-compatible)**: base URL, model, and the *name* of an env var holding the API key (the key itself is never stored in settings).
155
+ - Configure in `/voice-settings` → **Transcription backend** → **Remote ASR (OpenAI-compatible)**: base URL, model, and the *name* of an env var holding the API key (the key itself is never stored in settings). First-run setup also offers the remote backend directly.
137
156
  - Audio is uploaded as a 16 kHz mono WAV base64 data URL. Remote transcription is batch-only (no streaming preview).
138
157
  - **Local model fallback:** if a remote request fails (network, quota, HTTP error, or the API key is unset), the full recording is transcribed with the configured local model; the failure is logged to `~/.pi/agent/pi-voice.log`. Aborted/cancelled transcriptions are never retried locally.
139
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tychenjiajun/pi-voice",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Speech-to-text for Pi (local model or OpenAI-compatible remote ASR, compaction-style post-processing)",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/chinese.ts CHANGED
@@ -33,6 +33,21 @@ export function isChineseLanguage(language: string): boolean {
33
33
  return base === "zh" || base === "yue";
34
34
  }
35
35
 
36
+ /**
37
+ * CJK ranges worth converting: Ext A, the main block, compatibility ideographs,
38
+ * and Ext B+ (surrogate pairs are matched by their lead code unit).
39
+ */
40
+ const HAN_CHARACTERS = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\ud840-\ud87f]/;
41
+
42
+ /**
43
+ * Remote ASR reports no detected language, so Chinese output is decided by
44
+ * looking at the transcript: OpenCC is only worth loading when there is Han
45
+ * text (or the configured language is Chinese) to convert.
46
+ */
47
+ export function containsChineseCharacters(text: string): boolean {
48
+ return HAN_CHARACTERS.test(text);
49
+ }
50
+
36
51
  export async function convertChineseOutput(
37
52
  text: string,
38
53
  output: ChineseOutput,
@@ -145,7 +145,7 @@ export function registerFileTranscriptionTool(
145
145
  content: [
146
146
  {
147
147
  type: "text",
148
- text: `Queued ${audio.seconds.toFixed(1)}s from ${basename(inputPath)} for local transcription…`,
148
+ text: `Queued ${audio.seconds.toFixed(1)}s from ${basename(inputPath)} for transcription…`,
149
149
  },
150
150
  ],
151
151
  details: {
package/src/onboarding.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "./recommendation-picker.js";
15
15
  import { CATALOG_MODELS } from "./catalog.js";
16
16
  import { recommendModels } from "./recommendations.js";
17
+ import { promptRemoteBackend, warnMissingApiKey } from "./remote-asr.js";
17
18
  import { createShortcutPicker } from "./shortcuts.js";
18
19
  import { tryVoice } from "./try-it.js";
19
20
  import {
@@ -28,6 +29,10 @@ import {
28
29
  type TranscriptionLanguage,
29
30
  } from "./settings.js";
30
31
  import { DEFAULT_SHORTCUT } from "./shortcut-core.js";
32
+ import {
33
+ SingleSelectPicker,
34
+ type SingleSelectChoice,
35
+ } from "./ui-components.js";
31
36
 
32
37
  function requireTui(ctx: ExtensionContext): boolean {
33
38
  if (ctx.mode === "tui") return true;
@@ -213,6 +218,7 @@ export async function changeOnboardingModel(
213
218
  () => ({
214
219
  shortcut: current.shortcut,
215
220
  preferredLanguages: languages,
221
+ transcriptionLanguage: current.transcriptionLanguage,
216
222
  microphone: current.microphone,
217
223
  chineseOutput: current.chineseOutput,
218
224
  backend: current.backend,
@@ -264,12 +270,61 @@ export async function changeOnboardingModel(
264
270
  }
265
271
  }
266
272
 
273
+ export type SetupMode = "local" | "remote";
274
+
275
+ /**
276
+ * First screen of first-run setup: on-device model, or a remote ASR endpoint.
277
+ * A local model is downloaded in both cases because it is the fallback.
278
+ */
279
+ async function chooseSetupMode(ctx: ExtensionContext): Promise<SetupMode | undefined> {
280
+ const choices: SingleSelectChoice<SetupMode>[] = [
281
+ {
282
+ value: "local",
283
+ label: "Local model",
284
+ description: "Transcribe fully on-device; no audio leaves this machine",
285
+ },
286
+ {
287
+ value: "remote",
288
+ label: "Remote ASR (OpenAI-compatible)",
289
+ description:
290
+ "Send audio to an OpenAI-compatible ASR endpoint; a local model is still downloaded as fallback",
291
+ },
292
+ ];
293
+ return ctx.ui.custom<SetupMode | undefined>((tui, theme, keybindings, done) =>
294
+ new SingleSelectPicker(
295
+ tui,
296
+ theme,
297
+ keybindings,
298
+ choices,
299
+ undefined,
300
+ {
301
+ title: "Pi Voice setup",
302
+ subtitle: "How should Pi Voice transcribe your speech?",
303
+ cancelLabel: "cancel",
304
+ onboardingStep: 1,
305
+ },
306
+ done,
307
+ ),
308
+ );
309
+ }
310
+
267
311
  export async function runOnboarding(
268
312
  ctx: ExtensionContext,
269
313
  shortcut = DEFAULT_SHORTCUT,
270
314
  ): Promise<TranscribeSettings | undefined> {
271
315
  if (!requireTui(ctx)) return undefined;
272
316
 
317
+ const mode = await chooseSetupMode(ctx);
318
+ if (!mode) return undefined;
319
+
320
+ let backend: BackendSettings | undefined;
321
+ if (mode === "remote") {
322
+ backend = await promptRemoteBackend(ctx);
323
+ if (!backend) return undefined;
324
+ warnMissingApiKey(ctx, backend.apiKeyEnv);
325
+ }
326
+ const usingRemote = backend !== undefined;
327
+
273
328
  let languages = defaultSpokenLanguages();
274
329
  // Navigation cannot undo a completed settings commit. Keep it across a
275
330
  // return to languages, even if the user then exits without another choice.
@@ -277,7 +332,7 @@ export async function runOnboarding(
277
332
  while (true) {
278
333
  const chosen = await chooseLanguages(ctx, languages, {
279
334
  cancelLabel: "exit",
280
- onboardingStep: 1,
335
+ onboardingStep: 2,
281
336
  });
282
337
  if (!chosen?.confirmed) return configured;
283
338
  languages = chosen.languages;
@@ -285,7 +340,7 @@ export async function runOnboarding(
285
340
  // the real wait on this machine.
286
341
  const picks = recommendModels(CATALOG_MODELS, languages);
287
342
  const { activate, waitForCommits } = createSettingsActivation(
288
- () => ({ shortcut, preferredLanguages: languages }),
343
+ () => ({ shortcut, preferredLanguages: languages, backend }),
289
344
  (settings) => {
290
345
  configured = settings;
291
346
  },
@@ -298,7 +353,10 @@ export async function runOnboarding(
298
353
  languages,
299
354
  picks,
300
355
  activate,
301
- { onboardingStep: 2 },
356
+ {
357
+ onboardingStep: 3,
358
+ ...(usingRemote ? { title: "Choose the local fallback model" } : {}),
359
+ },
302
360
  );
303
361
  await waitForCommits();
304
362
  if (!recommendation) return configured;
@@ -314,8 +372,8 @@ export async function runOnboarding(
314
372
  postActivation: "advance",
315
373
  onActivate: activate,
316
374
  cancelLabel: "back",
317
- onboardingStep: 2,
318
- title: "Browse all models",
375
+ onboardingStep: 3,
376
+ title: usingRemote ? "Choose the local fallback model" : "Browse all models",
319
377
  });
320
378
  await waitForCommits();
321
379
  if (selection?.type === "complete" && configured) {
package/src/openai-asr.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import OpenAI from "openai";
2
2
  import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
3
- import { convertChineseOutput, isChineseLanguage } from "./chinese.js";
3
+ import { containsChineseCharacters, convertChineseOutput, isChineseLanguage } from "./chinese.js";
4
4
  import { describeError, writeLog } from "./log.js";
5
5
  import type { BackendSettings, ChineseOutput } from "./settings.js";
6
6
  import type {
@@ -122,15 +122,15 @@ function createOpenAITransport(config: OpenAIAsrSettings): {
122
122
  /**
123
123
  * Trims, then applies the Chinese script preference exactly like the local
124
124
  * backend. The chat-completions response carries no detected language, so
125
- * this mirrors the local `finishTranscript` with detectedLanguage = "" —
126
- * conversion runs only when the configured language is Chinese.
125
+ * Chinese is inferred from the text itself: Han characters mean the transcript
126
+ * is Chinese and the preference applies, matching local models that detect it.
127
127
  */
128
128
  export async function finishRemoteTranscript(
129
129
  text: string,
130
130
  options: TranscriptionOptions,
131
131
  ): Promise<string> {
132
132
  const trimmed = text.trim();
133
- return isChineseLanguage(options.language ?? "")
133
+ return isChineseLanguage(options.language ?? "") || containsChineseCharacters(trimmed)
134
134
  ? convertChineseOutput(trimmed, options.chineseOutput ?? "simplified")
135
135
  : trimmed;
136
136
  }
@@ -0,0 +1,212 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, Input, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { VoiceKeys } from "./keybindings.js";
4
+ import type { BackendSettings } from "./settings.js";
5
+ import {
6
+ LIST_PADDING,
7
+ panelBorder,
8
+ SingleSelectPicker,
9
+ type SingleSelectChoice,
10
+ } from "./ui-components.js";
11
+
12
+ export type BackendType = "transcribe-cpp" | "openai-asr";
13
+
14
+ /** Pick the transcription backend; shared by first-run setup and the settings menu. */
15
+ export async function chooseBackendType(
16
+ ctx: ExtensionContext,
17
+ current: BackendSettings,
18
+ ): Promise<BackendType | undefined> {
19
+ const choices: SingleSelectChoice<BackendType>[] = [
20
+ {
21
+ value: "transcribe-cpp",
22
+ label: "Local model",
23
+ description: "Transcribe fully on-device with the downloaded model; works offline",
24
+ },
25
+ {
26
+ value: "openai-asr",
27
+ label: "Remote ASR (OpenAI-compatible)",
28
+ description:
29
+ "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; falls back to the local model if the request fails",
30
+ },
31
+ ];
32
+ return ctx.ui.custom<BackendType | undefined>((tui, theme, keybindings, done) =>
33
+ new SingleSelectPicker(
34
+ tui,
35
+ theme,
36
+ keybindings,
37
+ choices,
38
+ current.type,
39
+ { title: "Transcription backend", cancelLabel: "back" },
40
+ done,
41
+ ),
42
+ );
43
+ }
44
+
45
+ function validateBaseUrl(value: string): string | undefined {
46
+ if (!value) return "Base URL is required";
47
+ try {
48
+ const url = new URL(value);
49
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
50
+ return "Base URL must start with http:// or https://";
51
+ }
52
+ } catch {
53
+ return "Base URL must be a valid URL";
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function validateEnvName(value: string): string | undefined {
59
+ if (!value) return "Environment variable name is required";
60
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
61
+ return "Use a plain environment variable name, e.g. OPENAI_API_KEY";
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ function remoteBackendsEqual(
67
+ left: Extract<BackendSettings, { type: "openai-asr" }>,
68
+ right: Extract<BackendSettings, { type: "openai-asr" }>,
69
+ ): boolean {
70
+ return (
71
+ left.baseUrl === right.baseUrl &&
72
+ left.model === right.model &&
73
+ left.apiKeyEnv === right.apiKeyEnv
74
+ );
75
+ }
76
+
77
+ /** Warn when the named variable is unset, so remote calls silently fall back to local. */
78
+ export function warnMissingApiKey(ctx: ExtensionContext, apiKeyEnv: string): void {
79
+ if (process.env[apiKeyEnv]) return;
80
+ ctx.ui.notify(
81
+ `${apiKeyEnv} is not set in Pi's environment; remote transcription will fall back to the local model until it is set`,
82
+ "warning",
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Single-field text prompt built on pi-tui's Input. Empty input may be legal
88
+ * (the caller decides via validate); Escape backs out of the whole flow.
89
+ */
90
+ function promptText(
91
+ ctx: ExtensionContext,
92
+ options: {
93
+ title: string;
94
+ description?: string;
95
+ value: string;
96
+ placeholder?: string;
97
+ validate?: (value: string) => string | undefined;
98
+ },
99
+ ): Promise<string | undefined> {
100
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
101
+ const keys = new VoiceKeys(keybindings);
102
+ const input = new Input();
103
+ input.setValue(options.value);
104
+ let error: string | undefined;
105
+ const container = new Container();
106
+
107
+ const rebuild = (): void => {
108
+ container.clear();
109
+ container.addChild(panelBorder(theme));
110
+ container.addChild(new Spacer(1));
111
+ container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
112
+ if (options.description) {
113
+ container.addChild(new Text(theme.fg("muted", options.description), 1, 0));
114
+ }
115
+ container.addChild(new Spacer(1));
116
+ if (error) {
117
+ container.addChild(new Text(theme.fg("error", error), 1, 0));
118
+ container.addChild(new Spacer(1));
119
+ }
120
+ if (!input.getValue() && options.placeholder) {
121
+ container.addChild(new Text(theme.fg("dim", options.placeholder), 2, 0));
122
+ }
123
+ const inputBox = new Box(LIST_PADDING, 0);
124
+ inputBox.addChild(input);
125
+ container.addChild(inputBox);
126
+ container.addChild(new Spacer(1));
127
+ container.addChild(
128
+ new Text(
129
+ `${keys.hint("tui.input.submit", "save")} ${keys.hint("tui.select.cancel", "back")}`,
130
+ 1,
131
+ 0,
132
+ ),
133
+ );
134
+ container.addChild(new Spacer(1));
135
+ container.addChild(panelBorder(theme));
136
+ };
137
+ rebuild();
138
+
139
+ input.onSubmit = (value) => {
140
+ const problem = options.validate?.(value.trim());
141
+ if (problem) {
142
+ error = problem;
143
+ rebuild();
144
+ tui.requestRender();
145
+ return;
146
+ }
147
+ done(value.trim());
148
+ };
149
+ input.onEscape = () => done(undefined);
150
+
151
+ return {
152
+ get focused() {
153
+ return input.focused;
154
+ },
155
+ set focused(value: boolean) {
156
+ input.focused = value;
157
+ },
158
+ render(width: number): string[] {
159
+ return container
160
+ .render(width)
161
+ .map((line) => truncateToWidth(line, width, ""));
162
+ },
163
+ handleInput(data: string): void {
164
+ input.handleInput(data);
165
+ },
166
+ invalidate(): void {
167
+ container.invalidate();
168
+ },
169
+ };
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Collect base URL, model, and API key env var for an OpenAI-compatible ASR
175
+ * endpoint. Returns undefined if the user backs out of any field. `current`
176
+ * pre-fills the prompts when editing an existing backend.
177
+ */
178
+ export async function promptRemoteBackend(
179
+ ctx: ExtensionContext,
180
+ current?: Extract<BackendSettings, { type: "openai-asr" }>,
181
+ ): Promise<Extract<BackendSettings, { type: "openai-asr" }> | undefined> {
182
+ const baseUrl = await promptText(ctx, {
183
+ title: "ASR base URL",
184
+ description: "OpenAI-compatible endpoint root, without the /chat/completions path",
185
+ value: current?.baseUrl ?? "",
186
+ placeholder: "https://api.example.com/v1",
187
+ validate: validateBaseUrl,
188
+ });
189
+ if (baseUrl === undefined) return undefined;
190
+
191
+ const model = await promptText(ctx, {
192
+ title: "ASR model",
193
+ value: current?.model ?? "",
194
+ placeholder: "whisper-1",
195
+ validate: (value) => (value ? undefined : "Model name is required"),
196
+ });
197
+ if (model === undefined) return undefined;
198
+
199
+ const apiKeyEnv = await promptText(ctx, {
200
+ title: "API key environment variable",
201
+ description:
202
+ "The key itself is read from this variable at runtime; it is never stored in settings",
203
+ value: current?.apiKeyEnv ?? "",
204
+ placeholder: "OPENAI_API_KEY",
205
+ validate: validateEnvName,
206
+ });
207
+ if (apiKeyEnv === undefined) return undefined;
208
+
209
+ const backend = { type: "openai-asr" as const, baseUrl, model, apiKeyEnv };
210
+ if (current && remoteBackendsEqual(current, backend)) return undefined;
211
+ return backend;
212
+ }
package/src/runtime.ts CHANGED
@@ -48,7 +48,7 @@ function captureErrorMessage(error: unknown): string {
48
48
 
49
49
  function transcriptionErrorMessage(error: unknown): string {
50
50
  const message = error instanceof Error ? error.message : String(error);
51
- return `Local transcription failed: ${message}`;
51
+ return `Transcription failed: ${message}`;
52
52
  }
53
53
 
54
54
  export function createPiVoiceRuntime(
@@ -189,12 +189,12 @@ export function createPiVoiceRuntime(
189
189
  await loadSettingsOnce();
190
190
  if (settingsReadWarning && !settings) {
191
191
  throw new Error(
192
- `${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to configure a local model, then retry transcribe_file.`,
192
+ `${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to set up Pi Voice (a local model, and optionally a remote ASR endpoint), then retry transcribe_file.`,
193
193
  );
194
194
  }
195
195
  if (!settings) {
196
196
  throw new Error(
197
- "Pi Voice is not configured. Ask the user to run /voice-settings in Pi's interactive TUI once to choose and download a local model, then retry transcribe_file.",
197
+ "Pi Voice is not configured. Ask the user to run /voice-settings in Pi's interactive TUI once to set up Pi Voice (choose and download a local model, and optionally a remote ASR endpoint), then retry transcribe_file.",
198
198
  );
199
199
  }
200
200
  if (!existsSync(settings.model.path)) {
@@ -458,15 +458,17 @@ export function createPiVoiceRuntime(
458
458
  let reload = false;
459
459
  await runExclusive(ctx, async () => {
460
460
  await loadSettingsOnce();
461
- const hadConfiguration = Boolean(settings && existsSync(settings.model.path));
462
- const { configured } = await ensureSettings(ctx);
461
+ const { configured, completedFirstRun } = await ensureSettings(ctx);
463
462
  if (!configured) return;
464
- if (!hadConfiguration) {
463
+ if (completedFirstRun) {
465
464
  // First-run setup ends on its Ready message rather than falling
466
465
  // straight through into the regular settings menu.
467
466
  reload = configured.shortcut !== registeredShortcut;
468
467
  return;
469
468
  }
469
+ // A recovered missing model file also lands here: the user asked for
470
+ // settings, so continue into them instead of making them run the
471
+ // command a second time.
470
472
  const { showSettingsMenu } = await import("./settings-menu.js");
471
473
  reload = await showSettingsMenu(pi, ctx, configured, registeredShortcut);
472
474
  });
@@ -2,11 +2,10 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { Box, Container, Input, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
5
+ import { truncateToWidth } from "@earendil-works/pi-tui";
6
6
  import { microphonePermission, type Permission } from "./audio.js";
7
7
  import { displayLanguage, getCatalogModel } from "./catalog.js";
8
8
  import { chineseOutputSummary, isChineseLanguage } from "./chinese.js";
9
- import { VoiceKeys } from "./keybindings.js";
10
9
  import {
11
10
  chooseLanguages,
12
11
  createTranscriptionLanguagePicker,
@@ -18,6 +17,7 @@ import {
18
17
  microphoneSummary,
19
18
  } from "./microphone-picker.js";
20
19
  import { runModelSelection } from "./onboarding.js";
20
+ import { chooseBackendType, promptRemoteBackend, warnMissingApiKey } from "./remote-asr.js";
21
21
  import {
22
22
  writeSettings,
23
23
  type BackendSettings,
@@ -30,9 +30,7 @@ import {
30
30
  import { displayShortcut } from "./shortcut-core.js";
31
31
  import { createShortcutPicker } from "./shortcuts.js";
32
32
  import {
33
- LIST_PADDING,
34
33
  padToWidth,
35
- panelBorder,
36
34
  SingleSelectPicker,
37
35
  type SingleSelectChoice,
38
36
  } from "./ui-components.js";
@@ -43,7 +41,7 @@ const SETTINGS_LABEL_WIDTH = 25;
43
41
  // Text padding (2), cursor gutter (2), label column, and its gap (2).
44
42
  const SETTINGS_ROW_OVERHEAD = SETTINGS_LABEL_WIDTH + 6;
45
43
 
46
- type SettingsAction =
44
+ export type SettingsAction =
47
45
  | "preferred-languages"
48
46
  | "model"
49
47
  | "backend"
@@ -53,7 +51,7 @@ type SettingsAction =
53
51
  | "microphone"
54
52
  | "shortcut";
55
53
 
56
- type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
54
+ export type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
57
55
  summary: string;
58
56
  /** Render the summary in the error color. */
59
57
  alert?: boolean;
@@ -98,7 +96,7 @@ function polishSummary(polishEnabled: boolean | undefined, model: PolishModelRef
98
96
  return model ? `${model.provider}/${model.id}` : "Pi's current model";
99
97
  }
100
98
 
101
- function settingsHomeChoices(
99
+ export function settingsHomeChoices(
102
100
  configured: TranscribeSettings,
103
101
  permission: Permission,
104
102
  ): SettingsHomeChoice[] {
@@ -134,13 +132,23 @@ function settingsHomeChoices(
134
132
  value: "transcription-language",
135
133
  label: "Transcription language",
136
134
  summary: transcriptionLanguageSummary(configured.transcriptionLanguage, model),
137
- description: "Language expected in recordings, or automatic detection when supported",
135
+ description:
136
+ configured.backend.type === "openai-asr"
137
+ ? "Used by the local fallback and Chinese output conversion; the remote endpoint detects the language itself"
138
+ : "Language expected in recordings, or automatic detection when supported",
138
139
  },
139
140
  ];
140
141
 
142
+ // Chinese script conversion applies when the configured language is Chinese,
143
+ // when a preferred language is Chinese, or when a detecting model can pick
144
+ // Chinese on its own. Gating only on the first two leaves the row unreachable
145
+ // for anyone relying on auto-detection.
146
+ const detectsChinese =
147
+ model.capabilities.languageDetection && model.languages.some(isChineseLanguage);
141
148
  if (
142
149
  isChineseLanguage(configured.transcriptionLanguage) ||
143
- configured.preferredLanguages.some(isChineseLanguage)
150
+ configured.preferredLanguages.some(isChineseLanguage) ||
151
+ detectsChinese
144
152
  ) {
145
153
  choices.push({
146
154
  value: "chinese-output",
@@ -280,145 +288,6 @@ async function chooseShortcut(
280
288
  );
281
289
  }
282
290
 
283
- /**
284
- * Single-field text prompt built on pi-tui's Input. Empty input may be legal
285
- * (the caller decides via validate); Escape backs out of the whole flow.
286
- */
287
- function promptText(
288
- ctx: ExtensionContext,
289
- options: {
290
- title: string;
291
- description?: string;
292
- value: string;
293
- placeholder?: string;
294
- validate?: (value: string) => string | undefined;
295
- },
296
- ): Promise<string | undefined> {
297
- return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
298
- const keys = new VoiceKeys(keybindings);
299
- const input = new Input();
300
- input.setValue(options.value);
301
- let error: string | undefined;
302
- const container = new Container();
303
-
304
- const rebuild = (): void => {
305
- container.clear();
306
- container.addChild(panelBorder(theme));
307
- container.addChild(new Spacer(1));
308
- container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
309
- if (options.description) {
310
- container.addChild(new Text(theme.fg("muted", options.description), 1, 0));
311
- }
312
- container.addChild(new Spacer(1));
313
- if (error) {
314
- container.addChild(new Text(theme.fg("error", error), 1, 0));
315
- container.addChild(new Spacer(1));
316
- }
317
- if (!input.getValue() && options.placeholder) {
318
- container.addChild(new Text(theme.fg("dim", options.placeholder), 2, 0));
319
- }
320
- const inputBox = new Box(LIST_PADDING, 0);
321
- inputBox.addChild(input);
322
- container.addChild(inputBox);
323
- container.addChild(new Spacer(1));
324
- container.addChild(
325
- new Text(
326
- `${keys.hint("tui.input.submit", "save")} ${keys.hint("tui.select.cancel", "back")}`,
327
- 1,
328
- 0,
329
- ),
330
- );
331
- container.addChild(new Spacer(1));
332
- container.addChild(panelBorder(theme));
333
- };
334
- rebuild();
335
-
336
- input.onSubmit = (value) => {
337
- const problem = options.validate?.(value.trim());
338
- if (problem) {
339
- error = problem;
340
- rebuild();
341
- tui.requestRender();
342
- return;
343
- }
344
- done(value.trim());
345
- };
346
- input.onEscape = () => done(undefined);
347
-
348
- return {
349
- get focused() {
350
- return input.focused;
351
- },
352
- set focused(value: boolean) {
353
- input.focused = value;
354
- },
355
- render(width: number): string[] {
356
- return container
357
- .render(width)
358
- .map((line) => truncateToWidth(line, width, ""));
359
- },
360
- handleInput(data: string): void {
361
- input.handleInput(data);
362
- },
363
- invalidate(): void {
364
- container.invalidate();
365
- },
366
- };
367
- });
368
- }
369
-
370
- async function chooseBackendType(
371
- ctx: ExtensionContext,
372
- current: BackendSettings,
373
- ): Promise<"transcribe-cpp" | "openai-asr" | undefined> {
374
- const choices: SingleSelectChoice<"transcribe-cpp" | "openai-asr">[] = [
375
- {
376
- value: "transcribe-cpp",
377
- label: "Local model",
378
- description: "Transcribe fully on-device with the downloaded model; works offline",
379
- },
380
- {
381
- value: "openai-asr",
382
- label: "Remote ASR (OpenAI-compatible)",
383
- description:
384
- "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; falls back to the local model if the request fails",
385
- },
386
- ];
387
- return ctx.ui.custom<"transcribe-cpp" | "openai-asr" | undefined>(
388
- (tui, theme, keybindings, done) =>
389
- new SingleSelectPicker(
390
- tui,
391
- theme,
392
- keybindings,
393
- choices,
394
- current.type,
395
- { title: "Transcription backend", cancelLabel: "back" },
396
- done,
397
- ),
398
- );
399
- }
400
-
401
- function validateBaseUrl(value: string): string | undefined {
402
- if (!value) return "Base URL is required";
403
- try {
404
- const url = new URL(value);
405
- if (url.protocol !== "http:" && url.protocol !== "https:") {
406
- return "Base URL must start with http:// or https://";
407
- }
408
- } catch {
409
- return "Base URL must be a valid URL";
410
- }
411
- return undefined;
412
- }
413
-
414
- function validateEnvName(value: string): string | undefined {
415
- if (!value) return "Environment variable name is required";
416
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
417
- return "Use a plain environment variable name, e.g. OPENAI_API_KEY";
418
- }
419
- return undefined;
420
- }
421
-
422
291
  /** Backend row flow: pick the type, then collect remote settings step by step. */
423
292
  async function editBackend(
424
293
  ctx: ExtensionContext,
@@ -438,53 +307,16 @@ async function editBackend(
438
307
 
439
308
  const current =
440
309
  configured.backend.type === "openai-asr" ? configured.backend : undefined;
441
- const baseUrl = await promptText(ctx, {
442
- title: "ASR base URL",
443
- description: "OpenAI-compatible endpoint root, without the /chat/completions path",
444
- value: current?.baseUrl ?? "",
445
- placeholder: "https://api.example.com/v1",
446
- validate: validateBaseUrl,
447
- });
448
- if (baseUrl === undefined) return false;
310
+ const backend = await promptRemoteBackend(ctx, current);
311
+ if (!backend) return false;
449
312
 
450
- const model = await promptText(ctx, {
451
- title: "ASR model",
452
- value: current?.model ?? "",
453
- placeholder: "whisper-1",
454
- validate: (value) => (value ? undefined : "Model name is required"),
455
- });
456
- if (model === undefined) return false;
457
-
458
- const apiKeyEnv = await promptText(ctx, {
459
- title: "API key environment variable",
460
- description: "The key itself is read from this variable at runtime; it is never stored in settings",
461
- value: current?.apiKeyEnv ?? "",
462
- placeholder: "OPENAI_API_KEY",
463
- validate: validateEnvName,
464
- });
465
- if (apiKeyEnv === undefined) return false;
466
-
467
- const backend: BackendSettings = { type: "openai-asr", baseUrl, model, apiKeyEnv };
468
- if (
469
- current &&
470
- current.baseUrl === baseUrl &&
471
- current.model === model &&
472
- current.apiKeyEnv === apiKeyEnv
473
- ) {
474
- return false;
475
- }
476
313
  const saved = await saveUpdatedSettings(
477
314
  ctx,
478
315
  configured,
479
316
  { ...configured, backend },
480
- `Remote ASR configured (${model})`,
317
+ `Remote ASR configured (${backend.model})`,
481
318
  );
482
- if (saved && !process.env[apiKeyEnv]) {
483
- ctx.ui.notify(
484
- `${apiKeyEnv} is not set in Pi's environment; remote transcription will fall back to the local model until it is set`,
485
- "warning",
486
- );
487
- }
319
+ if (saved) warnMissingApiKey(ctx, backend.apiKeyEnv);
488
320
  return saved;
489
321
  }
490
322
 
package/src/try-it.ts CHANGED
@@ -189,7 +189,7 @@ export class TryItPane implements Component {
189
189
  const topChrome = (compact: boolean): string[] => [
190
190
  ...panelBorder(this.theme).render(width),
191
191
  ...(compact ? [] : [""]),
192
- ...onboardingHeader(this.theme, title, 3).render(width),
192
+ ...onboardingHeader(this.theme, title, 4).render(width),
193
193
  ...(compact ? [] : [""]),
194
194
  ...(compact ? [line(instructions(true))] : text(instructions(false))),
195
195
  ...(compact ? [] : [""]),
@@ -208,7 +208,12 @@ export class TryItPane implements Component {
208
208
  ...permission,
209
209
  ...render(setting("Shortcut", shortcut, this.keys.keyText("voice.tryIt.shortcut"), compact)),
210
210
  ...render(setting("Microphone", microphoneSummary(this.settings.microphone), this.keys.keyText("voice.tryIt.microphone"), compact)),
211
- ...render(setting("Model", modelName, this.keys.keyText("voice.tryIt.model"), compact)),
211
+ ...render(setting(
212
+ this.settings.backend.type === "openai-asr" ? "Fallback" : "Model",
213
+ modelName,
214
+ this.keys.keyText("voice.tryIt.model"),
215
+ compact,
216
+ )),
212
217
  ...(compact ? [] : [""]),
213
218
  ...text(hints),
214
219
  ...(compact ? [] : [""]),
@@ -230,7 +235,7 @@ export class TryItPane implements Component {
230
235
  if (top.length + bottom.length + frameRows + 1 > budget) {
231
236
  // Tiny terminals: drop settings, but keep the task and current actions visible.
232
237
  top = [
233
- ...onboardingHeader(this.theme, title, 3).render(width),
238
+ ...onboardingHeader(this.theme, title, 4).render(width),
234
239
  line(instructions(true)),
235
240
  ];
236
241
  bottom = [...text(hints), ...panelBorder(this.theme).render(width)];
@@ -37,7 +37,7 @@ export function onboardingHeader(
37
37
  theme: UiTheme,
38
38
  title: string,
39
39
  step: number,
40
- total = 3,
40
+ total = 4,
41
41
  ): Component {
42
42
  const context = `Pi Voice setup · ${step} of ${total}`;
43
43
  const compactContext = `${step} of ${total}`;
@@ -217,6 +217,8 @@ export class SingleSelectPicker<T extends string> extends Container implements F
217
217
  searchable?: boolean;
218
218
  maximumVisible?: number;
219
219
  cancelLabel?: string;
220
+ /** Adds setup context and progress to the title. */
221
+ onboardingStep?: number;
220
222
  /** Extra footer legend, appended after the ✓ current marker. */
221
223
  legend?: string;
222
224
  /** Custom row body after the cursor and ✓ markers; handles its own active styling. */
@@ -245,7 +247,11 @@ export class SingleSelectPicker<T extends string> extends Container implements F
245
247
 
246
248
  this.addChild(panelBorder(theme));
247
249
  this.addChild(new Spacer(1));
248
- this.addChild(this.titleText);
250
+ if (typeof options.onboardingStep === "number") {
251
+ this.addChild(onboardingHeader(theme, options.title, options.onboardingStep));
252
+ } else {
253
+ this.addChild(this.titleText);
254
+ }
249
255
  if (this.subtitleText) this.addChild(this.subtitleText);
250
256
  this.addChild(new Spacer(1));
251
257
  if (options.searchable) {