@earendil-works/pi-voice 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/catalog/recommendations.json +710 -0
  4. package/index.ts +1 -0
  5. package/package.json +69 -0
  6. package/src/async-limiter.ts +77 -0
  7. package/src/audio-constants.ts +1 -0
  8. package/src/audio.ts +193 -0
  9. package/src/catalog.generated.ts +1305 -0
  10. package/src/catalog.ts +89 -0
  11. package/src/chinese.ts +52 -0
  12. package/src/deferred.ts +30 -0
  13. package/src/dictation-controller.ts +204 -0
  14. package/src/file-audio.ts +164 -0
  15. package/src/file-transcription.ts +212 -0
  16. package/src/index.ts +114 -0
  17. package/src/install-migration.ts +47 -0
  18. package/src/keybindings.ts +118 -0
  19. package/src/languages.ts +22 -0
  20. package/src/microphone-picker.ts +99 -0
  21. package/src/model-activation.ts +74 -0
  22. package/src/model-cells.ts +218 -0
  23. package/src/model-picker.ts +1026 -0
  24. package/src/model-ratings-help.md +41 -0
  25. package/src/model-ratings-help.ts +146 -0
  26. package/src/model-selection-controller.ts +185 -0
  27. package/src/models.ts +263 -0
  28. package/src/onboarding.ts +314 -0
  29. package/src/pcm-chunker.ts +42 -0
  30. package/src/pcm.ts +19 -0
  31. package/src/recommendation-picker.ts +512 -0
  32. package/src/recommendations.ts +423 -0
  33. package/src/runtime.ts +501 -0
  34. package/src/settings-menu.ts +410 -0
  35. package/src/settings-path.ts +13 -0
  36. package/src/settings.ts +235 -0
  37. package/src/shortcut-core.ts +85 -0
  38. package/src/shortcuts.ts +167 -0
  39. package/src/startup-shortcut.ts +24 -0
  40. package/src/transcript-preview.ts +52 -0
  41. package/src/transcription-service.ts +548 -0
  42. package/src/transcription.ts +186 -0
  43. package/src/try-it.ts +327 -0
  44. package/src/ui-components.ts +432 -0
  45. package/src/visualizer.ts +269 -0
