@tychenjiajun/pi-voice 0.2.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.
@@ -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,20 +17,20 @@ 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,
24
24
  type ChineseOutput,
25
25
  type MicrophoneSetting,
26
+ type PolishModelRef,
26
27
  type TranscribeSettings,
27
28
  type TranscriptionLanguage,
28
29
  } from "./settings.js";
29
30
  import { displayShortcut } from "./shortcut-core.js";
30
31
  import { createShortcutPicker } from "./shortcuts.js";
31
32
  import {
32
- LIST_PADDING,
33
33
  padToWidth,
34
- panelBorder,
35
34
  SingleSelectPicker,
36
35
  type SingleSelectChoice,
37
36
  } from "./ui-components.js";
@@ -42,16 +41,17 @@ const SETTINGS_LABEL_WIDTH = 25;
42
41
  // Text padding (2), cursor gutter (2), label column, and its gap (2).
43
42
  const SETTINGS_ROW_OVERHEAD = SETTINGS_LABEL_WIDTH + 6;
44
43
 
45
- type SettingsAction =
44
+ export type SettingsAction =
46
45
  | "preferred-languages"
47
46
  | "model"
48
47
  | "backend"
49
48
  | "transcription-language"
50
49
  | "chinese-output"
50
+ | "post-processing"
51
51
  | "microphone"
52
52
  | "shortcut";
53
53
 
54
- type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
54
+ export type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
55
55
  summary: string;
56
56
  /** Render the summary in the error color. */
57
57
  alert?: boolean;
@@ -91,7 +91,12 @@ function backendSummary(backend: BackendSettings): string {
91
91
  return backend.type === "openai-asr" ? `Remote · ${backend.model}` : "Local model";
92
92
  }
93
93
 
