@maheidem/pi-audio-transcribe 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.
- package/LICENSE +32 -0
- package/README.md +132 -0
- package/config.ts +174 -0
- package/index.ts +322 -0
- package/lib/json-store.ts +92 -0
- package/omlx.ts +295 -0
- package/package.json +69 -0
- package/paths.ts +128 -0
- package/pipeline.ts +252 -0
- package/settings.ts +130 -0
- package/sidecar.ts +123 -0
- package/ui/audio-panel.ts +104 -0
- package/ui/settings-panel.ts +393 -0
- package/validate.ts +388 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/** pi-extension-builder SettingsPanel v0.1.0 — canonical source and vendored primitive. */
|
|
2
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Input,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
type Component,
|
|
9
|
+
type Focusable,
|
|
10
|
+
type KeyId,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
|
|
13
|
+
export type PanelRowKind = "toggle" | "input" | "cycle" | "action" | "info";
|
|
14
|
+
export type PanelValueStyle = "accent" | "success" | "warning" | "error" | "muted" | "text";
|
|
15
|
+
|
|
16
|
+
export interface PanelRow {
|
|
17
|
+
key: string;
|
|
18
|
+
label: string;
|
|
19
|
+
value: string;
|
|
20
|
+
kind: PanelRowKind;
|
|
21
|
+
rawValue?: string;
|
|
22
|
+
choices?: string[];
|
|
23
|
+
inputHint?: string;
|
|
24
|
+
valueStyle?: PanelValueStyle;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PanelSection {
|
|
29
|
+
title: string;
|
|
30
|
+
rows: PanelRow[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PanelShortcut {
|
|
34
|
+
key: KeyId;
|
|
35
|
+
label: string;
|
|
36
|
+
action: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PanelSnapshot {
|
|
40
|
+
title: string;
|
|
41
|
+
summaryLines?: string[];
|
|
42
|
+
sections: PanelSection[];
|
|
43
|
+
detailLines?: string[];
|
|
44
|
+
idleMessage?: string;
|
|
45
|
+
shortcuts?: PanelShortcut[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type PanelActionResult =
|
|
49
|
+
| { kind: "updated"; message?: string }
|
|
50
|
+
| { kind: "close"; action: string }
|
|
51
|
+
| { kind: "error"; message: string }
|
|
52
|
+
| { kind: "none" };
|
|
53
|
+
|
|
54
|
+
export interface PanelResult {
|
|
55
|
+
action?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SettingsPanelHost {
|
|
59
|
+
theme: Theme;
|
|
60
|
+
keybindings: KeybindingsManager;
|
|
61
|
+
initialKey?: string;
|
|
62
|
+
snapshot(): PanelSnapshot;
|
|
63
|
+
/** Apply a canonical setting value. Return an error string or null. */
|
|
64
|
+
apply(key: string, rawValue: string): string | null;
|
|
65
|
+
activate(key: string): PanelActionResult | void;
|
|
66
|
+
requestRender(): void;
|
|
67
|
+
done(result: PanelResult): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type FlashKind = "error" | "success" | "info";
|
|
71
|
+
interface Flash {
|
|
72
|
+
kind: FlashKind;
|
|
73
|
+
text: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reusable, synchronous control panel.
|
|
78
|
+
*
|
|
79
|
+
* Business logic stays in the host. The panel owns only navigation, editing,
|
|
80
|
+
* rendering, and transient feedback. Async or destructive actions should
|
|
81
|
+
* close with an action result and be handled by the command adapter.
|
|
82
|
+
*/
|
|
83
|
+
export class SettingsPanel implements Component, Focusable {
|
|
84
|
+
private readonly host: SettingsPanelHost;
|
|
85
|
+
private snapshotValue: PanelSnapshot;
|
|
86
|
+
private cursor = 0;
|
|
87
|
+
private editingKey: string | null = null;
|
|
88
|
+
private input: Input | null = null;
|
|
89
|
+
private flash: Flash | null = null;
|
|
90
|
+
private _focused = false;
|
|
91
|
+
|
|
92
|
+
constructor(host: SettingsPanelHost) {
|
|
93
|
+
this.host = host;
|
|
94
|
+
this.snapshotValue = host.snapshot();
|
|
95
|
+
if (host.initialKey) this.focusRow(host.initialKey);
|
|
96
|
+
this.clampCursor();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get focused(): boolean {
|
|
100
|
+
return this._focused;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
set focused(value: boolean) {
|
|
104
|
+
this._focused = value;
|
|
105
|
+
if (this.input) this.input.focused = value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
invalidate(): void {
|
|
109
|
+
this.input?.invalidate();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
handleInput(data: string): void {
|
|
113
|
+
if (this.input) {
|
|
114
|
+
this.input.handleInput(data);
|
|
115
|
+
this.host.requestRender();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const kb = this.host.keybindings;
|
|
120
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "q")) {
|
|
121
|
+
this.host.done({});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "k")) {
|
|
126
|
+
this.move(-1);
|
|
127
|
+
} else if (kb.matches(data, "tui.select.down") || matchesKey(data, "j")) {
|
|
128
|
+
this.move(1);
|
|
129
|
+
} else if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "space")) {
|
|
130
|
+
this.activateCurrent();
|
|
131
|
+
} else {
|
|
132
|
+
const shortcut = this.snapshotValue.shortcuts?.find((candidate) => matchesKey(data, candidate.key));
|
|
133
|
+
if (!shortcut) return;
|
|
134
|
+
this.runAction(shortcut.action, shortcut.label);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.host.requestRender();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
render(width: number): string[] {
|
|
141
|
+
const t = this.host.theme;
|
|
142
|
+
const lines: string[] = [this.topBorder(width, this.snapshotValue.title)];
|
|
143
|
+
|
|
144
|
+
for (const line of this.snapshotValue.summaryLines ?? []) {
|
|
145
|
+
lines.push(this.boxLine(t.fg("muted", ` ${line}`), width));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const selectedKey = this.selectableRows()[this.cursor]?.key;
|
|
149
|
+
for (const section of this.snapshotValue.sections) {
|
|
150
|
+
lines.push(this.boxLine(t.fg("accent", t.bold(` ${section.title}`)), width));
|
|
151
|
+
for (const row of section.rows) {
|
|
152
|
+
lines.push(this.renderRow(row, row.key === selectedKey, width));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const line of this.snapshotValue.detailLines ?? []) {
|
|
157
|
+
lines.push(this.boxLine(t.fg("dim", ` ${line}`), width));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
lines.push(this.boxLine(this.renderMessageLine(), width));
|
|
161
|
+
const shortcutLine = this.renderShortcutLine();
|
|
162
|
+
if (shortcutLine) lines.push(this.boxLine(t.fg("dim", ` ${shortcutLine}`), width));
|
|
163
|
+
lines.push(this.boxLine(t.fg("dim", ` ${this.renderNavigationLine()}`), width));
|
|
164
|
+
lines.push(this.bottomBorder(width));
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private allRows(): PanelRow[] {
|
|
169
|
+
return this.snapshotValue.sections.flatMap((section) => section.rows);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private selectableRows(): PanelRow[] {
|
|
173
|
+
return this.allRows().filter((row) => row.kind !== "info" && !row.disabled);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private focusRow(key: string): void {
|
|
177
|
+
const index = this.selectableRows().findIndex((row) => row.key === key);
|
|
178
|
+
if (index >= 0) this.cursor = index;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private clampCursor(): void {
|
|
182
|
+
const rows = this.selectableRows();
|
|
183
|
+
this.cursor = Math.max(0, Math.min(this.cursor, Math.max(0, rows.length - 1)));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Refresh derived rows while preserving the current selection when possible. */
|
|
187
|
+
refresh(preferredKey?: string): void {
|
|
188
|
+
const currentKey = preferredKey ?? this.selectableRows()[this.cursor]?.key;
|
|
189
|
+
this.snapshotValue = this.host.snapshot();
|
|
190
|
+
if (currentKey) this.focusRow(currentKey);
|
|
191
|
+
this.clampCursor();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private move(delta: number): void {
|
|
195
|
+
const rows = this.selectableRows();
|
|
196
|
+
if (!rows.length) return;
|
|
197
|
+
this.cursor = (this.cursor + delta + rows.length) % rows.length;
|
|
198
|
+
this.flash = null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private activateCurrent(): void {
|
|
202
|
+
const row = this.selectableRows()[this.cursor];
|
|
203
|
+
if (!row) return;
|
|
204
|
+
|
|
205
|
+
if (row.kind === "input") {
|
|
206
|
+
this.startEdit(row.key, row.rawValue ?? "");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (row.kind === "toggle") {
|
|
211
|
+
this.applyValue(row, row.rawValue === "true" ? "false" : "true");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (row.kind === "cycle") {
|
|
216
|
+
const choices = row.choices ?? [];
|
|
217
|
+
if (!choices.length) return;
|
|
218
|
+
const current = Math.max(0, choices.indexOf(row.rawValue ?? ""));
|
|
219
|
+
this.applyValue(row, choices[(current + 1) % choices.length] ?? choices[0] ?? "");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (row.kind === "action") this.runAction(row.key, row.label);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private applyValue(row: PanelRow, value: string): void {
|
|
227
|
+
const error = this.host.apply(row.key, value);
|
|
228
|
+
if (error) {
|
|
229
|
+
this.flash = { kind: "error", text: error };
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.refresh(row.key);
|
|
234
|
+
const fresh = this.allRows().find((candidate) => candidate.key === row.key);
|
|
235
|
+
this.flash = { kind: "success", text: `${row.label}: ${fresh?.value ?? value}` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private runAction(key: string, label: string): void {
|
|
239
|
+
const result = this.host.activate(key) ?? { kind: "none" as const };
|
|
240
|
+
if (result.kind === "close") {
|
|
241
|
+
this.host.done({ action: result.action });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (result.kind === "error") {
|
|
245
|
+
this.flash = { kind: "error", text: result.message };
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (result.kind === "updated") {
|
|
249
|
+
this.refresh(key);
|
|
250
|
+
this.flash = { kind: "success", text: result.message ?? `${label} updated` };
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.flash = { kind: "info", text: label };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private startEdit(key: string, initialValue: string): void {
|
|
257
|
+
this.editingKey = key;
|
|
258
|
+
this.input = new Input();
|
|
259
|
+
this.input.focused = this._focused;
|
|
260
|
+
this.input.setValue(initialValue);
|
|
261
|
+
// A fresh Input keeps its cursor at 0 after setValue(); move to End so a
|
|
262
|
+
// prefilled setting edits naturally.
|
|
263
|
+
this.input.handleInput("\x1b[F");
|
|
264
|
+
this.flash = null;
|
|
265
|
+
|
|
266
|
+
this.input.onSubmit = (value) => {
|
|
267
|
+
const row = this.allRows().find((candidate) => candidate.key === key);
|
|
268
|
+
if (!row) {
|
|
269
|
+
this.cancelEdit();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const canonical = value.trim();
|
|
273
|
+
const error = this.host.apply(key, canonical);
|
|
274
|
+
if (error) {
|
|
275
|
+
this.flash = { kind: "error", text: error };
|
|
276
|
+
this.host.requestRender();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.input = null;
|
|
280
|
+
this.editingKey = null;
|
|
281
|
+
this.refresh(key);
|
|
282
|
+
const fresh = this.allRows().find((candidate) => candidate.key === key);
|
|
283
|
+
this.flash = { kind: "success", text: `${row.label}: ${fresh?.value ?? canonical}` };
|
|
284
|
+
this.host.requestRender();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
this.input.onEscape = () => {
|
|
288
|
+
this.cancelEdit();
|
|
289
|
+
this.host.requestRender();
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private cancelEdit(): void {
|
|
294
|
+
this.input = null;
|
|
295
|
+
this.editingKey = null;
|
|
296
|
+
this.flash = { kind: "info", text: "Edit cancelled" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private renderRow(row: PanelRow, selected: boolean, width: number): string {
|
|
300
|
+
const t = this.host.theme;
|
|
301
|
+
const innerWidth = Math.max(1, width - 2);
|
|
302
|
+
const selectable = row.kind !== "info" && !row.disabled;
|
|
303
|
+
const prefix = selected ? t.fg("accent", " › ") : " ";
|
|
304
|
+
const labelColor = row.disabled ? "dim" : selected ? "accent" : row.kind === "info" ? "muted" : "text";
|
|
305
|
+
const label = t.fg(labelColor, row.label);
|
|
306
|
+
|
|
307
|
+
if (this.editingKey === row.key && this.input) {
|
|
308
|
+
const left = `${prefix}${label}: `;
|
|
309
|
+
const available = Math.max(1, innerWidth - visibleWidth(left) - 1);
|
|
310
|
+
this.input.focused = this._focused;
|
|
311
|
+
const inputLine = this.input.render(available)[0] ?? "";
|
|
312
|
+
return this.boxLine(`${left}${inputLine}`, width);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const value = t.fg(this.valueColor(row), row.value);
|
|
316
|
+
const left = `${selectable ? prefix : " "}${label}`;
|
|
317
|
+
const gap = Math.max(1, innerWidth - visibleWidth(left) - visibleWidth(value) - 2);
|
|
318
|
+
return this.boxLine(`${left}${" ".repeat(gap)}${value} `, width);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private renderMessageLine(): string {
|
|
322
|
+
const t = this.host.theme;
|
|
323
|
+
if (this.flash) {
|
|
324
|
+
const color = this.flash.kind === "error" ? "error" : this.flash.kind === "success" ? "success" : "muted";
|
|
325
|
+
return t.fg(color, ` ${this.flash.text}`);
|
|
326
|
+
}
|
|
327
|
+
if (this.editingKey) {
|
|
328
|
+
const row = this.allRows().find((candidate) => candidate.key === this.editingKey);
|
|
329
|
+
return t.fg("muted", ` ${row?.inputHint ?? "Enter saves · Esc cancels"}`);
|
|
330
|
+
}
|
|
331
|
+
return t.fg("dim", ` ${this.snapshotValue.idleMessage ?? "Changes save immediately"}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private renderShortcutLine(): string | null {
|
|
335
|
+
const shortcuts = this.snapshotValue.shortcuts ?? [];
|
|
336
|
+
if (!shortcuts.length) return null;
|
|
337
|
+
return shortcuts.map((shortcut) => `${shortcut.key} ${shortcut.label}`).join(" · ");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private renderNavigationLine(): string {
|
|
341
|
+
const kb = this.host.keybindings;
|
|
342
|
+
const up = this.bindingText(kb.getKeys("tui.select.up"), "↑");
|
|
343
|
+
const down = this.bindingText(kb.getKeys("tui.select.down"), "↓");
|
|
344
|
+
const confirm = this.bindingText(kb.getKeys("tui.select.confirm"), "enter");
|
|
345
|
+
const cancel = this.bindingText(kb.getKeys("tui.select.cancel"), "esc");
|
|
346
|
+
return `${up}/${down}/jk move · ${confirm} select · ${cancel}/q close`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private bindingText(keys: readonly string[], fallback: string): string {
|
|
350
|
+
const first = keys[0];
|
|
351
|
+
if (!first) return fallback;
|
|
352
|
+
return first
|
|
353
|
+
.replace(/^up$/, "↑")
|
|
354
|
+
.replace(/^down$/, "↓")
|
|
355
|
+
.replace(/^left$/, "←")
|
|
356
|
+
.replace(/^right$/, "→")
|
|
357
|
+
.replace(/^escape$/, "esc")
|
|
358
|
+
.replace(/^return$/, "enter");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private valueColor(row: PanelRow): Parameters<Theme["fg"]>[0] {
|
|
362
|
+
if (row.valueStyle) return row.valueStyle;
|
|
363
|
+
if (row.disabled) return "dim";
|
|
364
|
+
if (row.kind === "toggle") return row.rawValue === "true" ? "success" : "muted";
|
|
365
|
+
if (row.kind === "action") return "accent";
|
|
366
|
+
return "text";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private boxLine(content: string, width: number): string {
|
|
370
|
+
const t = this.host.theme;
|
|
371
|
+
if (width <= 1) return truncateToWidth(content, Math.max(1, width), "", true);
|
|
372
|
+
const innerWidth = Math.max(0, width - 2);
|
|
373
|
+
const clipped = truncateToWidth(content, innerWidth, "…", true);
|
|
374
|
+
const padded = clipped + " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
375
|
+
return t.fg("border", "│") + padded + t.fg("border", "│");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private topBorder(width: number, title: string): string {
|
|
379
|
+
const t = this.host.theme;
|
|
380
|
+
if (width <= 1) return t.fg("borderAccent", "─".repeat(Math.max(1, width)));
|
|
381
|
+
const innerWidth = Math.max(0, width - 2);
|
|
382
|
+
const styledTitle = t.fg("accent", t.bold(` ${title} `));
|
|
383
|
+
const clippedTitle = truncateToWidth(styledTitle, innerWidth, "", false);
|
|
384
|
+
const tail = "─".repeat(Math.max(0, innerWidth - visibleWidth(clippedTitle)));
|
|
385
|
+
return t.fg("border", "╭") + clippedTitle + t.fg("border", `${tail}╮`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private bottomBorder(width: number): string {
|
|
389
|
+
const t = this.host.theme;
|
|
390
|
+
if (width <= 1) return t.fg("border", "─".repeat(Math.max(1, width)));
|
|
391
|
+
return t.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
392
|
+
}
|
|
393
|
+
}
|