@@ -0,0 +1,1026 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Box,
4
+ Container,
5
+ type Focusable,
6
+ fuzzyFilter,
7
+ Input,
8
+ Spacer,
9
+ Text,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ type KeybindingsManager,
13
+ type TUI,
14
+ } from "@earendil-works/pi-tui";
15
+ import {
16
+ CATALOG_MODELS,
17
+ canonicalLanguage,
18
+ languageIdentity,
19
+ displayLanguage,
20
+ formatBinarySize,
21
+ modelMatchesLanguage,
22
+ rankCatalogModels,
23
+ type CatalogModel,
24
+ } from "./catalog.js";
25
+ import {
26
+ benchmarkModels,
27
+ frontierModelIds,
28
+ getPreferredRecommendationLanguages,
29
+ recommendModels,
30
+ type ModelBenchmark,
31
+ } from "./recommendations.js";
32
+ import {
33
+ MANUAL_LANGUAGE_TAG,
34
+ ON_DISK_LABEL,
35
+ matchesCatalogSearch,
36
+ modelDetailText,
37
+ modelTableLayout,
38
+ modelTableRow,
39
+ ROLE_LABELS,
40
+ } from "./model-cells.js";
41
+ import { ModelSelectionController } from "./model-selection-controller.js";
42
+ import { ModelRatingsHelp } from "./model-ratings-help.js";
43
+ import type { CatalogModelActivation } from "./model-activation.js";
44
+ import { findIncompleteDownload } from "./models.js";
45
+ import { VoiceKeys } from "./keybindings.js";
46
+ import type { TranscriptionLanguage } from "./settings.js";
47
+ import {
48
+ DownloadPanel,
49
+ LIST_PADDING,
50
+ MIN_VISIBLE_ROWS,
51
+ onboardingHeader,
52
+ PANEL_PADDING,
53
+ padToWidth,
54
+ panelBorder,
55
+ paneListWindow,
56
+ paneRowBudget,
57
+ selectedWindow,
58
+ selectionMarker,
59
+ SingleSelectPicker,
60
+ windowSizeForBudget,
61
+ type SingleSelectChoice,
62
+ } from "./ui-components.js";
63
+
64
+ type UiTheme = ExtensionContext["ui"]["theme"];
65
+
66
+ const MAX_VISIBLE_LANGUAGES = 9;
67
+ const PREFERRED_RECOMMENDATION_LANGUAGES =
68
+ getPreferredRecommendationLanguages(CATALOG_MODELS);
69
+ const MAX_VISIBLE_MODELS = 16;
70
+
71
+ function formatEta(seconds: number): string {
72
+ if (seconds < 90) return `~${Math.max(1, Math.round(seconds))}s left`;
73
+ const minutes = Math.round(seconds / 60);
74
+ if (minutes < 90) return `~${minutes} min left`;
75
+ const hours = Math.floor(minutes / 60);
76
+ return `~${hours} h ${minutes % 60} min left`;
77
+ }
78
+ const TEXT_PADDING = PANEL_PADDING;
79
+ // Longest catalog language name is "Norwegian Nynorsk" (17).
80
+ const LANGUAGE_NAME_WIDTH = 20;
81
+ const TRANSCRIPTION_LANGUAGE_NAME_WIDTH = 28;
82
+ /**
83
+ * A line in the model list: a section heading, a model, or the fold that
84
+ * hides the models missing one of the chosen languages.
85
+ */
86
+ type ListRow =
87
+ | { type: "gap" }
88
+ | { type: "section"; label: string }
89
+ | { type: "model"; model: CatalogModel }
90
+ | { type: "fold"; count: number };
91
+
92
+ function transcriptionLanguageName(
93
+ language: string,
94
+ supportedLanguages: readonly string[],
95
+ ): string {
96
+ const base = canonicalLanguage(language);
97
+ const variants = supportedLanguages.filter(
98
+ (supported) => canonicalLanguage(supported) === base,
99
+ );
100
+ return displayLanguage(variants.length > 1 ? language : base);
101
+ }
102
+
103
+ export type LanguageSelection = {
104
+ languages: string[];
105
+ /** False when the picker was closed with Esc instead of Continue. */
106
+ confirmed: boolean;
107
+ };
108
+
109
+ export class LanguagePicker extends Container implements Focusable {
110
+ private readonly search = new Input();
111
+ private readonly list = new Container();
112
+ private readonly footer = new Text("", TEXT_PADDING, 0);
113
+ private readonly selected: Set<string>;
114
+ private readonly available: readonly string[];
115
+ private ordered: string[] = [];
116
+ private filtered: string[] = [];
117
+ private selectedIndex = 0;
118
+ /** Scroll-window rows (rule included); shrinks to fit short terminals. */
119
+ private windowRows = MAX_VISIBLE_LANGUAGES + 1;
120
+ private _focused = false;
121
+
122
+ get focused(): boolean {
123
+ return this._focused;
124
+ }
125
+
126
+ set focused(value: boolean) {
127
+ this._focused = value;
128
+ this.search.focused = value;
129
+ }
130
+
131
+ private readonly keys: VoiceKeys;
132
+
133
+ constructor(
134
+ private readonly tui: TUI,
135
+ private readonly theme: UiTheme,
136
+ keybindings: KeybindingsManager,
137
+ initial: readonly string[],
138
+ private readonly cancelLabel: string,
139
+ private readonly done: (result: LanguageSelection | undefined) => void,
140
+ private readonly onboardingStep?: number,
141
+ ) {
142
+ super();
143
+ this.keys = new VoiceKeys(keybindings);
144
+ // Benchmark filtering controls new choices, not existing preferences.
145
+ // Keep saved languages visible and removable even if their support worsens.
146
+ this.selected = new Set(initial.map(languageIdentity).filter(Boolean));
147
+ this.available = [...new Set([...PREFERRED_RECOMMENDATION_LANGUAGES, ...this.selected])];
148
+ this.reorder();
149
+
150
+ this.addChild(panelBorder(theme));
151
+ this.addChild(new Spacer(1));
152
+ this.addChild(
153
+ onboardingStep
154
+ ? onboardingHeader(theme, "Choose your languages", onboardingStep)
155
+ : new Text(
156
+ theme.fg("accent", theme.bold("Select the languages you speak")),
157
+ TEXT_PADDING,
158
+ 0,
159
+ ),
160
+ );
161
+ this.addChild(
162
+ new Text(
163
+ onboardingStep
164
+ ? "Which languages will you speak to Pi in?"
165
+ : theme.fg("muted", "Used to recommend models"),
166
+ TEXT_PADDING,
167
+ 0,
168
+ ),
169
+ );
170
+ this.addChild(new Spacer(1));
171
+ // The search caret sits in the gutter, aligned with the list cursor; its
172
+ // "> " prompt then puts the typed query on the content edge.
173
+ const searchBox = new Box(LIST_PADDING, 0);
174
+ searchBox.addChild(this.search);
175
+ this.addChild(searchBox);
176
+ this.addChild(new Spacer(1));
177
+ this.addChild(this.list);
178
+ this.addChild(new Spacer(1));
179
+ this.addChild(this.footer);
180
+ this.addChild(new Spacer(1));
181
+ this.addChild(panelBorder(theme));
182
+ this.refresh();
183
+ }
184
+
185
+ private selectedLanguages(): string[] {
186
+ return this.available.filter((language) =>
187
+ this.selected.has(language),
188
+ );
189
+ }
190
+
191
+ // Selected languages are pinned to the top of the list so the current
192
+ // selection is always visible without scrolling.
193
+ private reorder(): void {
194
+ const available = this.available;
195
+ this.ordered = [
196
+ ...available.filter((language) => this.selected.has(language)),
197
+ ...available.filter((language) => !this.selected.has(language)),
198
+ ];
199
+ }
200
+
201
+ private continueRowIndex(): number {
202
+ return this.filtered.length;
203
+ }
204
+
205
+ private refresh(focusLanguage?: string): void {
206
+ const query = this.search.getValue().trim();
207
+ this.filtered = query
208
+ ? fuzzyFilter(this.ordered, query, (language) => `${displayLanguage(language)} ${language}`)
209
+ : this.ordered;
210
+ if (focusLanguage) {
211
+ const index = this.filtered.indexOf(focusLanguage);
212
+ if (index >= 0) this.selectedIndex = index;
213
+ }
214
+ this.selectedIndex = Math.min(this.selectedIndex, this.continueRowIndex());
215
+ this.list.clear();
216
+
217
+ if (this.filtered.length === 0) {
218
+ this.list.addChild(new Text(this.theme.fg("muted", " No matching languages"), LIST_PADDING, 0));
219
+ } else {
220
+ // In the unfiltered list, a rule separates the pinned (selected) group
221
+ // from the rest. It is a real row in the scroll window (null entry), so
222
+ // it scrolls like any other line instead of appearing and disappearing,
223
+ // which would shift the layout below the list.
224
+ const boundary =
225
+ !query && this.selected.size > 0 && this.selected.size < this.filtered.length
226
+ ? this.selected.size
227
+ : -1;
228
+ const rows: (string | null)[] =
229
+ boundary >= 0
230
+ ? [...this.filtered.slice(0, boundary), null, ...this.filtered.slice(boundary)]
231
+ : [...this.filtered];
232
+ const cursorRow =
233
+ boundary >= 0 && this.selectedIndex >= boundary
234
+ ? this.selectedIndex + 1
235
+ : this.selectedIndex;
236
+ // Sized +1 so the window holds the same line count with or without the
237
+ // rule row.
238
+ const [start, end] = selectedWindow(rows, cursorRow, this.windowRows);
239
+ for (let index = start; index < end; index += 1) {
240
+ const language = rows[index]!;
241
+ if (language === null) {
242
+ this.list.addChild(
243
+ new Text(` ${this.theme.fg("dim", "─".repeat(LANGUAGE_NAME_WIDTH + 6))}`, LIST_PADDING, 0),
244
+ );
245
+ continue;
246
+ }
247
+ const active = index === cursorRow;
248
+ const checked = this.selected.has(language);
249
+ const prefix = active ? this.theme.fg("accent", "→ ") : " ";
250
+ const mark = selectionMarker(this.theme, checked);
251
+ const name = padToWidth(displayLanguage(language), LANGUAGE_NAME_WIDTH);
252
+ this.list.addChild(
253
+ new Text(
254
+ `${prefix}${mark} ${active ? this.theme.fg("accent", name) : name}${this.theme.fg("dim", language)}`,
255
+ LIST_PADDING,
256
+ 0,
257
+ ),
258
+ );
259
+ }
260
+ }
261
+
262
+ const selected = this.selectedLanguages();
263
+ const onContinue = this.selectedIndex === this.continueRowIndex();
264
+ const continuePrefix = onContinue ? this.theme.fg("accent", "→ ") : " ";
265
+ const continueAction = ` ${this.keys.keyText("voice.languages.continue")} Continue `;
266
+ const continueRow = selected.length === 0
267
+ ? this.theme.fg("warning", "Select at least one language to continue")
268
+ : this.theme.inverse(
269
+ this.theme.fg("accent", this.theme.bold(continueAction)),
270
+ );
271
+ this.list.addChild(new Spacer(1));
272
+ this.list.addChild(new Text(`${continuePrefix}${continueRow}`, LIST_PADDING, 0));
273
+
274
+ this.footer.setText(
275
+ `${this.keys.navHint("move")} ${this.keys.hint(["voice.languages.toggle", "tui.select.confirm"], "select")} ${this.keys.hint("tui.select.cancel", query ? "clear search" : this.cancelLabel)}`,
276
+ );
277
+ this.tui.requestRender();
278
+ }
279
+
280
+ private toggleHighlighted(): void {
281
+ const language = this.filtered[this.selectedIndex];
282
+ if (!language) return;
283
+ const adding = !this.selected.has(language);
284
+ if (adding) this.selected.add(language);
285
+ else this.selected.delete(language);
286
+ this.reorder();
287
+ // A search query is spent once used: clear it so the full list returns.
288
+ this.search.setValue("");
289
+ // Follow a newly selected language so the user sees it land in the pinned
290
+ // group; on deselect stay put — trailing the language to its new spot far
291
+ // down the list is disorienting.
292
+ this.refresh(adding ? language : undefined);
293
+ }
294
+
295
+ // The pane replaces the host editor and cannot scroll: when the terminal is
296
+ // short, shrink the window so the title, Continue row, and footer stay on
297
+ // screen.
298
+ override render(width: number): string[] {
299
+ const budget = paneRowBudget(this.tui);
300
+ if (budget !== undefined) {
301
+ const chrome = super.render(width).length - this.list.render(width).length;
302
+ // The spacer and Continue row live inside the list; the scroll window
303
+ // gets the rest, still +1 sized for the rule row.
304
+ const rows = windowSizeForBudget(
305
+ budget - chrome - 2,
306
+ MAX_VISIBLE_LANGUAGES + 1,
307
+ MIN_VISIBLE_ROWS + 1,
308
+ );
309
+ if (rows !== this.windowRows) {
310
+ this.windowRows = rows;
311
+ this.refresh();
312
+ }
313
+ }
314
+ return super.render(width);
315
+ }
316
+
317
+ handleInput(data: string): void {
318
+ const lastIndex = this.continueRowIndex();
319
+ if (this.keys.matches(data, "voice.languages.continue")) {
320
+ const selected = this.selectedLanguages();
321
+ if (selected.length > 0) this.done({ languages: selected, confirmed: true });
322
+ return;
323
+ }
324
+ if (this.keys.matches(data, "tui.select.up")) {
325
+ this.selectedIndex = this.selectedIndex === 0 ? lastIndex : this.selectedIndex - 1;
326
+ this.refresh();
327
+ return;
328
+ }
329
+ if (this.keys.matches(data, "tui.select.down")) {
330
+ this.selectedIndex = this.selectedIndex === lastIndex ? 0 : this.selectedIndex + 1;
331
+ this.refresh();
332
+ return;
333
+ }
334
+ if (this.keys.matches(data, "voice.languages.toggle")) {
335
+ this.toggleHighlighted();
336
+ return;
337
+ }
338
+ if (this.keys.matches(data, "tui.select.confirm")) {
339
+ if (this.selectedIndex === this.continueRowIndex()) {
340
+ const selected = this.selectedLanguages();
341
+ if (selected.length > 0) this.done({ languages: selected, confirmed: true });
342
+ return;
343
+ }
344
+ // Enter on a language toggles it, so landing Enter never silently
345
+ // confirms a selection the user was not pointing at.
346
+ this.toggleHighlighted();
347
+ return;
348
+ }
349
+ if (this.keys.matches(data, "tui.select.cancel")) {
350
+ if (this.search.getValue()) {
351
+ this.search.setValue("");
352
+ this.selectedIndex = 0;
353
+ this.refresh();
354
+ } else {
355
+ // Esc keeps the current selection; only an empty selection reads as
356
+ // "never mind".
357
+ const selected = this.selectedLanguages();
358
+ this.done(
359
+ selected.length > 0 ? { languages: selected, confirmed: false } : undefined,
360
+ );
361
+ }
362
+ return;
363
+ }
364
+
365
+ this.search.handleInput(data);
366
+ this.selectedIndex = 0;
367
+ this.refresh();
368
+ }
369
+ }
370
+
371
+ export type CatalogModelPickerResult =
372
+ | { type: "change-languages" }
373
+ | { type: "complete" };
374
+
375
+ export type CatalogModelPostActivation = "stay" | "advance";
376
+
377
+ export type CatalogModelPickerOptions = {
378
+ /** What the host does after activation and its settings commit succeed. */
379
+ postActivation?: CatalogModelPostActivation;
380
+ /** The host is reopening this picker after an activation in the same flow. */
381
+ activatedInFlow?: boolean;
382
+ /** What Esc does once there is no search to clear; the host knows where it leads. */
383
+ cancelLabel?: string;
384
+ /** Optional onboarding shell for catalog detours. */
385
+ onboardingStep?: number;
386
+ title?: string;
387
+ };
388
+
389
+ export class CatalogModelPicker extends Container implements Focusable {
390
+ private readonly search = new Input();
391
+ private readonly searchBox = new Box(LIST_PADDING, 0);
392
+ private readonly body = new Container();
393
+ private readonly preferredLine = new Text("", TEXT_PADDING, 0);
394
+ private readonly list = new Container();
395
+ private readonly detail = new Text("", TEXT_PADDING, 0);
396
+ private readonly footer = new Text("", TEXT_PADDING, 0);
397
+ private readonly ratingsHelp: ModelRatingsHelp;
398
+ private readonly selection: ModelSelectionController<CatalogModelPickerResult | undefined>;
399
+ private readonly cancelLabel: string;
400
+ private readonly languageColumns: readonly string[];
401
+ /** Benchmarks on every chosen language; absent models miss one. */
402
+ private readonly benchmarks: ReadonlyMap<string, ModelBenchmark>;
403
+ /** Frontier models and role picks, most accurate first. */
404
+ private readonly recommended: readonly CatalogModel[];
405
+ /** The other benchmarked models, most accurate first, the unusable last. */
406
+ private readonly benchmarked: readonly CatalogModel[];
407
+ /** The rest: missing a chosen language or a benchmark for it. */
408
+ private readonly unbenchmarked: readonly CatalogModel[];
409
+ private readonly roleTags: ReadonlyMap<string, string>;
410
+ private folded = true;
411
+ /** Widest model name / formatted size in the catalog; column ceilings. */
412
+ private readonly modelNameWidth: number;
413
+ private readonly modelSizeWidth: number;
414
+ /** Width of the last render; row columns are laid out against it. */
415
+ private renderWidth = 80;
416
+ /** Rows the model window may use; shrinks to fit short terminals. */
417
+ private visibleModels = MAX_VISIBLE_MODELS;
418
+ private rows: ListRow[] = [];
419
+ /** The models in list order; the cursor only ever rests on these or the fold. */
420
+ private filtered: CatalogModel[] = [];
421
+ private selectedIndex = 0;
422
+ private downloadPanel: DownloadPanel | undefined;
423
+ private disposed = false;
424
+ private _focused = false;
425
+
426
+ get focused(): boolean {
427
+ return this._focused;
428
+ }
429
+
430
+ set focused(value: boolean) {
431
+ this._focused = value;
432
+ this.search.focused = value && !this.selection.download && !this.ratingsHelp.isOpen;
433
+ }
434
+
435
+ private readonly keys: VoiceKeys;
436
+
437
+ constructor(
438
+ private readonly tui: TUI,
439
+ private readonly theme: UiTheme,
440
+ keybindings: KeybindingsManager,
441
+ private readonly preferredLanguages: readonly string[],
442
+ currentModelId: string | undefined,
443
+ private readonly done: (result: CatalogModelPickerResult | undefined) => void,
444
+ private readonly onActivate: CatalogModelActivation,
445
+ options: CatalogModelPickerOptions = {},
446
+ ) {
447
+ super();
448
+ this.keys = new VoiceKeys(keybindings);
449
+ this.cancelLabel = options.cancelLabel ?? "close";
450
+ this.ratingsHelp = new ModelRatingsHelp(tui, theme, this.keys, true);
451
+ this.selection = new ModelSelectionController<CatalogModelPickerResult | undefined>((...args) => this.onActivate(...args), {
452
+ models: CATALOG_MODELS,
453
+ currentModelId,
454
+ activatedInFlow: options.activatedInFlow,
455
+ advance: options.postActivation === "advance",
456
+ completion: { type: "complete" },
457
+ onChange: () => this.refresh(),
458
+ onExit: (result) => { this.ratingsHelp.close(); this.stopSpinner(); this.done(result); },
459
+ });
460
+ this.languageColumns = [...new Set(preferredLanguages.map(languageIdentity))];
461
+ // Without chosen languages there is nothing to benchmark against, so the
462
+ // list falls back to the catalog's own ranking, unsectioned.
463
+ this.benchmarks = this.languageColumns.length
464
+ ? benchmarkModels(CATALOG_MODELS, this.languageColumns)
465
+ : new Map();
466
+ const byError = (left: CatalogModel, right: CatalogModel) =>
467
+ this.benchmarks.get(left.id)!.error - this.benchmarks.get(right.id)!.error;
468
+ const measured = CATALOG_MODELS.filter((model) => this.benchmarks.has(model.id));
469
+ const byAccuracy = [
470
+ ...measured.filter((model) => this.benchmarks.get(model.id)!.usable).sort(byError),
471
+ ...measured.filter((model) => !this.benchmarks.get(model.id)!.usable).sort(byError),
472
+ ];
473
+ // The model in use is never folded away, whatever the chosen languages:
474
+ // it takes the last row of the second section, dashes and all.
475
+ const current = CATALOG_MODELS.find(
476
+ (model) => model.id === currentModelId && !this.benchmarks.has(model.id),
477
+ );
478
+ this.unbenchmarked = rankCatalogModels(
479
+ CATALOG_MODELS.filter((model) => !this.benchmarks.has(model.id) && model !== current),
480
+ preferredLanguages,
481
+ (model) => this.selection.cachedById.has(model.id),
482
+ );
483
+ // The picks carry their role; a pick that is also a frontier model is
484
+ // still listed once.
485
+ const roleTags = new Map<string, string>();
486
+ if (this.languageColumns.length) {
487
+ for (const pick of recommendModels(CATALOG_MODELS, this.languageColumns)) {
488
+ if (pick.status !== "eligible") continue;
489
+ roleTags.set(pick.model.id, pick.roles.map((role) => ROLE_LABELS[role]).join(" · "));
490
+ }
491
+ }
492
+ this.roleTags = roleTags;
493
+ // A model is listed once: recommended, or among the rest.
494
+ const frontier = frontierModelIds(this.benchmarks);
495
+ this.recommended = byAccuracy.filter(
496
+ (model) => frontier.has(model.id) || roleTags.has(model.id),
497
+ );
498
+ this.benchmarked = [
499
+ ...byAccuracy.filter((model) => !this.recommended.includes(model)),
500
+ ...(current ? [current] : []),
501
+ ];
502
+
503
+ this.modelNameWidth = Math.max(
504
+ ...CATALOG_MODELS.map((model) => visibleWidth(model.name)),
505
+ );
506
+ this.modelSizeWidth = Math.max(
507
+ visibleWidth(ON_DISK_LABEL),
508
+ ...CATALOG_MODELS.map((model) => visibleWidth(formatBinarySize(model.size))),
509
+ );
510
+
511
+ const title = options.title ?? (options.onboardingStep ? "Browse all models" : "Choose a model");
512
+ this.searchBox.addChild(this.search);
513
+ this.addChild(panelBorder(theme));
514
+ this.addChild(new Spacer(1));
515
+ this.addChild(
516
+ options.onboardingStep
517
+ ? onboardingHeader(theme, title, options.onboardingStep)
518
+ : new Text(
519
+ theme.fg("accent", theme.bold(title)),
520
+ TEXT_PADDING,
521
+ 0,
522
+ ),
523
+ );
524
+ this.addChild(this.preferredLine);
525
+ this.addChild(this.body);
526
+ this.addChild(new Spacer(1));
527
+ this.addChild(panelBorder(theme));
528
+
529
+ // The cursor opens on the model in use, the first row of Downloaded;
530
+ // without one it rests at the top, on the best recommendation.
531
+ this.refresh();
532
+ if (currentModelId) {
533
+ const index = this.rows.findIndex(
534
+ (row) => row.type === "model" && row.model.id === currentModelId,
535
+ );
536
+ if (index !== -1) {
537
+ this.selectedIndex = index;
538
+ this.refresh();
539
+ }
540
+ }
541
+ }
542
+
543
+ /** Cached models, current first, then most accurate on the chosen languages. */
544
+ private downloadedModels(): CatalogModel[] {
545
+ const currentId = this.selection.displayedModelId;
546
+ const error = (model: CatalogModel) =>
547
+ this.benchmarks.get(model.id)?.error ?? Number.POSITIVE_INFINITY;
548
+ return CATALOG_MODELS.filter((model) => this.selection.cachedById.has(model.id)).sort(
549
+ (left, right) =>
550
+ Number(right.id === currentId) - Number(left.id === currentId) ||
551
+ error(left) - error(right),
552
+ );
553
+ }
554
+
555
+ // Section headings and the gaps above them are landmarks, not choices:
556
+ // the cursor skips them.
557
+ private selectable(index: number): boolean {
558
+ const type = this.rows[index]?.type;
559
+ return type !== "section" && type !== "gap";
560
+ }
561
+
562
+ private moveSelection(step: 1 | -1): void {
563
+ if (!this.rows.some((_, index) => this.selectable(index))) return;
564
+ let index = this.selectedIndex;
565
+ do {
566
+ index = (index + step + this.rows.length) % this.rows.length;
567
+ } while (!this.selectable(index));
568
+ this.selectedIndex = index;
569
+ this.refresh();
570
+ }
571
+
572
+ private highlightedModel(): CatalogModel | undefined {
573
+ const row = this.rows[this.selectedIndex];
574
+ return row?.type === "model" ? row.model : undefined;
575
+ }
576
+
577
+ // The sectioned list: models on disk first, then the ranked catalog with
578
+ // each of them left out, so a model is listed once. A search filters each
579
+ // section in place, in its own order, and reaches the folded models too:
580
+ // while a query is on they are a section of their own, so a match there
581
+ // says why it was folded.
582
+ private buildRows(query: string): ListRow[] {
583
+ const downloaded = this.downloadedModels();
584
+ const onDisk = new Set(downloaded.map((model) => model.id));
585
+ const matching = (models: readonly CatalogModel[]) =>
586
+ query ? models.filter((model) => matchesCatalogSearch(model, query)) : models;
587
+ const keep = (models: readonly CatalogModel[]) =>
588
+ matching(models).filter((model) => !onDisk.has(model.id));
589
+ const rows: ListRow[] = [];
590
+ const section = (label: string, models: readonly CatalogModel[]) => {
591
+ if (!models.length) return;
592
+ if (rows.length) rows.push({ type: "gap" });
593
+ rows.push({ type: "section", label });
594
+ for (const model of models) rows.push({ type: "model", model });
595
+ };
596
+ // Every section row is also the column header row, so the language the
597
+ // grades cover is on the same line and the labels can stay short.
598
+ section("Downloaded", matching(downloaded));
599
+ if (!this.languageColumns.length) {
600
+ section("All models", keep(this.unbenchmarked));
601
+ return rows;
602
+ }
603
+ section("Recommended", keep(this.recommended));
604
+ section(`${this.recommended.length ? "Other" : "All"} models`, keep(this.benchmarked));
605
+ const rest = keep(this.unbenchmarked);
606
+ if (query) {
607
+ section("Other languages", rest);
608
+ } else if (rest.length) {
609
+ rows.push({ type: "fold", count: rest.length });
610
+ if (!this.folded) for (const model of rest) rows.push({ type: "model", model });
611
+ }
612
+ return rows;
613
+ }
614
+
615
+ override invalidate(): void {
616
+ super.invalidate();
617
+ this.ratingsHelp.invalidate();
618
+ }
619
+
620
+ // Column widths depend on the terminal: relay out when the width changes so
621
+ // rows truncate their name column instead of wrapping onto a second line.
622
+ // Short terminals also shrink the list window so the title, Languages line,
623
+ // detail, and footer stay on screen; the downloading panel is short enough
624
+ // to be exempt.
625
+ override render(width: number): string[] {
626
+ if (this.ratingsHelp.isOpen) return this.ratingsHelp.render(width);
627
+ if (width !== this.renderWidth) {
628
+ this.renderWidth = width;
629
+ this.refresh();
630
+ }
631
+ const visible = this.selection.download
632
+ ? undefined
633
+ : paneListWindow(
634
+ this.tui,
635
+ super.render(width).length,
636
+ this.list.render(width).length,
637
+ this.detail.render(width).length,
638
+ this.detailReserve(width),
639
+ MAX_VISIBLE_MODELS,
640
+ );
641
+ if (visible !== undefined && visible !== this.visibleModels) {
642
+ this.visibleModels = visible;
643
+ this.refresh();
644
+ }
645
+ return super.render(width);
646
+ }
647
+
648
+ // The description is truncated to one line, so only transient feedback can
649
+ // change the detail height; reserving for it keeps the window steady.
650
+ private detailReserve(width: number): number {
651
+ const feedbackLines = this.selection.feedback
652
+ ? new Text(this.selection.feedback.text, TEXT_PADDING, 0).render(width).length
653
+ : 0;
654
+ // One description line plus the features line.
655
+ return 2 + feedbackLines;
656
+ }
657
+
658
+ private refresh(): void {
659
+ if (this.disposed) return;
660
+ this.body.clear();
661
+ if (!this.selection.download) this.stopSpinner();
662
+ const preferredAction = this.selection.selectedDuringSession
663
+ ? ""
664
+ : ` · ${this.keys.hint("voice.languages.change", "change")}`;
665
+ const languagesText = truncateToWidth(
666
+ `Your languages: ${this.preferredLanguages.map(displayLanguage).join(", ")}`,
667
+ Math.max(24, this.renderWidth - TEXT_PADDING * 2 - visibleWidth(preferredAction)),
668
+ "…",
669
+ );
670
+ this.preferredLine.setText(`${this.theme.fg("muted", languagesText)}${preferredAction}`);
671
+ this.search.focused = this._focused && !this.selection.download && !this.ratingsHelp.isOpen;
672
+
673
+ if (this.selection.download) {
674
+ this.downloadPanel ??= new DownloadPanel(this.tui, this.theme, this.keys, this.selection.download);
675
+ this.downloadPanel.update(this.selection.download, this.downloadStats());
676
+ this.body.addChild(this.downloadPanel);
677
+ this.tui.requestRender();
678
+ return;
679
+ }
680
+
681
+ this.body.addChild(new Spacer(1));
682
+ this.body.addChild(this.searchBox);
683
+ this.body.addChild(new Spacer(1));
684
+ this.body.addChild(this.list);
685
+ this.body.addChild(new Spacer(1));
686
+ this.body.addChild(this.detail);
687
+ this.body.addChild(new Spacer(1));
688
+ this.body.addChild(this.footer);
689
+
690
+ const query = this.search.getValue().trim();
691
+ // Selecting a model reorders the Downloaded section, so the cursor
692
+ // follows the highlighted model rather than its old row number.
693
+ const highlightedId = this.highlightedModel()?.id;
694
+ this.rows = this.buildRows(query);
695
+ this.filtered = this.rows.flatMap((row) => (row.type === "model" ? [row.model] : []));
696
+ const followed = highlightedId === undefined
697
+ ? -1
698
+ : this.rows.findIndex((row) => row.type === "model" && row.model.id === highlightedId);
699
+ this.selectedIndex = followed !== -1
700
+ ? followed
701
+ : Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
702
+ if (!this.selectable(this.selectedIndex)) {
703
+ const next = this.rows.findIndex((_, index) => index > this.selectedIndex && this.selectable(index));
704
+ this.selectedIndex = next === -1 ? this.selectedIndex : next;
705
+ }
706
+ this.list.clear();
707
+ const displayedId = this.selection.displayedModelId;
708
+
709
+ if (this.rows.length === 0) {
710
+ this.list.addChild(new Text(this.theme.fg("dim", " No matching models"), LIST_PADDING, 0));
711
+ this.detail.setText("");
712
+ } else {
713
+ const [start, end] = selectedWindow(this.rows, this.selectedIndex, this.visibleModels);
714
+ const table = modelTableLayout(
715
+ this.theme,
716
+ this.renderWidth,
717
+ this.modelNameWidth,
718
+ this.languageColumns,
719
+ this.modelSizeWidth,
720
+ );
721
+ for (let index = start; index < end; index += 1) {
722
+ const row = this.rows[index]!;
723
+ const active = index === this.selectedIndex;
724
+ const prefix = active ? this.theme.fg("accent", "→ ") : " ";
725
+ if (row.type === "gap") {
726
+ this.list.addChild(new Spacer(1));
727
+ continue;
728
+ }
729
+ if (row.type === "section") {
730
+ this.list.addChild(new Text(table.header(row.label), LIST_PADDING, 0));
731
+ continue;
732
+ }
733
+ if (row.type === "fold") {
734
+ const arrow = this.folded ? "▸" : "▾";
735
+ const label = `${arrow} ${row.count} more models missing one of your languages`;
736
+ this.list.addChild(
737
+ new Text(`${prefix} ${active ? this.theme.fg("accent", label) : label}`, LIST_PADDING, 0),
738
+ );
739
+ continue;
740
+ }
741
+ const model = row.model;
742
+ const benchmark = this.benchmarks.get(model.id);
743
+ const role = this.roleTags.get(model.id);
744
+ const tag = role
745
+ ? this.theme.fg("accent", role)
746
+ : benchmark?.manual
747
+ ? this.theme.fg("dim", MANUAL_LANGUAGE_TAG)
748
+ : "";
749
+ this.list.addChild(
750
+ new Text(
751
+ modelTableRow(this.theme, model, this.languageColumns, table, {
752
+ active,
753
+ current: model.id === displayedId,
754
+ tag,
755
+ downloaded: this.selection.cachedById.has(model.id),
756
+ }),
757
+ LIST_PADDING,
758
+ 0,
759
+ ),
760
+ );
761
+ }
762
+ const selected = this.highlightedModel();
763
+ if (!selected) {
764
+ this.detail.setText(
765
+ `${this.theme.fg("muted", "Models that lack one of your languages, or a benchmark for it.")}\n${this.theme.fg("dim", this.folded ? "Enter shows them" : "Enter hides them again")}`,
766
+ );
767
+ this.finishFooter(query);
768
+ return;
769
+ }
770
+ this.detail.setText(
771
+ modelDetailText(
772
+ this.theme,
773
+ selected,
774
+ this.renderWidth,
775
+ TEXT_PADDING,
776
+ this.selection.feedback,
777
+ ),
778
+ );
779
+ }
780
+ this.finishFooter(query);
781
+ }
782
+
783
+ private finishFooter(query: string): void {
784
+ const displayedId = this.selection.displayedModelId;
785
+ const total = CATALOG_MODELS.length;
786
+ const shown = query
787
+ ? `${this.filtered.length}/${total} matching models`
788
+ : `${total} models`;
789
+ const statusLegend = displayedId
790
+ ? `${selectionMarker(this.theme, true)} ${this.theme.fg("dim", "current")}`
791
+ : "";
792
+ const closeLabel = query ? "clear search" : this.cancelLabel;
793
+ // The confirm key says what it will do for the highlighted row.
794
+ const highlighted = this.highlightedModel();
795
+ const confirmLabel = this.rows[this.selectedIndex]?.type === "fold"
796
+ ? this.folded ? "show" : "hide"
797
+ : highlighted && !this.selection.cachedById.has(highlighted.id)
798
+ ? findIncompleteDownload(highlighted)
799
+ ? "resume download"
800
+ : `download ${formatBinarySize(highlighted.size)}`
801
+ : "choose";
802
+ this.footer.setText(
803
+ `${this.theme.fg("dim", shown)} ${statusLegend} ${this.keys.hint("voice.models.ratingsHelp", "rating guide")}\n${this.keys.navHint("navigate")} ${this.keys.hint("tui.select.confirm", confirmLabel)} ${this.keys.hint("tui.select.cancel", closeLabel)}`,
804
+ );
805
+ this.tui.requestRender();
806
+ }
807
+
808
+ private downloadStats(): string {
809
+ const { downloaded, total } = this.selection.download!;
810
+ if (total === 0) return "Preparing download…";
811
+ const parts = [`${formatBinarySize(downloaded)} / ${formatBinarySize(total)}`];
812
+ const speed = this.selection.downloadSpeed;
813
+ if (speed !== undefined && speed > 0) {
814
+ parts.push(`${formatBinarySize(speed)}/s`);
815
+ const remaining = (total - downloaded) / speed;
816
+ if (remaining > 1) parts.push(formatEta(remaining));
817
+ }
818
+ return parts.join(" · ");
819
+ }
820
+
821
+ private stopSpinner(): void {
822
+ this.downloadPanel?.dispose();
823
+ this.downloadPanel = undefined;
824
+ }
825
+
826
+ handleInput(data: string): void {
827
+ // An exit is waiting on the final save; the picker is already closing.
828
+ if (!this.selection.acceptsInput) return;
829
+ if (this.ratingsHelp.isOpen) {
830
+ this.ratingsHelp.handleInput(data);
831
+ this.focused = this._focused;
832
+ return;
833
+ }
834
+ if (this.selection.download) {
835
+ // Downloading is the one modal state: the progress panel is visible, so
836
+ // ignoring everything except cancel cannot read as a dead keyboard.
837
+ // Stopping is cheap: the partial file stays in the cache, and selecting
838
+ // the model again resumes from where it left off.
839
+ if (this.keys.matches(data, "tui.select.cancel")) this.selection.cancelDownload();
840
+ return;
841
+ }
842
+
843
+ if (this.keys.matches(data, "voice.models.ratingsHelp")) {
844
+ this.ratingsHelp.open();
845
+ this.focused = this._focused;
846
+ return;
847
+ }
848
+ // Tab policy: see VOICE_KEYBINDINGS. Also keeps it out of the search.
849
+ if (this.keys.matches(data, "voice.languages.continue")) return;
850
+ if (
851
+ !this.selection.selectedDuringSession &&
852
+ this.keys.matches(data, "voice.languages.change")
853
+ ) {
854
+ this.selection.requestExit({ type: "change-languages" });
855
+ return;
856
+ }
857
+ if (this.keys.matches(data, "tui.select.up")) {
858
+ this.moveSelection(-1);
859
+ return;
860
+ }
861
+ if (this.keys.matches(data, "tui.select.down")) {
862
+ this.moveSelection(1);
863
+ return;
864
+ }
865
+ if (this.keys.matches(data, "tui.select.confirm")) {
866
+ if (this.rows[this.selectedIndex]?.type === "fold") {
867
+ this.folded = !this.folded;
868
+ this.refresh();
869
+ return;
870
+ }
871
+ const selected = this.highlightedModel();
872
+ if (!selected) return;
873
+ // Enter on a model that is not cached starts its download immediately;
874
+ // the detail pane already spells out the size, license, and source.
875
+ this.selection.select(selected);
876
+ return;
877
+ }
878
+ if (this.keys.matches(data, "tui.select.cancel")) {
879
+ if (this.search.getValue()) {
880
+ this.search.setValue("");
881
+ this.selectedIndex = 0;
882
+ this.refresh();
883
+ } else {
884
+ this.selection.requestExit(undefined);
885
+ }
886
+ return;
887
+ }
888
+
889
+ this.search.handleInput(data);
890
+ this.selectedIndex = 0;
891
+ this.refresh();
892
+ }
893
+
894
+ dispose(): void {
895
+ this.disposed = true;
896
+ this.ratingsHelp.close();
897
+ this.stopSpinner();
898
+ this.selection.dispose();
899
+ }
900
+ }
901
+
902
+ export function defaultSpokenLanguages(): string[] {
903
+ const locale = languageIdentity(Intl.DateTimeFormat().resolvedOptions().locale);
904
+ return PREFERRED_RECOMMENDATION_LANGUAGES.includes(locale) ? [locale] : ["en"];
905
+ }
906
+
907
+ export async function chooseLanguages(
908
+ ctx: ExtensionContext,
909
+ initial: readonly string[] = defaultSpokenLanguages(),
910
+ options: { cancelLabel?: string; onboardingStep?: number } = {},
911
+ ): Promise<LanguageSelection | undefined> {
912
+ return ctx.ui.custom<LanguageSelection | undefined>((tui, theme, keybindings, done) =>
913
+ new LanguagePicker(
914
+ tui,
915
+ theme,
916
+ keybindings,
917
+ initial,
918
+ options.cancelLabel ?? "close",
919
+ done,
920
+ options.onboardingStep,
921
+ ),
922
+ );
923
+ }
924
+
925
+ export async function chooseCatalogModel(
926
+ ctx: ExtensionContext,
927
+ preferredLanguages: readonly string[],
928
+ currentModelId: string | undefined,
929
+ options: {
930
+ onActivate: CatalogModelActivation;
931
+ postActivation?: CatalogModelPostActivation;
932
+ /** A model was already activated earlier in this flow. */
933
+ activatedInFlow?: boolean;
934
+ /** What Esc does once there is no search to clear. */
935
+ cancelLabel?: string;
936
+ /** Optional onboarding shell for catalog detours. */
937
+ onboardingStep?: number;
938
+ title?: string;
939
+ },
940
+ ): Promise<CatalogModelPickerResult | undefined> {
941
+ return ctx.ui.custom<CatalogModelPickerResult | undefined>(
942
+ (tui, theme, keybindings, done) =>
943
+ new CatalogModelPicker(
944
+ tui,
945
+ theme,
946
+ keybindings,
947
+ preferredLanguages,
948
+ currentModelId,
949
+ done,
950
+ options.onActivate,
951
+ {
952
+ postActivation: options.postActivation,
953
+ activatedInFlow: options.activatedInFlow,
954
+ cancelLabel: options.cancelLabel,
955
+ onboardingStep: options.onboardingStep,
956
+ title: options.title,
957
+ },
958
+ ),
959
+ );
960
+ }
961
+
962
+ export function transcriptionLanguageSummary(
963
+ language: TranscriptionLanguage,
964
+ model: CatalogModel,
965
+ ): string {
966
+ return language === "auto"
967
+ ? "Auto detect"
968
+ : transcriptionLanguageName(language, model.languages);
969
+ }
970
+
971
+ /** Single-choice picker over a model's transcription languages. */
972
+ export function createTranscriptionLanguagePicker(
973
+ tui: TUI,
974
+ theme: UiTheme,
975
+ keybindings: KeybindingsManager,
976
+ model: CatalogModel,
977
+ current: TranscriptionLanguage,
978
+ preferredLanguages: readonly string[],
979
+ done: (language: TranscriptionLanguage | undefined) => void,
980
+ ): SingleSelectPicker<TranscriptionLanguage> {
981
+ const preferred = new Set(preferredLanguages.map(languageIdentity));
982
+ const isPreferred = (value: TranscriptionLanguage): boolean =>
983
+ value !== "auto" && preferred.has(languageIdentity(value));
984
+ const languages: SingleSelectChoice<TranscriptionLanguage>[] = [
985
+ ...new Set(model.languages),
986
+ ]
987
+ .map((language) => ({
988
+ value: language,
989
+ label: transcriptionLanguageName(language, model.languages),
990
+ }))
991
+ .sort(
992
+ (left, right) =>
993
+ Number(isPreferred(right.value)) - Number(isPreferred(left.value)) ||
994
+ left.label.localeCompare(right.label) ||
995
+ left.value.localeCompare(right.value),
996
+ );
997
+ const choices: SingleSelectChoice<TranscriptionLanguage>[] = [
998
+ ...(model.capabilities.languageDetection
999
+ ? [{ value: "auto", label: "Auto detect" }]
1000
+ : []),
1001
+ ...languages,
1002
+ ];
1003
+ return new SingleSelectPicker(
1004
+ tui,
1005
+ theme,
1006
+ keybindings,
1007
+ choices,
1008
+ current,
1009
+ {
1010
+ title: "Choose transcription language",
1011
+ subtitle: model.capabilities.languageDetection
1012
+ ? "Language expected in recordings, or automatic detection."
1013
+ : "Language expected in recordings.",
1014
+ searchable: true,
1015
+ maximumVisible: MAX_VISIBLE_LANGUAGES,
1016
+ cancelLabel: "back",
1017
+ renderLabel: (choice, active) => {
1018
+ const nameText = padToWidth(choice.label, TRANSCRIPTION_LANGUAGE_NAME_WIDTH);
1019
+ const name = active ? theme.fg("accent", nameText) : nameText;
1020
+ const code = choice.value === "auto" ? "" : theme.fg("dim", choice.value);
1021
+ return `${name} ${code}`;
1022
+ },
1023
+ },
1024
+ done,
1025
+ );
1026
+ }