@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 +23 -4
- package/package.json +1 -1
- package/src/chinese.ts +15 -0
- package/src/file-transcription.ts +1 -1
- package/src/onboarding.ts +63 -5
- package/src/openai-asr.ts +4 -4
- package/src/remote-asr.ts +212 -0
- package/src/runtime.ts +8 -6
- package/src/settings-menu.ts +21 -189
- package/src/try-it.ts +8 -3
- package/src/ui-components.ts +8 -2
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
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
|
|
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:
|
|
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
|
-
{
|
|
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:
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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 `
|
|
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
|
|
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
|
|
462
|
-
const { configured } = await ensureSettings(ctx);
|
|
461
|
+
const { configured, completedFirstRun } = await ensureSettings(ctx);
|
|
463
462
|
if (!configured) return;
|
|
464
|
-
if (
|
|
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
|
});
|
package/src/settings-menu.ts
CHANGED
|
@@ -2,11 +2,10 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
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:
|
|
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
|
|
442
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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,
|
|
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)];
|
package/src/ui-components.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function onboardingHeader(
|
|
|
37
37
|
theme: UiTheme,
|
|
38
38
|
title: string,
|
|
39
39
|
step: number,
|
|
40
|
-
total =
|
|
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
|
-
|
|
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) {
|