@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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +69 -0
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio.ts +193 -0
- package/src/catalog.generated.ts +1305 -0
- package/src/catalog.ts +89 -0
- package/src/chinese.ts +52 -0
- package/src/deferred.ts +30 -0
- package/src/dictation-controller.ts +204 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +114 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +118 -0
- package/src/languages.ts +22 -0
- package/src/microphone-picker.ts +99 -0
- package/src/model-activation.ts +74 -0
- package/src/model-cells.ts +218 -0
- package/src/model-picker.ts +1026 -0
- package/src/model-ratings-help.md +41 -0
- package/src/model-ratings-help.ts +146 -0
- package/src/model-selection-controller.ts +185 -0
- package/src/models.ts +263 -0
- package/src/onboarding.ts +314 -0
- package/src/pcm-chunker.ts +42 -0
- package/src/pcm.ts +19 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +501 -0
- package/src/settings-menu.ts +410 -0
- package/src/settings-path.ts +13 -0
- package/src/settings.ts +235 -0
- package/src/shortcut-core.ts +85 -0
- package/src/shortcuts.ts +167 -0
- package/src/startup-shortcut.ts +24 -0
- package/src/transcript-preview.ts +52 -0
- package/src/transcription-service.ts +548 -0
- package/src/transcription.ts +186 -0
- package/src/try-it.ts +327 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +269 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
type Focusable,
|
|
5
|
+
Spacer,
|
|
6
|
+
Text,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
type KeybindingsManager,
|
|
9
|
+
type TUI,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import {
|
|
12
|
+
displayLanguage,
|
|
13
|
+
formatBinarySize,
|
|
14
|
+
} from "./catalog.js";
|
|
15
|
+
import type { CatalogModelActivation } from "./model-activation.js";
|
|
16
|
+
import { ModelSelectionController } from "./model-selection-controller.js";
|
|
17
|
+
import { VoiceKeys } from "./keybindings.js";
|
|
18
|
+
import {
|
|
19
|
+
DownloadPanel,
|
|
20
|
+
LIST_PADDING,
|
|
21
|
+
onboardingHeader,
|
|
22
|
+
PANEL_PADDING,
|
|
23
|
+
padToWidth,
|
|
24
|
+
panelBorder,
|
|
25
|
+
paneRowBudget,
|
|
26
|
+
selectedWindow,
|
|
27
|
+
} from "./ui-components.js";
|
|
28
|
+
import { EXPERIMENTAL_MAX_ERROR_PERCENT, type ModelRecommendation } from "./recommendations.js";
|
|
29
|
+
|
|
30
|
+
const NAME_WIDTH = 34;
|
|
31
|
+
|
|
32
|
+
type UiTheme = ExtensionContext["ui"]["theme"];
|
|
33
|
+
|
|
34
|
+
export type RecommendedModelResult =
|
|
35
|
+
| { type: "complete" }
|
|
36
|
+
| { type: "other-models" }
|
|
37
|
+
| { type: "change-languages" }
|
|
38
|
+
| { type: "back" };
|
|
39
|
+
|
|
40
|
+
export type RecommendedModelPickerOptions = {
|
|
41
|
+
/** Start with the alternatives unfolded. */
|
|
42
|
+
expanded?: boolean;
|
|
43
|
+
/** Defaults to a model-selection heading. */
|
|
44
|
+
title?: string;
|
|
45
|
+
/** Adds setup context and progress to the heading. */
|
|
46
|
+
onboardingStep?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Whether the recommendation pane has a distinct, supported trade-off to show. */
|
|
50
|
+
export function hasRecommendedAlternatives(
|
|
51
|
+
recommendations: readonly ModelRecommendation[],
|
|
52
|
+
): boolean {
|
|
53
|
+
const best = recommendations.find((pick) => pick.roles.includes("best")) ?? recommendations[0];
|
|
54
|
+
return recommendations.some((pick) => pick !== best && pick.status === "eligible");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A cursor stop: a model to choose, or the line that reveals the rest. */
|
|
58
|
+
type Row =
|
|
59
|
+
| { type: "model"; recommendation: ModelRecommendation }
|
|
60
|
+
| { type: "alternatives" }
|
|
61
|
+
| { type: "browse" };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Step two of onboarding. One model is recommended and Enter takes it; the
|
|
65
|
+
* faster and more accurate alternatives stay folded behind a question line
|
|
66
|
+
* until someone arrows down to it, so a recommendation never reads as a
|
|
67
|
+
* comparison. Everywhere that fold is spent — unfolded, or absent because one
|
|
68
|
+
* model won every role — a row onto the full catalog replaces it.
|
|
69
|
+
*/
|
|
70
|
+
export class RecommendedModelPicker extends Container implements Focusable {
|
|
71
|
+
private readonly body = new Container();
|
|
72
|
+
private readonly best: ModelRecommendation;
|
|
73
|
+
private readonly alternatives: readonly ModelRecommendation[];
|
|
74
|
+
private expanded = false;
|
|
75
|
+
private selectedIndex = 0;
|
|
76
|
+
private readonly selection: ModelSelectionController<RecommendedModelResult | undefined>;
|
|
77
|
+
private readonly title: string;
|
|
78
|
+
private readonly onboardingStep: number | undefined;
|
|
79
|
+
private downloadPanel: DownloadPanel | undefined;
|
|
80
|
+
private disposed = false;
|
|
81
|
+
private _focused = false;
|
|
82
|
+
|
|
83
|
+
get focused(): boolean {
|
|
84
|
+
return this._focused;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
set focused(value: boolean) {
|
|
88
|
+
this._focused = value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private readonly keys: VoiceKeys;
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private readonly tui: TUI,
|
|
95
|
+
private readonly theme: UiTheme,
|
|
96
|
+
keybindings: KeybindingsManager,
|
|
97
|
+
private readonly languages: readonly string[],
|
|
98
|
+
recommendations: readonly ModelRecommendation[],
|
|
99
|
+
private readonly activate: CatalogModelActivation,
|
|
100
|
+
private readonly done: (result: RecommendedModelResult | undefined) => void,
|
|
101
|
+
options: RecommendedModelPickerOptions = {},
|
|
102
|
+
) {
|
|
103
|
+
super();
|
|
104
|
+
this.keys = new VoiceKeys(keybindings);
|
|
105
|
+
this.title = options.title ?? "Choose a model";
|
|
106
|
+
this.onboardingStep = options.onboardingStep;
|
|
107
|
+
this.selection = new ModelSelectionController<RecommendedModelResult | undefined>(
|
|
108
|
+
(...args) => this.activate(...args),
|
|
109
|
+
{
|
|
110
|
+
models: recommendations.map((pick) => pick.model),
|
|
111
|
+
advance: true,
|
|
112
|
+
completion: { type: "complete" },
|
|
113
|
+
onChange: () => this.refresh(),
|
|
114
|
+
onExit: (result) => {
|
|
115
|
+
this.downloadPanel?.dispose();
|
|
116
|
+
this.done(result);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
this.best = recommendations.find((pick) => pick.roles.includes("best")) ?? recommendations[0]!;
|
|
121
|
+
// Only benchmark-eligible alternatives are recommendations. Experimental
|
|
122
|
+
// results contain one explicit fallback; unsupported and unbenchmarked
|
|
123
|
+
// results lead to the full model browser instead.
|
|
124
|
+
this.alternatives = recommendations.filter(
|
|
125
|
+
(pick) => pick !== this.best && pick.status === "eligible",
|
|
126
|
+
);
|
|
127
|
+
// Opened from the Try it step the alternatives are the point, so start
|
|
128
|
+
// unfolded with the cursor on the first of them.
|
|
129
|
+
if (options.expanded && this.alternatives.length > 0) {
|
|
130
|
+
this.expanded = true;
|
|
131
|
+
this.selectedIndex = 1;
|
|
132
|
+
}
|
|
133
|
+
this.addChild(panelBorder(theme));
|
|
134
|
+
this.addChild(new Spacer(1));
|
|
135
|
+
this.addChild(
|
|
136
|
+
options.onboardingStep
|
|
137
|
+
? onboardingHeader(theme, this.title, options.onboardingStep)
|
|
138
|
+
: new Text(theme.fg("accent", theme.bold(this.title)), PANEL_PADDING, 0),
|
|
139
|
+
);
|
|
140
|
+
this.addChild(
|
|
141
|
+
new Text(
|
|
142
|
+
`${theme.fg("muted", `Your languages: ${languages.map(displayLanguage).join(", ")}`)} · ${this.keys.hint("voice.languages.change", "change")}`,
|
|
143
|
+
PANEL_PADDING,
|
|
144
|
+
0,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
this.addChild(this.body);
|
|
148
|
+
this.addChild(new Spacer(1));
|
|
149
|
+
this.addChild(panelBorder(theme));
|
|
150
|
+
this.refresh();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private rows(): Row[] {
|
|
154
|
+
if (this.best.status === "unsupported" || this.best.status === "unbenchmarked") {
|
|
155
|
+
return [{ type: "browse" }];
|
|
156
|
+
}
|
|
157
|
+
const rows: Row[] = [{ type: "model", recommendation: this.best }];
|
|
158
|
+
// While the trade-offs stay folded that question is the only invitation to
|
|
159
|
+
// look further; a second "more models" row beside it would turn the
|
|
160
|
+
// recommendation into a comparison. Once it unfolds — or when one model
|
|
161
|
+
// carries every role and there is nothing to unfold — the full catalog
|
|
162
|
+
// takes its place, so the pane is never a dead end.
|
|
163
|
+
if (this.expanded) {
|
|
164
|
+
for (const recommendation of this.alternatives) rows.push({ type: "model", recommendation });
|
|
165
|
+
rows.push({ type: "browse" });
|
|
166
|
+
} else if (this.alternatives.length > 0) {
|
|
167
|
+
rows.push({ type: "alternatives" });
|
|
168
|
+
} else {
|
|
169
|
+
rows.push({ type: "browse" });
|
|
170
|
+
}
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private offers(role: "fast" | "accurate"): boolean {
|
|
175
|
+
return this.alternatives.some((pick) => pick.roles.includes(role));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The folded line names only the trade-offs that actually exist. */
|
|
179
|
+
private question(): string {
|
|
180
|
+
const faster = this.offers("fast");
|
|
181
|
+
const accurate = this.offers("accurate");
|
|
182
|
+
if (faster && accurate) return "For faster or more accurate transcriptions";
|
|
183
|
+
return faster ? "For faster transcriptions" : "For more accurate transcriptions";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Benchmark timings are deliberately not shown: they come from a reference
|
|
187
|
+
// laptop and read as promises about this machine. The Try it step measures
|
|
188
|
+
// the real wait.
|
|
189
|
+
private detail(recommendation: ModelRecommendation): string {
|
|
190
|
+
const faster = recommendation.roles.includes("fast");
|
|
191
|
+
const accurate = recommendation.roles.includes("accurate");
|
|
192
|
+
if (recommendation === this.best) {
|
|
193
|
+
let text: string;
|
|
194
|
+
if (faster && accurate) {
|
|
195
|
+
text = recommendation.withinFastWaitTarget
|
|
196
|
+
? "Fast, accurate, and a good all-around choice."
|
|
197
|
+
: "The best balance of speed and accuracy available.";
|
|
198
|
+
} else if (faster && recommendation.withinFastWaitTarget) {
|
|
199
|
+
text = "A well-balanced model that also transcribes quickly.";
|
|
200
|
+
} else if (accurate) {
|
|
201
|
+
text = "A well-balanced model with especially accurate transcriptions.";
|
|
202
|
+
} else {
|
|
203
|
+
text = "A good balance of speed and accuracy.";
|
|
204
|
+
}
|
|
205
|
+
if (this.languages.length > 1 && recommendation.model.capabilities.languageDetection) {
|
|
206
|
+
const scope = this.languages.length === 2 ? "both languages" : "all your languages";
|
|
207
|
+
text += ` It switches between ${scope} automatically.`;
|
|
208
|
+
}
|
|
209
|
+
return text;
|
|
210
|
+
}
|
|
211
|
+
if (faster && accurate) return "Faster and more accurate.";
|
|
212
|
+
if (faster) return "Faster, but may make more mistakes.";
|
|
213
|
+
return "More accurate, but may take longer.";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Title, description, and confirm verb for the rows that are not models.
|
|
218
|
+
* Browsing means something different either side of a usable
|
|
219
|
+
* recommendation, so its copy follows the pick's status rather than the
|
|
220
|
+
* row alone.
|
|
221
|
+
*/
|
|
222
|
+
private rowLabel(row: Exclude<Row, { type: "model" }>): {
|
|
223
|
+
title: string;
|
|
224
|
+
description: string;
|
|
225
|
+
action: string;
|
|
226
|
+
} {
|
|
227
|
+
if (row.type === "alternatives") {
|
|
228
|
+
return { title: "Other options", description: this.question(), action: "show alternatives" };
|
|
229
|
+
}
|
|
230
|
+
if (this.best.status === "unsupported" || this.best.status === "unbenchmarked") {
|
|
231
|
+
return {
|
|
232
|
+
title: "Browse models anyway",
|
|
233
|
+
description: this.best.status === "unsupported"
|
|
234
|
+
? "Available models are unlikely to produce a usable transcript"
|
|
235
|
+
: "Inspect models whose language support has not been verified",
|
|
236
|
+
action: "browse models",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
title: "Show all models",
|
|
241
|
+
description: "Search the whole catalog and pick a model yourself",
|
|
242
|
+
action: "show all models",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private addModelRow(recommendation: ModelRecommendation, active: boolean): void {
|
|
247
|
+
const prefix = active ? this.theme.fg("accent", "→ ") : " ";
|
|
248
|
+
const nameText = padToWidth(recommendation.model.name, NAME_WIDTH);
|
|
249
|
+
const name = active ? this.theme.fg("accent", nameText) : nameText;
|
|
250
|
+
const size = this.theme.fg("dim", formatBinarySize(recommendation.model.size));
|
|
251
|
+
this.body.addChild(new Text(`${prefix}${name} ${size}`, LIST_PADDING, 0));
|
|
252
|
+
this.body.addChild(this.modelDetails(recommendation));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private modelDetails(recommendation: ModelRecommendation): Container {
|
|
256
|
+
const details = new Container();
|
|
257
|
+
const description = recommendation.status === "experimental" && recommendation.worstLanguage
|
|
258
|
+
? this.theme.fg("warning", `Experimental: this is the best option we found, but it may make frequent mistakes in ${displayLanguage(recommendation.worstLanguage)}.`)
|
|
259
|
+
: this.theme.fg("muted", this.detail(recommendation));
|
|
260
|
+
details.addChild(new Text(description, LIST_PADDING + 2, 0));
|
|
261
|
+
// A usable pick still spans most of an order of magnitude of error, so a
|
|
262
|
+
// model near the floor should not read exactly like one many times more
|
|
263
|
+
// accurate. Where the shortfall separates the picks it is a short tag on
|
|
264
|
+
// the rows that have it; where it covers all of them the heading carries
|
|
265
|
+
// it instead, so the same sentence never repeats down the pane.
|
|
266
|
+
if (recommendation.nearFloor && recommendation.worstLanguage && !this.sharedNearFloorLanguage()) {
|
|
267
|
+
details.addChild(
|
|
268
|
+
new Text(
|
|
269
|
+
this.theme.fg("warning", `Lower accuracy in ${displayLanguage(recommendation.worstLanguage)}.`),
|
|
270
|
+
LIST_PADDING + 2,
|
|
271
|
+
0,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (this.languages.length > 1 && !recommendation.model.capabilities.languageDetection) {
|
|
276
|
+
details.addChild(new Text(this.theme.fg("warning", "You will need to change the transcription language manually."), LIST_PADDING + 2, 0));
|
|
277
|
+
}
|
|
278
|
+
return details;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private refresh(): void {
|
|
282
|
+
if (this.disposed) return;
|
|
283
|
+
this.body.clear();
|
|
284
|
+
if (!this.selection.download) {
|
|
285
|
+
this.downloadPanel?.dispose();
|
|
286
|
+
this.downloadPanel = undefined;
|
|
287
|
+
}
|
|
288
|
+
if (this.selection.download) {
|
|
289
|
+
this.downloadPanel ??= new DownloadPanel(this.tui, this.theme, this.keys, this.selection.download);
|
|
290
|
+
this.downloadPanel.update(this.selection.download);
|
|
291
|
+
this.body.addChild(this.downloadPanel);
|
|
292
|
+
this.tui.requestRender();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.body.addChild(new Spacer(1));
|
|
297
|
+
this.body.addChild(new Text(this.heading(), PANEL_PADDING, 0));
|
|
298
|
+
const notice = this.notice();
|
|
299
|
+
if (notice) {
|
|
300
|
+
this.body.addChild(new Text(this.theme.fg("warning", notice), PANEL_PADDING, 0));
|
|
301
|
+
}
|
|
302
|
+
this.body.addChild(new Spacer(1));
|
|
303
|
+
|
|
304
|
+
// The pick stands alone; the alternatives, folded or not, are styled
|
|
305
|
+
// like the model rows so they read as choices, one blank line apart.
|
|
306
|
+
const rows = this.rows();
|
|
307
|
+
for (const [index, row] of rows.entries()) {
|
|
308
|
+
const active = index === this.selectedIndex;
|
|
309
|
+
const prefix = active ? this.theme.fg("accent", "→ ") : " ";
|
|
310
|
+
if (index > 0) {
|
|
311
|
+
this.body.addChild(new Spacer(1));
|
|
312
|
+
}
|
|
313
|
+
if (row.type !== "model") {
|
|
314
|
+
// Shaped like a model row, title then description, so it reads as
|
|
315
|
+
// a choice rather than a footnote.
|
|
316
|
+
const { title, description } = this.rowLabel(row);
|
|
317
|
+
const padded = padToWidth(title, NAME_WIDTH);
|
|
318
|
+
this.body.addChild(
|
|
319
|
+
new Text(`${prefix}${active ? this.theme.fg("accent", padded) : padded}`, LIST_PADDING, 0),
|
|
320
|
+
);
|
|
321
|
+
this.body.addChild(
|
|
322
|
+
new Text(this.theme.fg("muted", description), LIST_PADDING + 2, 0),
|
|
323
|
+
);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
this.addModelRow(row.recommendation, active);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (this.selection.feedback) {
|
|
330
|
+
const { type, text } = this.selection.feedback;
|
|
331
|
+
this.body.addChild(new Spacer(1));
|
|
332
|
+
this.body.addChild(new Text(this.theme.fg(type, text), PANEL_PADDING, 0));
|
|
333
|
+
}
|
|
334
|
+
this.body.addChild(new Spacer(1));
|
|
335
|
+
this.body.addChild(
|
|
336
|
+
new Text(
|
|
337
|
+
`${this.keys.hint("tui.select.confirm", this.confirmLabel())} ${this.keys.hint("voice.recommendations.browseAll", "all models")} ${this.keys.hint("tui.select.cancel", "back")}`,
|
|
338
|
+
PANEL_PADDING,
|
|
339
|
+
0,
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
this.tui.requestRender();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private heading(): string {
|
|
346
|
+
const languageText = this.languages.map(displayLanguage).join(" + ");
|
|
347
|
+
const title = this.best.status === "experimental" ? "Experimental option"
|
|
348
|
+
: this.best.status === "unsupported" ? "No supported model"
|
|
349
|
+
: this.best.status === "unbenchmarked" ? "No benchmark-backed recommendation" : "Recommended";
|
|
350
|
+
return `${title} for ${languageText}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The weak language when no pick escapes the note band, which makes the
|
|
355
|
+
* shortfall a property of the language rather than of any one model. Judged
|
|
356
|
+
* over every pick rather than the visible rows, so unfolding the
|
|
357
|
+
* alternatives never changes the story the pane tells.
|
|
358
|
+
*/
|
|
359
|
+
private sharedNearFloorLanguage(): string | undefined {
|
|
360
|
+
const picks = [this.best, ...this.alternatives];
|
|
361
|
+
const language = this.best.worstLanguage;
|
|
362
|
+
// Different models can be weakest in different languages. No catalog pick
|
|
363
|
+
// does today, but hoisting one model's weak language over a row it does
|
|
364
|
+
// not describe would state something false, so disagreement falls back to
|
|
365
|
+
// the per-row tags.
|
|
366
|
+
return language !== undefined &&
|
|
367
|
+
picks.every((pick) => pick.nearFloor && pick.worstLanguage === language)
|
|
368
|
+
? language
|
|
369
|
+
: undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private notice(): string {
|
|
373
|
+
if (this.best.status === "unsupported") {
|
|
374
|
+
return `Every measured model is at or above ${EXPERIMENTAL_MAX_ERROR_PERCENT}% benchmark error.`;
|
|
375
|
+
}
|
|
376
|
+
if (this.best.status === "unbenchmarked") {
|
|
377
|
+
return "Model cards claim support, but measured accuracy is unavailable.";
|
|
378
|
+
}
|
|
379
|
+
const language = this.sharedNearFloorLanguage();
|
|
380
|
+
return language
|
|
381
|
+
? `Every model here is less accurate in ${displayLanguage(language)}. Expect to correct transcripts more often.`
|
|
382
|
+
: "";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private confirmLabel(): string {
|
|
386
|
+
const row = this.rows()[this.selectedIndex];
|
|
387
|
+
if (!row) return "choose";
|
|
388
|
+
if (row.type !== "model") return this.rowLabel(row).action;
|
|
389
|
+
const model = row.recommendation.model;
|
|
390
|
+
return this.selection.cachedById.has(model.id) ? "choose" : `download ${formatBinarySize(model.size)}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
override render(width: number): string[] {
|
|
394
|
+
const lines = super.render(width);
|
|
395
|
+
const budget = Math.max(1, paneRowBudget(this.tui) ?? Infinity);
|
|
396
|
+
if (lines.length <= budget) return lines;
|
|
397
|
+
const line = (value: string) => truncateToWidth(` ${value}`, width);
|
|
398
|
+
const text = (value: string) => new Text(value, PANEL_PADDING, 0).render(width);
|
|
399
|
+
const title = this.onboardingStep
|
|
400
|
+
? onboardingHeader(this.theme, this.title, this.onboardingStep).render(width)[0]!
|
|
401
|
+
: line(this.theme.fg("accent", this.title));
|
|
402
|
+
if (this.downloadPanel) {
|
|
403
|
+
return budget === 1 ? this.downloadPanel.render(width, 1)
|
|
404
|
+
: [title, ...this.downloadPanel.render(width, budget - 1)];
|
|
405
|
+
}
|
|
406
|
+
// Collapse whitespace and descriptions before hiding any choices. On tiny
|
|
407
|
+
// terminals window the choices around the cursor, keeping the actions visible.
|
|
408
|
+
const footer = text(`${this.keys.hint("tui.select.confirm", this.confirmLabel())} ${this.keys.hint("tui.select.cancel", "back")}\n${this.keys.hint("voice.languages.change", "languages")} ${this.keys.hint("voice.recommendations.browseAll", "all models")}`)
|
|
409
|
+
.slice(0, Math.max(0, budget - 1));
|
|
410
|
+
const header = [title, line(this.heading())].slice(0, Math.max(0, budget - footer.length - 1));
|
|
411
|
+
const rows = this.rows();
|
|
412
|
+
const room = budget - header.length - footer.length;
|
|
413
|
+
const [start, end] = selectedWindow(rows, this.selectedIndex, room);
|
|
414
|
+
const choices = rows.slice(start, end).map((row, index) => {
|
|
415
|
+
const label = row.type === "model"
|
|
416
|
+
? `${row.recommendation.model.name} · ${formatBinarySize(row.recommendation.model.size)}`
|
|
417
|
+
: this.rowLabel(row).title;
|
|
418
|
+
return line(index + start === this.selectedIndex ? this.theme.fg("accent", `→ ${label}`) : ` ${label}`);
|
|
419
|
+
});
|
|
420
|
+
const row = rows[this.selectedIndex];
|
|
421
|
+
const detail = new Container();
|
|
422
|
+
const notice = this.notice();
|
|
423
|
+
if (notice) {
|
|
424
|
+
detail.addChild(new Text(this.theme.fg("warning", notice), PANEL_PADDING, 0));
|
|
425
|
+
}
|
|
426
|
+
if (row?.type === "model") {
|
|
427
|
+
detail.addChild(this.modelDetails(row.recommendation));
|
|
428
|
+
} else if (row) detail.addChild(new Text(this.rowLabel(row).description, PANEL_PADDING, 0));
|
|
429
|
+
const feedback = this.selection.feedback;
|
|
430
|
+
const details = [...(feedback ? text(this.theme.fg(feedback.type, feedback.text)) : []), ...detail.render(width)];
|
|
431
|
+
return [...header, ...choices, ...details.slice(0, room - choices.length), ...footer];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
handleInput(data: string): void {
|
|
435
|
+
if (!this.selection.acceptsInput) return;
|
|
436
|
+
if (this.selection.download) {
|
|
437
|
+
if (this.keys.matches(data, "tui.select.cancel")) {
|
|
438
|
+
this.selection.cancelDownload();
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// Tab policy: see VOICE_KEYBINDINGS. Activation stays an explicit Enter.
|
|
443
|
+
if (this.keys.matches(data, "voice.languages.continue")) return;
|
|
444
|
+
if (this.keys.matches(data, "voice.languages.change")) {
|
|
445
|
+
this.selection.requestExit({ type: "change-languages" });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (this.keys.matches(data, "tui.select.cancel")) {
|
|
449
|
+
this.selection.requestExit({ type: "back" });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const count = this.rows().length;
|
|
453
|
+
if (this.keys.matches(data, "tui.select.up") && count > 1) {
|
|
454
|
+
this.selectedIndex = (this.selectedIndex - 1 + count) % count;
|
|
455
|
+
this.refresh();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (this.keys.matches(data, "tui.select.down") && count > 1) {
|
|
459
|
+
this.selectedIndex = (this.selectedIndex + 1) % count;
|
|
460
|
+
this.refresh();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (this.keys.matches(data, "voice.recommendations.browseAll")) {
|
|
464
|
+
this.selection.requestExit({ type: "other-models" });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (this.keys.matches(data, "tui.select.confirm")) {
|
|
468
|
+
const row = this.rows()[this.selectedIndex];
|
|
469
|
+
if (!row) return;
|
|
470
|
+
if (row.type === "browse") {
|
|
471
|
+
this.selection.requestExit({ type: "other-models" });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (row.type === "alternatives") {
|
|
475
|
+
// Unfold in place and land on the first alternative, since that is
|
|
476
|
+
// what the person asked to see.
|
|
477
|
+
this.expanded = true;
|
|
478
|
+
this.selectedIndex = 1;
|
|
479
|
+
this.refresh();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
this.selection.select(row.recommendation.model);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
dispose(): void {
|
|
487
|
+
this.disposed = true;
|
|
488
|
+
this.selection.dispose();
|
|
489
|
+
this.downloadPanel?.dispose();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function chooseRecommendedModel(
|
|
494
|
+
ctx: ExtensionContext,
|
|
495
|
+
languages: readonly string[],
|
|
496
|
+
recommendations: readonly ModelRecommendation[],
|
|
497
|
+
activate: CatalogModelActivation,
|
|
498
|
+
options: RecommendedModelPickerOptions = {},
|
|
499
|
+
): Promise<RecommendedModelResult | undefined> {
|
|
500
|
+
return ctx.ui.custom<RecommendedModelResult | undefined>((tui, theme, keybindings, done) =>
|
|
501
|
+
new RecommendedModelPicker(
|
|
502
|
+
tui,
|
|
503
|
+
theme,
|
|
504
|
+
keybindings,
|
|
505
|
+
languages,
|
|
506
|
+
recommendations,
|
|
507
|
+
activate,
|
|
508
|
+
done,
|
|
509
|
+
options,
|
|
510
|
+
),
|
|
511
|
+
);
|
|
512
|
+
}
|