@tychenjiajun/pi-voice 0.0.0-stage → 0.2.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/catalog/recommendations.json +710 -0
  4. package/index.ts +1 -0
  5. package/package.json +68 -4
  6. package/src/async-limiter.ts +77 -0
  7. package/src/audio-constants.ts +1 -0
  8. package/src/audio-errors.ts +14 -0
  9. package/src/audio.ts +109 -0
  10. package/src/catalog.generated.ts +1305 -0
  11. package/src/catalog.ts +89 -0
  12. package/src/chinese.ts +52 -0
  13. package/src/deferred.ts +30 -0
  14. package/src/dictation-controller.ts +315 -0
  15. package/src/file-audio.ts +164 -0
  16. package/src/file-transcription.ts +212 -0
  17. package/src/index.ts +135 -0
  18. package/src/install-migration.ts +47 -0
  19. package/src/keybindings.ts +119 -0
  20. package/src/languages.ts +22 -0
  21. package/src/log.ts +171 -0
  22. package/src/microphone-picker.ts +95 -0
  23. package/src/model-activation.ts +74 -0
  24. package/src/model-cells.ts +218 -0
  25. package/src/model-picker.ts +1026 -0
  26. package/src/model-ratings-help.md +41 -0
  27. package/src/model-ratings-help.ts +146 -0
  28. package/src/model-selection-controller.ts +185 -0
  29. package/src/models.ts +263 -0
  30. package/src/onboarding.ts +319 -0
  31. package/src/openai-asr.ts +247 -0
  32. package/src/pcm-chunker.ts +46 -0
  33. package/src/recommendation-picker.ts +512 -0
  34. package/src/recommendations.ts +423 -0
  35. package/src/runtime.ts +524 -0
  36. package/src/settings-menu.ts +638 -0
  37. package/src/settings-path.ts +19 -0
  38. package/src/settings.ts +273 -0
  39. package/src/shortcut-core.ts +85 -0
  40. package/src/shortcuts.ts +167 -0
  41. package/src/startup-shortcut.ts +24 -0
  42. package/src/transcript-preview.ts +52 -0
  43. package/src/transcription-service.ts +570 -0
  44. package/src/transcription.ts +203 -0
  45. package/src/try-it.ts +318 -0
  46. package/src/ui-components.ts +432 -0
  47. package/src/visualizer.ts +271 -0