94
- function settingsHomeChoices(
94
+ function polishSummary(polishEnabled: boolean | undefined, model: PolishModelRef | undefined): string {
95
+ if (!polishEnabled) return "Off";
96
+ return model ? `${model.provider}/${model.id}` : "Pi's current model";
97
+ }
98
+
99
+ export function settingsHomeChoices(
95
100
  configured: TranscribeSettings,
96
101
  permission: Permission,
97
102
  ): SettingsHomeChoice[] {
@@ -116,17 +121,34 @@ function settingsHomeChoices(
116
121
  description:
117
122
  "Local model, or an OpenAI-compatible remote ASR endpoint (local model stays as fallback)",
118
123
  },
124
+ {
125
+ value: "post-processing",
126
+ label: "Post-processing",
127
+ summary: polishSummary(configured.polishEnabled, configured.polishModel),
128
+ description:
129
+ "Refine transcripts with Pi's current model after ASR, like context compaction",
130
+ },
119
131
  {
120
132
  value: "transcription-language",
121
133
  label: "Transcription language",
122
134
  summary: transcriptionLanguageSummary(configured.transcriptionLanguage, model),
123
- 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",
124
139
  },
125
140
  ];
126
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);
127
148
  if (
128
149
  isChineseLanguage(configured.transcriptionLanguage) ||
129
- configured.preferredLanguages.some(isChineseLanguage)
150
+ configured.preferredLanguages.some(isChineseLanguage) ||
151
+ detectsChinese
130
152
  ) {
131
153
  choices.push({
132
154
  value: "chinese-output",
@@ -266,212 +288,158 @@ async function chooseShortcut(
266
288
  );
267
289
  }
268
290
 
269
- /**
270
- * Single-field text prompt built on pi-tui's Input. Empty input may be legal
271
- * (the caller decides via validate); Escape backs out of the whole flow.
272
- */
273
- function promptText(
291
+ /** Backend row flow: pick the type, then collect remote settings step by step. */
292
+ async function editBackend(
274
293
  ctx: ExtensionContext,
275
- options: {
276
- title: string;
277
- description?: string;
278
- value: string;
279
- placeholder?: string;
280
- validate?: (value: string) => string | undefined;
281
- },
282
- ): Promise<string | undefined> {
283
- return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
284
- const keys = new VoiceKeys(keybindings);
285
- const input = new Input();
286
- input.setValue(options.value);
287
- let error: string | undefined;
288
- const container = new Container();
289
-
290
- const rebuild = (): void => {
291
- container.clear();
292
- container.addChild(panelBorder(theme));
293
- container.addChild(new Spacer(1));
294
- container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
295
- if (options.description) {
296
- container.addChild(new Text(theme.fg("muted", options.description), 1, 0));
297
- }
298
- container.addChild(new Spacer(1));
299
- if (error) {
300
- container.addChild(new Text(theme.fg("error", error), 1, 0));
301
- container.addChild(new Spacer(1));
302
- }
303
- if (!input.getValue() && options.placeholder) {
304
- container.addChild(new Text(theme.fg("dim", options.placeholder), 2, 0));
305
- }
306
- const inputBox = new Box(LIST_PADDING, 0);
307
- inputBox.addChild(input);
308
- container.addChild(inputBox);
309
- container.addChild(new Spacer(1));
310
- container.addChild(
311
- new Text(
312
- `${keys.hint("tui.input.submit", "save")} ${keys.hint("tui.select.cancel", "back")}`,
313
- 1,
314
- 0,
315
- ),
316
- );
317
- container.addChild(new Spacer(1));
318
- container.addChild(panelBorder(theme));
319
- };
320
- rebuild();
321
-
322
- input.onSubmit = (value) => {
323
- const problem = options.validate?.(value.trim());
324
- if (problem) {
325
- error = problem;
326
- rebuild();
327
- tui.requestRender();
328
- return;
329
- }
330
- done(value.trim());
331
- };
332
- input.onEscape = () => done(undefined);
294
+ configured: TranscribeSettings,
295
+ ): Promise<boolean> {
296
+ const type = await chooseBackendType(ctx, configured.backend);
297
+ if (!type) return false;
298
+ if (type === "transcribe-cpp") {
299
+ if (configured.backend.type === "transcribe-cpp") return false;
300
+ return saveUpdatedSettings(
301
+ ctx,
302
+ configured,
303
+ { ...configured, backend: { type: "transcribe-cpp" } },
304
+ "Using the local model",
305
+ );
306
+ }
333
307
 
334
- return {
335
- get focused() {
336
- return input.focused;
337
- },
338
- set focused(value: boolean) {
339
- input.focused = value;
340
- },
341
- render(width: number): string[] {
342
- return container
343
- .render(width)
344
- .map((line) => truncateToWidth(line, width, ""));
345
- },
346
- handleInput(data: string): void {
347
- input.handleInput(data);
348
- },
349
- invalidate(): void {
350
- container.invalidate();
351
- },
352
- };
353
- });
308
+ const current =
309
+ configured.backend.type === "openai-asr" ? configured.backend : undefined;
310
+ const backend = await promptRemoteBackend(ctx, current);
311
+ if (!backend) return false;
312
+
313
+ const saved = await saveUpdatedSettings(
314
+ ctx,
315
+ configured,
316
+ { ...configured, backend },
317
+ `Remote ASR configured (${backend.model})`,
318
+ );
319
+ if (saved) warnMissingApiKey(ctx, backend.apiKeyEnv);
320
+ return saved;
354
321
  }
355
322
 
356
- async function chooseBackendType(
323
+ async function choosePolishEnabled(
357
324
  ctx: ExtensionContext,
358
- current: BackendSettings,
359
- ): Promise<"transcribe-cpp" | "openai-asr" | undefined> {
360
- const choices: SingleSelectChoice<"transcribe-cpp" | "openai-asr">[] = [
325
+ current: boolean,
326
+ ): Promise<boolean | undefined> {
327
+ const choices: SingleSelectChoice<"off" | "on">[] = [
361
328
  {
362
- value: "transcribe-cpp",
363
- label: "Local model",
364
- description: "Transcribe fully on-device with the downloaded model; works offline",
329
+ value: "off",
330
+ label: "Off",
331
+ description: "Insert transcripts exactly as ASR output them",
365
332
  },
366
333
  {
367
- value: "openai-asr",
368
- label: "Remote ASR (OpenAI-compatible)",
334
+ value: "on",
335
+ label: "On",
369
336
  description:
370
- "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; falls back to the local model if the request fails",
337
+ "Refine transcripts after ASR with a Pi model and its provider auth, like context compaction; keeps the raw transcript on failure",
371
338
  },
372
339
  ];
373
- return ctx.ui.custom<"transcribe-cpp" | "openai-asr" | undefined>(
340
+ const chosen = await ctx.ui.custom<"off" | "on" | undefined>(
374
341
  (tui, theme, keybindings, done) =>
375
342
  new SingleSelectPicker(
376
343
  tui,
377
344
  theme,
378
345
  keybindings,
379
346
  choices,
380
- current.type,
381
- { title: "Transcription backend", cancelLabel: "back" },
347
+ current ? "on" : "off",
348
+ { title: "Post-processing", cancelLabel: "back" },
382
349
  done,
383
350
  ),
384
351
  );
352
+ return chosen === undefined ? undefined : chosen === "on";
385
353
  }
386
354
 
387
- function validateBaseUrl(value: string): string | undefined {
388
- if (!value) return "Base URL is required";
389
- try {
390
- const url = new URL(value);
391
- if (url.protocol !== "http:" && url.protocol !== "https:") {
392
- return "Base URL must start with http:// or https://";
393
- }
394
- } catch {
395
- return "Base URL must be a valid URL";
396
- }
397
- return undefined;
355
+ function polishModelKey(model: PolishModelRef): string {
356
+ return `${model.provider}/${model.id}`;
357
+ }
358
+
359
+ function samePolishModel(
360
+ left: PolishModelRef | undefined,
361
+ right: PolishModelRef | undefined,
362
+ ): boolean {
363
+ if (!left || !right) return left === right;
364
+ return left.provider === right.provider && left.id === right.id;
398
365
  }
399
366
 
400
- function validateEnvName(value: string): string | undefined {
401
- if (!value) return "Environment variable name is required";
402
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
403
- return "Use a plain environment variable name, e.g. OPENAI_API_KEY";
367
+ /** Pick Pi's current model (default) or any model with configured auth. */
368
+ async function choosePolishModel(
369
+ ctx: ExtensionContext,
370
+ current: PolishModelRef | undefined,
371
+ ): Promise<"current" | PolishModelRef | undefined> {
372
+ const available = ctx.modelRegistry.getAvailable();
373
+ const models = available.length > 0 ? available : ctx.modelRegistry.getAll();
374
+ const choices: SingleSelectChoice<string>[] = [
375
+ {
376
+ value: "current",
377
+ label: "Pi's current model",
378
+ description: "Use whichever model the agent is using (default)",
379
+ },
380
+ ];
381
+ const seen = new Set<string>();
382
+ for (const model of models) {
383
+ const key = `${model.provider}/${model.id}`;
384
+ if (key === "current" || seen.has(key)) continue;
385
+ seen.add(key);
386
+ choices.push({ value: key, label: key, description: model.name });
404
387
  }
405
- return undefined;
388
+ if (current && !seen.has(polishModelKey(current))) {
389
+ choices.push({
390
+ value: polishModelKey(current),
391
+ label: polishModelKey(current),
392
+ description: "Configured post-processing model (not currently available)",
393
+ });
394
+ }
395
+ const chosen = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
396
+ new SingleSelectPicker(
397
+ tui,
398
+ theme,
399
+ keybindings,
400
+ choices,
401
+ current ? polishModelKey(current) : "current",
402
+ { title: "Post-processing model", cancelLabel: "back" },
403
+ done,
404
+ ),
405
+ );
406
+ if (chosen === undefined) return undefined;
407
+ if (chosen === "current") return "current";
408
+ const slash = chosen.indexOf("/");
409
+ return { provider: chosen.slice(0, slash), id: chosen.slice(slash + 1) };
406
410
  }
407
411
 
408
- /** Backend row flow: pick the type, then collect remote settings step by step. */
409
- async function editBackend(
412
+ /** Post-processing row flow: toggle, then choose Pi's current model or an override. */
413
+ async function editPostProcessing(
410
414
  ctx: ExtensionContext,
411
415
  configured: TranscribeSettings,
412
416
  ): Promise<boolean> {
413
- const type = await chooseBackendType(ctx, configured.backend);
414
- if (!type) return false;
415
- if (type === "transcribe-cpp") {
416
- if (configured.backend.type === "transcribe-cpp") return false;
417
+ const enabled = await choosePolishEnabled(ctx, configured.polishEnabled ?? false);
418
+ if (enabled === undefined) return false;
419
+ if (!enabled) {
420
+ if (!configured.polishEnabled) return false;
417
421
  return saveUpdatedSettings(
418
422
  ctx,
419
423
  configured,
420
- { ...configured, backend: { type: "transcribe-cpp" } },
421
- "Using the local model",
424
+ { ...configured, polishEnabled: false },
425
+ "Post-processing disabled",
422
426
  );
423
427
  }
424
428
 
425
- const current =
426
- configured.backend.type === "openai-asr" ? configured.backend : undefined;
427
- const baseUrl = await promptText(ctx, {
428
- title: "ASR base URL",
429
- description: "OpenAI-compatible endpoint root, without the /chat/completions path",
430
- value: current?.baseUrl ?? "",
431
- placeholder: "https://api.example.com/v1",
432
- validate: validateBaseUrl,
433
- });
434
- if (baseUrl === undefined) return false;
435
-
436
- const model = await promptText(ctx, {
437
- title: "ASR model",
438
- value: current?.model ?? "",
439
- placeholder: "whisper-1",
440
- validate: (value) => (value ? undefined : "Model name is required"),
441
- });
442
- if (model === undefined) return false;
443
-
444
- const apiKeyEnv = await promptText(ctx, {
445
- title: "API key environment variable",
446
- description: "The key itself is read from this variable at runtime; it is never stored in settings",
447
- value: current?.apiKeyEnv ?? "",
448
- placeholder: "OPENAI_API_KEY",
449
- validate: validateEnvName,
450
- });
451
- if (apiKeyEnv === undefined) return false;
452
-
453
- const backend: BackendSettings = { type: "openai-asr", baseUrl, model, apiKeyEnv };
454
- if (
455
- current &&
456
- current.baseUrl === baseUrl &&
457
- current.model === model &&
458
- current.apiKeyEnv === apiKeyEnv
459
- ) {
429
+ const selection = await choosePolishModel(ctx, configured.polishModel);
430
+ if (selection === undefined) return false;
431
+ const polishModel = selection === "current" ? undefined : selection;
432
+ if (configured.polishEnabled && samePolishModel(configured.polishModel, polishModel)) {
460
433
  return false;
461
434
  }
462
- const saved = await saveUpdatedSettings(
435
+ return saveUpdatedSettings(
463
436
  ctx,
464
437
  configured,
465
- { ...configured, backend },
466
- `Remote ASR configured (${model})`,
438
+ { ...configured, polishEnabled: true, polishModel },
439
+ polishModel
440
+ ? `Post-processing enabled (${polishModelKey(polishModel)})`
441
+ : "Post-processing enabled (Pi's current model)",
467
442
  );
468
- if (saved && !process.env[apiKeyEnv]) {
469
- ctx.ui.notify(
470
- `${apiKeyEnv} is not set in Pi's environment; remote transcription will fall back to the local model until it is set`,
471
- "warning",
472
- );
473
- }
474
- return saved;
475
443
  }
476
444
 
477
445
  export async function openMacOSMicrophoneSettings(
@@ -549,6 +517,8 @@ export async function showSettingsMenu(
549
517
  currentModelId: configured.model.id,
550
518
  microphone: configured.microphone,
551
519
  backend: configured.backend,
520
+ polishEnabled: configured.polishEnabled,
521
+ polishModel: configured.polishModel,
552
522
  postActivation: "stay",
553
523
  onPreferredLanguagesChange: async (preferredLanguages) => {
554
524
  if (languagesEqual(preferredLanguages, configured.preferredLanguages)) return;
@@ -567,6 +537,11 @@ export async function showSettingsMenu(
567
537
  continue;
568
538
  }
569
539
 
540
+ if (action === "post-processing") {
541
+ await editPostProcessing(ctx, configured);
542
+ continue;
543
+ }
544
+
570
545
  if (action === "transcription-language") {
571
546
  const transcriptionLanguage = await chooseTranscriptionLanguage(ctx, configured);
572
547
  if (
package/src/settings.ts CHANGED
@@ -28,6 +28,12 @@ export type BackendSettings =
28
28
  | { type: "transcribe-cpp" }
29
29
  | { type: "openai-asr"; baseUrl: string; model: string; apiKeyEnv: string };
30
30
 
31
+ /**
32
+ * Explicit model for post-processing, resolved through Pi's model registry and
33
+ * its provider auth. Absent means the agent's current model.
34
+ */
35
+ export type PolishModelRef = { provider: string; id: string };
36
+
31
37
  function defaultChineseOutput(): ChineseOutput {
32
38
  const locale = Intl.DateTimeFormat().resolvedOptions().locale;
33
39
  const subtags = locale.toLowerCase().split("-");
@@ -47,6 +53,10 @@ export type TranscribeSettings = {
47
53
  transcriptionLanguage: TranscriptionLanguage;
48
54
  chineseOutput: ChineseOutput;
49
55
  microphone: MicrophoneSetting;
56
+ /** Optional post-processing with Pi's current model; absent means disabled (backward compatible). */
57
+ polishEnabled?: boolean;
58
+ /** Optional post-processing model override; absent uses the agent's current model. */
59
+ polishModel?: PolishModelRef;
50
60
  model: {
51
61
  source: "catalog";
52
62
  id: string;
@@ -147,6 +157,13 @@ function validateBackend(value: unknown): BackendSettings | undefined {
147
157
  return undefined;
148
158
  }
149
159
 
160
+ function normalizePolishModel(value: unknown): PolishModelRef | undefined {
161
+ if (!isObject(value)) return undefined;
162
+ if (typeof value.provider !== "string" || !value.provider.trim()) return undefined;
163
+ if (typeof value.id !== "string" || !value.id.trim()) return undefined;
164
+ return { provider: value.provider.trim(), id: value.id.trim() };
165
+ }
166
+
150
167
  function validateSettings(value: unknown): TranscribeSettings | undefined {
151
168
  if (!isObject(value) || value.version !== SETTINGS_VERSION) return undefined;
152
169
  const backend = validateBackend(value.backend);
@@ -163,6 +180,7 @@ function validateSettings(value: unknown): TranscribeSettings | undefined {
163
180
  const preferredLanguages = normalizeLanguages(value.preferredLanguages);
164
181
  const microphone = validateMicrophone(value.microphone);
165
182
  if (!preferredLanguages || !microphone) return undefined;
183
+ const polishModel = normalizePolishModel(value.polishModel);
166
184
 
167
185
  return {
168
186
  version: SETTINGS_VERSION,
@@ -176,6 +194,8 @@ function validateSettings(value: unknown): TranscribeSettings | undefined {
176
194
  ),
177
195
  chineseOutput: validateChineseOutput(value.chineseOutput),
178
196
  microphone,
197
+ polishEnabled: value.polishEnabled === true,
198
+ ...(polishModel ? { polishModel } : {}),
179
199
  model: {
180
200
  source: "catalog",
181
201
  id: value.model.id,
@@ -244,6 +264,10 @@ type ModelSettingsOptions = {
244
264
  microphone?: MicrophoneSetting;
245
265
  /** Preserved across model re-selection; defaults to the local backend. */
246
266
  backend?: BackendSettings;
267
+ /** Preserved across model re-selection; defaults to disabled. */
268
+ polishEnabled?: boolean;
269
+ /** Preserved across model re-selection; absent means the agent's current model. */
270
+ polishModel?: PolishModelRef;
247
271
  };
248
272
 
249
273
  export function settingsForModel(
@@ -268,6 +292,8 @@ export function settingsForModel(
268
292
  ),
269
293
  chineseOutput: options.chineseOutput ?? defaultChineseOutput(),
270
294
  microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
295
+ polishEnabled: options.polishEnabled ?? false,
296
+ ...(options.polishModel ? { polishModel: options.polishModel } : {}),
271
297
  model: { source: "catalog", id: modelId, path: modelPath },
272
298
  };
273
299
  }
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) {