@@ -0,0 +1,273 @@
1
+ import { readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import {
3
+ languageIdentity,
4
+ getCatalogModel,
5
+ resolveModelLanguage,
6
+ type CatalogModel,
7
+ } from "./catalog.js";
8
+ import { DEFAULT_SHORTCUT, normalizeShortcut } from "./shortcut-core.js";
9
+ import { legacySettingsPath, settingsPath } from "./settings-path.js";
10
+
11
+ const SETTINGS_VERSION = 1;
12
+
13
+ export type MicrophoneSetting =
14
+ | { type: "system-default" }
15
+ | { type: "device"; name: string; occurrence: number };
16
+
17
+ export const DEFAULT_MICROPHONE: MicrophoneSetting = { type: "system-default" };
18
+
19
+ export type ChineseOutput = "simplified" | "traditional-taiwan" | "traditional-hong-kong";
20
+
21
+ /**
22
+ * Where recordings are transcribed. `transcribe-cpp` runs a local catalog
23
+ * model; `openai-asr` uploads to any OpenAI-compatible chat-completions ASR
24
+ * endpoint via the `input_audio` content block. The local catalog model stays
25
+ * configured in both cases and acts as the fallback when the remote call fails.
26
+ */
27
+ export type BackendSettings =
28
+ | { type: "transcribe-cpp" }
29
+ | { type: "openai-asr"; baseUrl: string; model: string; apiKeyEnv: string };
30
+
31
+ function defaultChineseOutput(): ChineseOutput {
32
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
33
+ const subtags = locale.toLowerCase().split("-");
34
+ if (subtags.includes("hk") || subtags.includes("mo")) return "traditional-hong-kong";
35
+ if (subtags.includes("tw") || subtags.includes("hant")) return "traditional-taiwan";
36
+ return "simplified";
37
+ }
38
+
39
+ /** "auto" asks a capable model to detect the language; otherwise this is a language code. */
40
+ export type TranscriptionLanguage = string;
41
+
42
+ export type TranscribeSettings = {
43
+ version: 1;
44
+ backend: BackendSettings;
45
+ shortcut: string;
46
+ preferredLanguages: string[];
47
+ transcriptionLanguage: TranscriptionLanguage;
48
+ chineseOutput: ChineseOutput;
49
+ microphone: MicrophoneSetting;
50
+ model: {
51
+ source: "catalog";
52
+ id: string;
53
+ path: string;
54
+ };
55
+ };
56
+
57
+ type SettingsReadResult = {
58
+ settings?: TranscribeSettings;
59
+ warning?: string;
60
+ };
61
+
62
+ function isObject(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function normalizeLanguages(value: unknown): string[] | undefined {
67
+ if (!Array.isArray(value) || !value.every((language) => typeof language === "string")) {
68
+ return undefined;
69
+ }
70
+ const languages = [...new Set(value.map(languageIdentity).filter(Boolean))];
71
+ return languages.length > 0 ? languages : undefined;
72
+ }
73
+
74
+ function validateChineseOutput(value: unknown): ChineseOutput {
75
+ return value === "simplified" ||
76
+ value === "traditional-taiwan" ||
77
+ value === "traditional-hong-kong"
78
+ ? value
79
+ : defaultChineseOutput();
80
+ }
81
+
82
+ function validateMicrophone(value: unknown): MicrophoneSetting | undefined {
83
+ if (!isObject(value)) return undefined;
84
+ if (value.type === "system-default") return { type: "system-default" };
85
+ if (
86
+ value.type !== "device" ||
87
+ typeof value.name !== "string" ||
88
+ !value.name.trim() ||
89
+ !Number.isInteger(value.occurrence) ||
90
+ (value.occurrence as number) < 0
91
+ ) {
92
+ return undefined;
93
+ }
94
+ return { type: "device", name: value.name, occurrence: value.occurrence as number };
95
+ }
96
+
97
+ function defaultTranscriptionLanguage(
98
+ model: CatalogModel,
99
+ preferredLanguages: readonly string[] = [],
100
+ ): TranscriptionLanguage {
101
+ if (model.capabilities.languageDetection) return "auto";
102
+ for (const language of preferredLanguages) {
103
+ const code = resolveModelLanguage(model, language);
104
+ if (code) return code;
105
+ }
106
+ return model.languages[0] ?? "en";
107
+ }
108
+
109
+ /** Keep an exact model language when possible; otherwise choose a safe default. */
110
+ export function transcriptionLanguageForModel(
111
+ value: unknown,
112
+ model: CatalogModel,
113
+ preferredLanguages: readonly string[] = [],
114
+ ): TranscriptionLanguage {
115
+ if (typeof value === "string") {
116
+ if (value === "auto" && model.capabilities.languageDetection) return value;
117
+ if (value !== "auto") {
118
+ const code = resolveModelLanguage(model, value);
119
+ if (code) return code;
120
+ }
121
+ }
122
+ return defaultTranscriptionLanguage(model, preferredLanguages);
123
+ }
124
+
125
+ function validateBackend(value: unknown): BackendSettings | undefined {
126
+ if (!isObject(value)) return undefined;
127
+ if (value.type === "transcribe-cpp") return { type: "transcribe-cpp" };
128
+ if (value.type === "openai-asr") {
129
+ // Deliberately lenient here: settings are validated on every read, and a
130
+ // slightly odd base URL should surface as a request error, not as "invalid
131
+ // settings" that silently drops the whole configuration.
132
+ if (typeof value.baseUrl !== "string" || !value.baseUrl.trim()) return undefined;
133
+ if (typeof value.model !== "string" || !value.model.trim()) return undefined;
134
+ if (
135
+ typeof value.apiKeyEnv !== "string" ||
136
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value.apiKeyEnv.trim())
137
+ ) {
138
+ return undefined;
139
+ }
140
+ return {
141
+ type: "openai-asr",
142
+ baseUrl: value.baseUrl.trim(),
143
+ model: value.model.trim(),
144
+ apiKeyEnv: value.apiKeyEnv.trim(),
145
+ };
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ function validateSettings(value: unknown): TranscribeSettings | undefined {
151
+ if (!isObject(value) || value.version !== SETTINGS_VERSION) return undefined;
152
+ const backend = validateBackend(value.backend);
153
+ if (!backend) return undefined;
154
+ const shortcut =
155
+ typeof value.shortcut === "string" ? normalizeShortcut(value.shortcut) : undefined;
156
+ if (!shortcut) return undefined;
157
+ if (!isObject(value.model) || value.model.source !== "catalog") return undefined;
158
+ if (typeof value.model.id !== "string") return undefined;
159
+ const model = getCatalogModel(value.model.id);
160
+ if (!model) return undefined;
161
+ if (typeof value.model.path !== "string" || value.model.path.length === 0) return undefined;
162
+
163
+ const preferredLanguages = normalizeLanguages(value.preferredLanguages);
164
+ const microphone = validateMicrophone(value.microphone);
165
+ if (!preferredLanguages || !microphone) return undefined;
166
+
167
+ return {
168
+ version: SETTINGS_VERSION,
169
+ backend,
170
+ shortcut,
171
+ preferredLanguages,
172
+ transcriptionLanguage: transcriptionLanguageForModel(
173
+ value.transcriptionLanguage,
174
+ model,
175
+ preferredLanguages,
176
+ ),
177
+ chineseOutput: validateChineseOutput(value.chineseOutput),
178
+ microphone,
179
+ model: {
180
+ source: "catalog",
181
+ id: value.model.id,
182
+ path: value.model.path,
183
+ },
184
+ };
185
+ }
186
+
187
+ async function readSettingsFile(path: string): Promise<SettingsReadResult> {
188
+ try {
189
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
190
+ const settings = validateSettings(parsed);
191
+ return settings
192
+ ? { settings }
193
+ : { warning: `Invalid settings in ${path}; configuration is required.` };
194
+ } catch (error) {
195
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
196
+ return {
197
+ warning: `Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`,
198
+ };
199
+ }
200
+ }
201
+
202
+ export async function readSettings(): Promise<SettingsReadResult> {
203
+ const currentPath = settingsPath();
204
+ const current = await readSettingsFile(currentPath);
205
+ if (current.settings || current.warning) return current;
206
+
207
+ const legacyPath = legacySettingsPath();
208
+ const legacy = await readSettingsFile(legacyPath);
209
+ if (!legacy.settings) return legacy;
210
+
211
+ try {
212
+ await writeSettings(legacy.settings);
213
+ await unlink(legacyPath).catch((error: NodeJS.ErrnoException) => {
214
+ if (error.code !== "ENOENT") throw error;
215
+ });
216
+ return { settings: legacy.settings };
217
+ } catch (error) {
218
+ return {
219
+ settings: legacy.settings,
220
+ warning: `Loaded legacy settings from ${legacyPath}, but could not migrate them to ${currentPath}: ${error instanceof Error ? error.message : String(error)}`,
221
+ };
222
+ }
223
+ }
224
+
225
+ export async function writeSettings(settings: TranscribeSettings): Promise<void> {
226
+ const path = settingsPath();
227
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
228
+ const content = `${JSON.stringify(settings, null, 2)}\n`;
229
+
230
+ try {
231
+ await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
232
+ await rename(temporaryPath, path);
233
+ } catch (error) {
234
+ await unlink(temporaryPath).catch(() => undefined);
235
+ throw error;
236
+ }
237
+ }
238
+
239
+ type ModelSettingsOptions = {
240
+ shortcut?: string;
241
+ preferredLanguages?: readonly string[];
242
+ transcriptionLanguage?: TranscriptionLanguage;
243
+ chineseOutput?: ChineseOutput;
244
+ microphone?: MicrophoneSetting;
245
+ /** Preserved across model re-selection; defaults to the local backend. */
246
+ backend?: BackendSettings;
247
+ };
248
+
249
+ export function settingsForModel(
250
+ modelId: string,
251
+ modelPath: string,
252
+ options: ModelSettingsOptions = {},
253
+ ): TranscribeSettings {
254
+ const model = getCatalogModel(modelId);
255
+ if (!model) throw new Error(`Unknown catalog model: ${modelId}`);
256
+ const preferredLanguages = [
257
+ ...new Set((options.preferredLanguages ?? ["en"]).map(languageIdentity)),
258
+ ];
259
+ return {
260
+ version: SETTINGS_VERSION,
261
+ backend: options.backend ?? { type: "transcribe-cpp" },
262
+ shortcut: options.shortcut ?? DEFAULT_SHORTCUT,
263
+ preferredLanguages,
264
+ transcriptionLanguage: transcriptionLanguageForModel(
265
+ options.transcriptionLanguage,
266
+ model,
267
+ preferredLanguages,
268
+ ),
269
+ chineseOutput: options.chineseOutput ?? defaultChineseOutput(),
270
+ microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
271
+ model: { source: "catalog", id: modelId, path: modelPath },
272
+ };
273
+ }
@@ -0,0 +1,85 @@
1
+ export const DEFAULT_SHORTCUT = "ctrl+alt+z";
2
+
3
+ /** Widget slot shared by the startup status and the recording meter. */
4
+ export const STATUS_WIDGET_KEY = "pi-voice-meter";
5
+
6
+ const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
7
+ const SPECIAL_KEYS = new Set([
8
+ "escape",
9
+ "enter",
10
+ "tab",
11
+ "space",
12
+ "backspace",
13
+ "delete",
14
+ "insert",
15
+ "home",
16
+ "end",
17
+ "pageup",
18
+ "pagedown",
19
+ "up",
20
+ "down",
21
+ "left",
22
+ "right",
23
+ ]);
24
+ const SYMBOL_KEYS = new Set(["`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/"]);
25
+
26
+ export function normalizeShortcut(input: string): string | undefined {
27
+ const parts = input
28
+ .trim()
29
+ .toLowerCase()
30
+ .replaceAll("option", "alt")
31
+ .replaceAll("command", "super")
32
+ .replaceAll("cmd", "super")
33
+ .split("+")
34
+ .map((part) => part.trim())
35
+ .filter(Boolean);
36
+ if (parts.length === 0) return undefined;
37
+
38
+ const key = parts.at(-1)!;
39
+ const modifiers = parts.slice(0, -1);
40
+ if (new Set(modifiers).size !== modifiers.length) return undefined;
41
+ if (
42
+ modifiers.some(
43
+ (modifier) =>
44
+ !MODIFIER_ORDER.includes(modifier as (typeof MODIFIER_ORDER)[number]),
45
+ )
46
+ ) {
47
+ return undefined;
48
+ }
49
+
50
+ const isLetter = /^[a-z]$/.test(key);
51
+ const isDigit = /^\d$/.test(key);
52
+ const isFunction = /^f(?:[1-9]|1[0-2])$/.test(key);
53
+ const isSpecial = SPECIAL_KEYS.has(key);
54
+ const isSymbol = SYMBOL_KEYS.has(key);
55
+ if (!isLetter && !isDigit && !isFunction && !isSpecial && !isSymbol) return undefined;
56
+
57
+ // Bare printable keys would make normal editor input impossible.
58
+ if (modifiers.length === 0 && !isFunction) return undefined;
59
+ // Shift-only printable keys are also normal text input.
60
+ if (
61
+ modifiers.length === 1 &&
62
+ modifiers[0] === "shift" &&
63
+ (isLetter || isDigit || isSymbol)
64
+ ) {
65
+ return undefined;
66
+ }
67
+
68
+ const ordered = MODIFIER_ORDER.filter((modifier) => modifiers.includes(modifier));
69
+ return [
70
+ ...ordered,
71
+ key === "pageup" ? "pageUp" : key === "pagedown" ? "pageDown" : key,
72
+ ].join("+");
73
+ }
74
+
75
+ export function displayShortcut(shortcut: string): string {
76
+ return shortcut
77
+ .split("+")
78
+ .map((part) => {
79
+ if (process.platform === "darwin" && part.toLowerCase() === "alt") return "Option";
80
+ return part.length === 1
81
+ ? part.toUpperCase()
82
+ : `${part[0]?.toUpperCase()}${part.slice(1)}`;
83
+ })
84
+ .join("+");
85
+ }
@@ -0,0 +1,167 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Container,
4
+ parseKey,
5
+ Spacer,
6
+ Text,
7
+ truncateToWidth,
8
+ type Component,
9
+ type KeybindingsManager,
10
+ type TUI,
11
+ } from "@earendil-works/pi-tui";
12
+ import {
13
+ DEFAULT_SHORTCUT,
14
+ displayShortcut,
15
+ normalizeShortcut,
16
+ } from "./shortcut-core.js";
17
+ import { VoiceKeys } from "./keybindings.js";
18
+ import { panelBorder } from "./ui-components.js";
19
+
20
+ type UiTheme = ExtensionContext["ui"]["theme"];
21
+ type Conflict = { description: string };
22
+ type Phase =
23
+ | { kind: "waiting" }
24
+ | { kind: "preview"; normalized: string; conflicts: Conflict[] }
25
+ | { kind: "error"; message: string };
26
+
27
+ // Pi exposes its resolved built-in bindings here, but not shortcuts owned by
28
+ // other extensions. Pi reports those collisions when it reloads.
29
+ function findConflicts(kb: KeybindingsManager, shortcut: string): Conflict[] {
30
+ const resolved = kb.getResolvedBindings();
31
+ const result: Conflict[] = [];
32
+ for (const [id, keys] of Object.entries(resolved)) {
33
+ if (keys === undefined) continue;
34
+ const keyList: string[] = Array.isArray(keys) ? keys : [keys];
35
+ if (keyList.includes(shortcut)) {
36
+ const def = kb.getDefinition(id as never);
37
+ if (def?.description) result.push({ description: def.description });
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+
43
+ export function createShortcutPicker(
44
+ tui: TUI,
45
+ theme: UiTheme,
46
+ keybindings: KeybindingsManager,
47
+ current: string,
48
+ done: (shortcut: string | undefined) => void,
49
+ ): Component {
50
+ const keys = new VoiceKeys(keybindings);
51
+ let phase: Phase = { kind: "waiting" };
52
+ const container = new Container();
53
+
54
+ function rebuild(): void {
55
+ container.clear();
56
+ container.addChild(panelBorder(theme));
57
+ container.addChild(new Spacer(1));
58
+ container.addChild(
59
+ new Text(theme.fg("accent", theme.bold("Record keyboard shortcut")), 1, 0),
60
+ );
61
+ container.addChild(
62
+ new Text(theme.fg("muted", `Current: ${displayShortcut(current)}`), 1, 0),
63
+ );
64
+ container.addChild(new Spacer(1));
65
+
66
+ if (phase.kind === "preview") {
67
+ container.addChild(
68
+ new Text(theme.fg("accent", displayShortcut(phase.normalized)), 1, 0),
69
+ );
70
+ if (phase.conflicts.length > 0) {
71
+ container.addChild(
72
+ new Text(
73
+ theme.fg(
74
+ "warning",
75
+ `Built-in conflict: ${phase.conflicts.map((conflict) => conflict.description).join(", ")}. Pi may override or reject this shortcut.`,
76
+ ),
77
+ 1,
78
+ 0,
79
+ ),
80
+ );
81
+ }
82
+ container.addChild(new Spacer(1));
83
+ container.addChild(
84
+ new Text(
85
+ `${keys.hint("tui.select.confirm", "keep")} ${keys.hint("voice.shortcut.useDefault", "default")} ${theme.fg("dim", "press another shortcut to replace")} ${keys.hint("tui.select.cancel", "back")}`,
86
+ 1,
87
+ 0,
88
+ ),
89
+ );
90
+ } else {
91
+ if (phase.kind === "error") {
92
+ container.addChild(new Text(theme.fg("error", phase.message), 1, 0));
93
+ container.addChild(new Spacer(1));
94
+ }
95
+ container.addChild(
96
+ new Text(theme.fg("muted", "Press a key combination…"), 1, 0),
97
+ );
98
+ container.addChild(new Spacer(1));
99
+ container.addChild(
100
+ new Text(
101
+ `${keys.hint("voice.shortcut.useDefault", `default (${displayShortcut(DEFAULT_SHORTCUT)})`)} ${keys.hint("tui.select.cancel", "back")}`,
102
+ 1,
103
+ 0,
104
+ ),
105
+ );
106
+ }
107
+
108
+ container.addChild(new Spacer(1));
109
+ container.addChild(panelBorder(theme));
110
+ }
111
+
112
+ rebuild();
113
+
114
+ return {
115
+ wantsKeyRelease: false,
116
+
117
+ render(width: number): string[] {
118
+ return container.render(width).map((line) => truncateToWidth(line, width, ""));
119
+ },
120
+
121
+ handleInput(data: string): void {
122
+ if (keys.matches(data, "tui.select.cancel")) {
123
+ done(undefined);
124
+ return;
125
+ }
126
+
127
+ if (keys.matches(data, "tui.select.confirm")) {
128
+ if (phase.kind === "preview") done(phase.normalized);
129
+ return;
130
+ }
131
+
132
+ if (keys.matches(data, "voice.shortcut.useDefault")) {
133
+ phase = {
134
+ kind: "preview",
135
+ normalized: DEFAULT_SHORTCUT,
136
+ conflicts: findConflicts(keybindings, DEFAULT_SHORTCUT),
137
+ };
138
+ rebuild();
139
+ tui.requestRender();
140
+ return;
141
+ }
142
+
143
+ const parsed = parseKey(data);
144
+ if (!parsed) return;
145
+
146
+ const normalized = normalizeShortcut(parsed);
147
+ if (!normalized) {
148
+ phase = {
149
+ kind: "error",
150
+ message: `${displayShortcut(parsed)} is not valid. Use a modifier such as Ctrl or Alt, or a function key.`,
151
+ };
152
+ } else {
153
+ phase = {
154
+ kind: "preview",
155
+ normalized,
156
+ conflicts: findConflicts(keybindings, normalized),
157
+ };
158
+ }
159
+ rebuild();
160
+ tui.requestRender();
161
+ },
162
+
163
+ invalidate(): void {
164
+ rebuild();
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { DEFAULT_SHORTCUT, normalizeShortcut } from "./shortcut-core.js";
3
+ import { legacySettingsPath, settingsPath } from "./settings-path.js";
4
+
5
+ function isObject(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ /** Read only the setting Pi needs while synchronously registering the extension. */
10
+ export function readShortcutForRegistration(): string {
11
+ for (const path of [settingsPath(), legacySettingsPath()]) {
12
+ try {
13
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
14
+ if (!isObject(parsed) || parsed.version !== 1 || typeof parsed.shortcut !== "string") {
15
+ return DEFAULT_SHORTCUT;
16
+ }
17
+ return normalizeShortcut(parsed.shortcut) ?? DEFAULT_SHORTCUT;
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
20
+ return DEFAULT_SHORTCUT;
21
+ }
22
+ }
23
+ return DEFAULT_SHORTCUT;
24
+ }
@@ -0,0 +1,52 @@
1
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { VoiceKeys } from "./keybindings.js";
3
+
4
+ /** A bounded read-only view. Clipping never changes the underlying transcript. */
5
+ export class TranscriptPreview {
6
+ private content = "";
7
+ private text = new Text("", 1, 0);
8
+ private offset = 0;
9
+ private lineCount = 1;
10
+ private pageSize = 1;
11
+
12
+ constructor(private readonly keys: VoiceKeys) {}
13
+
14
+ setText(content: string): void {
15
+ if (this.content === content) return;
16
+ this.content = content;
17
+ this.text.setText(content);
18
+ this.offset = 0;
19
+ }
20
+
21
+ invalidate(): void { this.text.invalidate(); }
22
+
23
+ render(width: number, height: number, muted: (text: string) => string): string[] {
24
+ const rows = Math.max(1, height);
25
+ const lines = this.content ? this.text.render(width) : [" ".repeat(width)];
26
+ this.lineCount = lines.length;
27
+ const clipped = lines.length > rows;
28
+ this.pageSize = Math.max(1, rows - (clipped && rows > 1 ? 1 : 0));
29
+ this.offset = Math.max(0, Math.min(this.offset, lines.length - this.pageSize));
30
+ const visible = lines.slice(this.offset, this.offset + this.pageSize);
31
+ if (clipped && rows > 1) {
32
+ visible.push(truncateToWidth(
33
+ muted(` ${this.offset + 1}–${this.offset + visible.length} / ${lines.length} lines · ${this.keys.navLabel()} / ${this.keys.keyText(["tui.select.pageUp", "tui.select.pageDown"])} scroll`), width,
34
+ ));
35
+ }
36
+ return visible;
37
+ }
38
+
39
+ handleInput(data: string): boolean {
40
+ if (this.lineCount <= this.pageSize) return false;
41
+ let offset = this.offset;
42
+ if (this.keys.matches(data, "tui.select.up")) offset--;
43
+ else if (this.keys.matches(data, "tui.select.down")) offset++;
44
+ else if (this.keys.matches(data, "tui.select.pageUp")) offset -= this.pageSize;
45
+ else if (this.keys.matches(data, "tui.select.pageDown")) offset += this.pageSize;
46
+ else if (this.keys.matches(data, "voice.scroll.top")) offset = 0;
47
+ else if (this.keys.matches(data, "voice.scroll.bottom")) offset = this.lineCount - this.pageSize;
48
+ else return false;
49
+ this.offset = Math.max(0, Math.min(offset, this.lineCount - this.pageSize));
50
+ return true;
51
+ }
52
+ }