@rol1n/tui-asr 0.0.0-stage → 0.1.1
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/README.md +20 -2
- package/dictation.ts +272 -0
- package/index.ts +181 -0
- package/input.ts +87 -0
- package/model-selector.ts +163 -0
- package/package.json +40 -4
- package/status.ts +79 -0
package/README.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tui-asr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Streaming voice dictation for Pi's terminal editor using Aliyun DashScope Paraformer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/tui-asr
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Also included in `@rol1n/pi-kit`. Installing this package automatically installs `@rol1n/custom-providers`.
|
|
12
|
+
|
|
13
|
+
## Commands and shortcuts
|
|
14
|
+
|
|
15
|
+
| Action | Trigger |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Start / stop dictation | `/asr` or `Alt+M` |
|
|
18
|
+
| Cancel dictation | `Alt+N` |
|
|
19
|
+
| Switch ASR model | `/asr model [name]` |
|
|
20
|
+
|
|
21
|
+
Requires `sox` (`rec` on POSIX, `sox -t waveaudio` on Windows) on `PATH` and DashScope credentials via `/login dashscope`, `DASHSCOPE_API_KEY`, or `ALIYUN_API_KEY`.
|
package/dictation.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
3
|
+
import type { Readable } from "node:stream";
|
|
4
|
+
import {
|
|
5
|
+
ALL_ASR_MODELS,
|
|
6
|
+
createAsrProvider,
|
|
7
|
+
ensureCustomProviders,
|
|
8
|
+
type AsrModelDefinition,
|
|
9
|
+
type StreamingAsrProvider,
|
|
10
|
+
} from "@rol1n/custom-providers";
|
|
11
|
+
import { insertTranscript } from "./input.ts";
|
|
12
|
+
import { createStatusDisplay } from "./status.ts";
|
|
13
|
+
|
|
14
|
+
export { ALL_ASR_MODELS, createAsrProvider, ensureCustomProviders, type AsrModelDefinition, type StreamingAsrProvider };
|
|
15
|
+
|
|
16
|
+
export async function resolveProviderApiKey(provider: string, ctx?: ExtensionContext | null): Promise<string> {
|
|
17
|
+
if (ctx?.modelRegistry) {
|
|
18
|
+
const candidates = provider === "dashscope" ? ["dashscope", "aliyun"] : [provider];
|
|
19
|
+
for (const id of candidates) {
|
|
20
|
+
try {
|
|
21
|
+
const key = await ctx.modelRegistry.getApiKeyForProvider(id);
|
|
22
|
+
if (key?.trim()) return key.trim();
|
|
23
|
+
} catch {}
|
|
24
|
+
try {
|
|
25
|
+
const auth = await ctx.modelRegistry.getProviderAuth(id);
|
|
26
|
+
if (auth?.auth?.apiKey?.trim()) return auth.auth.apiKey.trim();
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const envKey = process.env[`${provider.toUpperCase()}_API_KEY`];
|
|
31
|
+
if (envKey?.trim()) return envKey.trim();
|
|
32
|
+
if (provider === "dashscope") {
|
|
33
|
+
return process.env.ALIYUN_API_KEY?.trim() || "";
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function rmsFromPcm16(buf: Buffer): number {
|
|
39
|
+
if (buf.length < 2) return 0;
|
|
40
|
+
const sampleCount = Math.floor(buf.length / 2);
|
|
41
|
+
let sumSquares = 0;
|
|
42
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
43
|
+
const sample = buf.readInt16LE(i * 2);
|
|
44
|
+
sumSquares += sample * sample;
|
|
45
|
+
}
|
|
46
|
+
const meanSquare = sumSquares / sampleCount;
|
|
47
|
+
return Math.sqrt(meanSquare);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Spawn the existing 16 kHz mono PCM recorder for the current platform. */
|
|
51
|
+
export function spawnRecorder(): ChildProcessByStdio<null, Readable, Readable> {
|
|
52
|
+
const commonAudioArgs = [
|
|
53
|
+
"-q",
|
|
54
|
+
"--buffer", "512",
|
|
55
|
+
"-r", "16000",
|
|
56
|
+
"-c", "1",
|
|
57
|
+
"-b", "16",
|
|
58
|
+
"-e", "signed-integer",
|
|
59
|
+
"-t", "raw",
|
|
60
|
+
"-",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
let proc: ChildProcessByStdio<null, Readable, Readable>;
|
|
64
|
+
if (process.platform === "win32") {
|
|
65
|
+
proc = spawn("sox", ["-t", "waveaudio", "default", ...commonAudioArgs], { stdio: ["ignore", "pipe", "pipe"] });
|
|
66
|
+
} else {
|
|
67
|
+
try {
|
|
68
|
+
proc = spawn("rec", commonAudioArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
|
69
|
+
} catch {
|
|
70
|
+
proc = spawn("sox", ["-d", ...commonAudioArgs], { stdio: ["ignore", "pipe", "pipe"] });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return proc;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type State = "idle" | "recording" | "stopping";
|
|
77
|
+
|
|
78
|
+
/** Keep recording state, cancellation guards, and cleanup in one session-local controller. */
|
|
79
|
+
export function createDictationController(getActiveModel: () => string, getTuiHandle: () => any) {
|
|
80
|
+
let state: State = "idle";
|
|
81
|
+
let rec: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
|
82
|
+
let activeProvider: StreamingAsrProvider | null = null;
|
|
83
|
+
let finals: string[] = [];
|
|
84
|
+
let activeCtx: ExtensionContext | null = null;
|
|
85
|
+
let flushed = false;
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
let stopTimeout: NodeJS.Timeout | null = null;
|
|
88
|
+
let generation = 0;
|
|
89
|
+
|
|
90
|
+
const status = createStatusDisplay(() => activeCtx);
|
|
91
|
+
const { setStatus, startMeter, stopMeter, startSpinner, stopSpinner } = status;
|
|
92
|
+
|
|
93
|
+
/** Flush transcribed text into the focused component once per recording. */
|
|
94
|
+
const flush = () => {
|
|
95
|
+
if (flushed || !activeCtx) return;
|
|
96
|
+
flushed = true;
|
|
97
|
+
if (cancelled) return;
|
|
98
|
+
|
|
99
|
+
const text = finals.join(" ").replace(/\s+/g, " ").trim();
|
|
100
|
+
if (!text) return;
|
|
101
|
+
insertTranscript(activeCtx, getTuiHandle(), text);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const cleanup = () => {
|
|
105
|
+
generation++;
|
|
106
|
+
flush();
|
|
107
|
+
stopSpinner();
|
|
108
|
+
stopMeter();
|
|
109
|
+
if (stopTimeout) {
|
|
110
|
+
clearTimeout(stopTimeout);
|
|
111
|
+
stopTimeout = null;
|
|
112
|
+
}
|
|
113
|
+
if (rec) {
|
|
114
|
+
try {
|
|
115
|
+
rec.kill("SIGTERM");
|
|
116
|
+
} catch {}
|
|
117
|
+
rec = null;
|
|
118
|
+
}
|
|
119
|
+
if (activeProvider) {
|
|
120
|
+
try {
|
|
121
|
+
activeProvider.cancel();
|
|
122
|
+
} catch {}
|
|
123
|
+
activeProvider = null;
|
|
124
|
+
}
|
|
125
|
+
finals = [];
|
|
126
|
+
state = "idle";
|
|
127
|
+
setStatus(undefined);
|
|
128
|
+
activeCtx = null;
|
|
129
|
+
flushed = false;
|
|
130
|
+
cancelled = false;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const startDictation = async (ctx: ExtensionContext) => {
|
|
134
|
+
const modelDef = ALL_ASR_MODELS.find((m) => m.id === getActiveModel()) ?? ALL_ASR_MODELS[0];
|
|
135
|
+
const apiKey = await resolveProviderApiKey(modelDef.provider, ctx);
|
|
136
|
+
if (!apiKey) {
|
|
137
|
+
ctx.ui.notify(
|
|
138
|
+
`API Key not found for ${modelDef.provider}.\nPlease run /login ${modelDef.provider} in terminal or set ${modelDef.provider.toUpperCase()}_API_KEY.`,
|
|
139
|
+
"error",
|
|
140
|
+
);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
activeCtx = ctx;
|
|
145
|
+
finals = [];
|
|
146
|
+
flushed = false;
|
|
147
|
+
cancelled = false;
|
|
148
|
+
state = "recording";
|
|
149
|
+
|
|
150
|
+
const provider = createAsrProvider(modelDef.provider, modelDef.id, apiKey);
|
|
151
|
+
activeProvider = provider;
|
|
152
|
+
const myGeneration = ++generation;
|
|
153
|
+
startMeter();
|
|
154
|
+
|
|
155
|
+
// Spawn cross-platform audio recorder (sox / rec)
|
|
156
|
+
let proc: ChildProcessByStdio<null, Readable, Readable>;
|
|
157
|
+
let recorderStderr = "";
|
|
158
|
+
try {
|
|
159
|
+
proc = spawnRecorder();
|
|
160
|
+
} catch {
|
|
161
|
+
ctx.ui.notify("Failed to spawn audio recorder. Please ensure sox or rec is installed (e.g. sudo pacman -S sox).", "error");
|
|
162
|
+
cleanup();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
rec = proc;
|
|
166
|
+
|
|
167
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
168
|
+
recorderStderr += chunk.toString();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
proc.on("error", (err) => {
|
|
172
|
+
if (myGeneration !== generation) return;
|
|
173
|
+
ctx.ui.notify(`Audio recording error: ${err.message}`, "error");
|
|
174
|
+
cleanup();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
proc.on("exit", (code) => {
|
|
178
|
+
if (myGeneration !== generation) return;
|
|
179
|
+
if (state === "recording" && code !== null && code !== 0) {
|
|
180
|
+
if (activeCtx) {
|
|
181
|
+
const detail = recorderStderr.trim() ? `: ${recorderStderr.trim()}` : "";
|
|
182
|
+
activeCtx.ui.notify(`Audio recorder exited unexpectedly (code ${code})${detail}`, "warning");
|
|
183
|
+
}
|
|
184
|
+
cleanup();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Start provider streaming connection
|
|
189
|
+
try {
|
|
190
|
+
await provider.start({
|
|
191
|
+
onFinal: (t) => {
|
|
192
|
+
if (myGeneration !== generation || cancelled) return;
|
|
193
|
+
if (t) finals.push(t);
|
|
194
|
+
},
|
|
195
|
+
onError: (err) => {
|
|
196
|
+
if (myGeneration !== generation || cancelled) return;
|
|
197
|
+
if (activeCtx) activeCtx.ui.notify(`DashScope ASR error: ${err.message}`, "error");
|
|
198
|
+
cleanup();
|
|
199
|
+
},
|
|
200
|
+
onClose: () => {
|
|
201
|
+
if (myGeneration !== generation || cancelled) return;
|
|
202
|
+
if (state === "recording" || state === "stopping") {
|
|
203
|
+
cleanup();
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
} catch (err: any) {
|
|
208
|
+
if (myGeneration !== generation || cancelled) return;
|
|
209
|
+
ctx.ui.notify(`Failed to connect to DashScope: ${err.message}`, "error");
|
|
210
|
+
cleanup();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (myGeneration !== generation || !rec) return;
|
|
215
|
+
|
|
216
|
+
rec.stdout.on("data", (chunk: Buffer) => {
|
|
217
|
+
status.setLevel(rmsFromPcm16(chunk));
|
|
218
|
+
if (provider && state === "recording") {
|
|
219
|
+
provider.sendAudio(chunk);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const stopDictation = async () => {
|
|
225
|
+
if (state !== "recording") return;
|
|
226
|
+
state = "stopping";
|
|
227
|
+
stopMeter();
|
|
228
|
+
startSpinner("finalizing…");
|
|
229
|
+
|
|
230
|
+
if (rec) {
|
|
231
|
+
try {
|
|
232
|
+
rec.kill("SIGTERM");
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (activeProvider) {
|
|
237
|
+
try {
|
|
238
|
+
await activeProvider.stop();
|
|
239
|
+
} catch {
|
|
240
|
+
cleanup();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
stopTimeout = setTimeout(() => {
|
|
245
|
+
if (state === "stopping") cleanup();
|
|
246
|
+
}, 3000);
|
|
247
|
+
} else {
|
|
248
|
+
cleanup();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const cancelDictation = () => {
|
|
253
|
+
if (state !== "recording" && state !== "stopping") return;
|
|
254
|
+
cancelled = true;
|
|
255
|
+
finals = [];
|
|
256
|
+
cleanup();
|
|
257
|
+
if (activeCtx) activeCtx.ui.notify("Voice dictation cancelled", "info");
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const toggleDictation = async (ctx: ExtensionContext) => {
|
|
261
|
+
if (state === "idle") {
|
|
262
|
+
await startDictation(ctx);
|
|
263
|
+
} else if (state === "recording") {
|
|
264
|
+
await stopDictation();
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
get state() { return state; },
|
|
270
|
+
startDictation, stopDictation, cancelDictation, toggleDictation, cleanup,
|
|
271
|
+
};
|
|
272
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui-asr — Terminal streaming ASR voice dictation extension for Pi.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* 1. Streaming ASR: Real-time recognition using Aliyun DashScope Paraformer WebSocket.
|
|
6
|
+
* 2. Visual metering: Animated braille volume level waveform (● ⣀⣄ listening…).
|
|
7
|
+
* 3. Native Pi auth: Resolves keys from `/login dashscope` (auth.json) or DASHSCOPE_API_KEY.
|
|
8
|
+
* 4. Model selector: Native Pi-style searchable TUI selector dialog (/asr model).
|
|
9
|
+
* 5. Input injection: Directly inserts text into active editor or clipboard.
|
|
10
|
+
*
|
|
11
|
+
* Keybindings:
|
|
12
|
+
* - Alt+M: Start / Stop voice dictation
|
|
13
|
+
* - Alt+N: Cancel current dictation
|
|
14
|
+
*
|
|
15
|
+
* Commands:
|
|
16
|
+
* - /asr: Toggle voice dictation
|
|
17
|
+
* - /asr model [name]: Interactive searchable model selector
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
22
|
+
import { ALL_ASR_MODELS, createDictationController, ensureCustomProviders, resolveProviderApiKey, type AsrModelDefinition } from "./dictation.ts";
|
|
23
|
+
import { SearchableAsrModelSelectorComponent } from "./model-selector.ts";
|
|
24
|
+
|
|
25
|
+
/** Default startup ASR model ID */
|
|
26
|
+
const DEFAULT_ASR_MODEL = "paraformer-realtime-v2";
|
|
27
|
+
|
|
28
|
+
/** Default shortcuts */
|
|
29
|
+
const TOGGLE_SHORTCUTS = ["alt+m"];
|
|
30
|
+
const CANCEL_SHORTCUTS = ["alt+n"];
|
|
31
|
+
|
|
32
|
+
export default function (pi: ExtensionAPI) {
|
|
33
|
+
ensureCustomProviders(pi);
|
|
34
|
+
let activeModel = DEFAULT_ASR_MODEL;
|
|
35
|
+
|
|
36
|
+
// TUI state
|
|
37
|
+
let tuiHandle: any = null;
|
|
38
|
+
let removeInputListener: (() => void) | null = null;
|
|
39
|
+
let lastCtx: ExtensionContext | null = null;
|
|
40
|
+
|
|
41
|
+
const dictation = createDictationController(() => activeModel, () => tuiHandle);
|
|
42
|
+
const { startDictation, stopDictation, cancelDictation, toggleDictation, cleanup } = dictation;
|
|
43
|
+
|
|
44
|
+
// Key interception
|
|
45
|
+
const ensureTuiAttached = (ctx: ExtensionContext) => {
|
|
46
|
+
lastCtx = ctx;
|
|
47
|
+
if (ctx.mode === "tui" && !removeInputListener && typeof ctx.ui?.onTerminalInput === "function") {
|
|
48
|
+
removeInputListener = ctx.ui.onTerminalInput((keyData) => {
|
|
49
|
+
const matchesAny = (list: string[]) =>
|
|
50
|
+
list.some((s) => {
|
|
51
|
+
const raw = s.toLowerCase().trim();
|
|
52
|
+
if (raw === "alt+m" && (keyData === "\x1bm" || keyData === "µ")) return true;
|
|
53
|
+
if (raw === "alt+n" && (keyData === "\x1bn" || keyData === "Å")) return true;
|
|
54
|
+
return false;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (matchesAny(TOGGLE_SHORTCUTS)) {
|
|
58
|
+
if (dictation.state === "idle") startDictation(ctx);
|
|
59
|
+
else if (dictation.state === "recording") stopDictation();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (matchesAny(CANCEL_SHORTCUTS)) {
|
|
64
|
+
if (dictation.state !== "idle") {
|
|
65
|
+
cancelDictation();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
pi.on("session_start", (_event, ctx) => {
|
|
76
|
+
ensureTuiAttached(ctx);
|
|
77
|
+
if (ctx.mode === "tui") {
|
|
78
|
+
ctx.ui.setWidget("tui-asr-handle", (tui: any) => {
|
|
79
|
+
tuiHandle = tui;
|
|
80
|
+
return {
|
|
81
|
+
render: () => [],
|
|
82
|
+
invalidate: () => {},
|
|
83
|
+
dispose: () => {
|
|
84
|
+
tuiHandle = null;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Register shortcuts
|
|
92
|
+
pi.registerShortcut("alt+m", {
|
|
93
|
+
description: "Toggle voice dictation (tui-asr)",
|
|
94
|
+
handler: async (ctx) => {
|
|
95
|
+
ensureTuiAttached(ctx);
|
|
96
|
+
await toggleDictation(ctx);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
pi.registerShortcut("alt+n", {
|
|
101
|
+
description: "Cancel voice dictation (tui-asr)",
|
|
102
|
+
handler: async (ctx) => {
|
|
103
|
+
ensureTuiAttached(ctx);
|
|
104
|
+
cancelDictation();
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Register command /asr
|
|
109
|
+
pi.registerCommand("asr", {
|
|
110
|
+
description: "Voice dictation control and model switching (/asr [model])",
|
|
111
|
+
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
112
|
+
const trimmed = prefix.trimStart();
|
|
113
|
+
if (!trimmed || "model".startsWith(trimmed)) {
|
|
114
|
+
return [{ value: "model", label: "model", description: "Select ASR voice model in TUI" }];
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
},
|
|
118
|
+
handler: async (args, ctx) => {
|
|
119
|
+
ensureTuiAttached(ctx);
|
|
120
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
121
|
+
const sub = parts[0]?.toLowerCase();
|
|
122
|
+
|
|
123
|
+
if (sub === "model") {
|
|
124
|
+
const modelArg = parts[1]?.toLowerCase();
|
|
125
|
+
if (modelArg) {
|
|
126
|
+
const matched = ALL_ASR_MODELS.find(
|
|
127
|
+
(m) =>
|
|
128
|
+
m.id.toLowerCase() === modelArg ||
|
|
129
|
+
m.name.toLowerCase() === modelArg ||
|
|
130
|
+
m.id.toLowerCase().includes(modelArg),
|
|
131
|
+
);
|
|
132
|
+
if (matched) {
|
|
133
|
+
activeModel = matched.id;
|
|
134
|
+
const isLogged = Boolean(await resolveProviderApiKey(matched.provider, ctx));
|
|
135
|
+
const extra = isLogged ? "" : ` (No key detected: please run /login ${matched.provider})`;
|
|
136
|
+
ctx.ui.notify(`ASR model switched to: ${matched.name} (${matched.id})${extra}`, "info");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Open searchable model selector TUI
|
|
142
|
+
const loginStatusMap = new Map<string, boolean>();
|
|
143
|
+
for (const item of ALL_ASR_MODELS) {
|
|
144
|
+
if (!loginStatusMap.has(item.provider)) {
|
|
145
|
+
const key = await resolveProviderApiKey(item.provider, ctx);
|
|
146
|
+
loginStatusMap.set(item.provider, Boolean(key));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const selected = await ctx.ui.custom<AsrModelDefinition | undefined>((tui, theme, _kb, done) => {
|
|
151
|
+
return new SearchableAsrModelSelectorComponent(
|
|
152
|
+
tui,
|
|
153
|
+
theme,
|
|
154
|
+
ALL_ASR_MODELS,
|
|
155
|
+
activeModel,
|
|
156
|
+
(model) => done(model),
|
|
157
|
+
() => done(undefined),
|
|
158
|
+
(provider) => loginStatusMap.get(provider) ?? false,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!selected) return;
|
|
163
|
+
|
|
164
|
+
activeModel = selected.id;
|
|
165
|
+
const isLogged = loginStatusMap.get(selected.provider);
|
|
166
|
+
const extra = isLogged ? "" : ` (No key detected: please run /login ${selected.provider})`;
|
|
167
|
+
ctx.ui.notify(`ASR model switched to: ${selected.name} (${selected.id})${extra}`, "info");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Default action: toggle dictation
|
|
172
|
+
await toggleDictation(ctx);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
pi.on("session_shutdown", () => {
|
|
177
|
+
if (dictation.state !== "idle") cleanup();
|
|
178
|
+
removeInputListener?.();
|
|
179
|
+
removeInputListener = null;
|
|
180
|
+
});
|
|
181
|
+
}
|
package/input.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
export function copyToClipboard(text: string): boolean {
|
|
5
|
+
try {
|
|
6
|
+
const isWsl =
|
|
7
|
+
process.platform === "linux" &&
|
|
8
|
+
typeof process.env.WSL_DISTRO_NAME === "string";
|
|
9
|
+
|
|
10
|
+
let proc: ChildProcessByStdio<any, any, any>;
|
|
11
|
+
if (isWsl) {
|
|
12
|
+
proc = spawn("clip.exe", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
13
|
+
} else if (process.platform === "win32") {
|
|
14
|
+
proc = spawn("clip", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
15
|
+
} else if (process.platform === "darwin") {
|
|
16
|
+
proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
17
|
+
} else {
|
|
18
|
+
try {
|
|
19
|
+
proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
20
|
+
} catch {
|
|
21
|
+
proc = spawn("xclip", ["-selection", "clipboard"], { stdio: ["pipe", "ignore", "ignore"] });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
proc.stdin.write(text);
|
|
26
|
+
proc.stdin.end();
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EditorLike {
|
|
34
|
+
getText(): string;
|
|
35
|
+
setText(text: string): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Target =
|
|
39
|
+
| { kind: "editor"; editor: EditorLike }
|
|
40
|
+
| { kind: "typable"; component: { handleInput(data: string): void } };
|
|
41
|
+
|
|
42
|
+
const asEditorLike = (value: any): EditorLike | null =>
|
|
43
|
+
value && typeof value.getText === "function" && typeof value.setText === "function" ? value : null;
|
|
44
|
+
|
|
45
|
+
function resolveTarget(tuiHandle: any): Target | null {
|
|
46
|
+
const focused = tuiHandle?.focusedComponent;
|
|
47
|
+
if (!focused) return null;
|
|
48
|
+
const editor = asEditorLike(focused) ?? asEditorLike(focused.editor);
|
|
49
|
+
if (editor) return { kind: "editor", editor };
|
|
50
|
+
if (typeof focused.handleInput === "function") return { kind: "typable", component: focused };
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Insert a final transcript into the focused input, with editor and clipboard fallbacks. */
|
|
55
|
+
export function insertTranscript(ctx: ExtensionContext, tuiHandle: any, text: string): void {
|
|
56
|
+
// 1. Fallback: append to main editor if no TUI handle
|
|
57
|
+
if (!tuiHandle) {
|
|
58
|
+
const current = ctx.ui.getEditorText() ?? "";
|
|
59
|
+
const sep = current && !/\s$/.test(current) ? " " : "";
|
|
60
|
+
ctx.ui.setEditorText(current + sep + text);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Insert into focused editor or typable component
|
|
65
|
+
const target = resolveTarget(tuiHandle);
|
|
66
|
+
if (target?.kind === "editor") {
|
|
67
|
+
const current = target.editor.getText() ?? "";
|
|
68
|
+
const sep = current && !/\s$/.test(current) ? " " : "";
|
|
69
|
+
target.editor.setText(current + sep + text);
|
|
70
|
+
tuiHandle.requestRender?.();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (target?.kind === "typable") {
|
|
75
|
+
target.component.handleInput(text);
|
|
76
|
+
tuiHandle.requestRender?.();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Fallback: copy to clipboard if no input focused
|
|
81
|
+
const copied = copyToClipboard(text);
|
|
82
|
+
if (copied) {
|
|
83
|
+
ctx.ui.notify("Dictated text copied to clipboard (no input focused)", "info");
|
|
84
|
+
} else {
|
|
85
|
+
ctx.ui.notify(`Dictated: ${text}`, "info");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Input, fuzzyFilter, getKeybindings } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { AsrModelDefinition } from "./dictation.ts";
|
|
3
|
+
|
|
4
|
+
export class SearchableAsrModelSelectorComponent {
|
|
5
|
+
private searchInput = new Input();
|
|
6
|
+
private selectedIndex = 0;
|
|
7
|
+
private filteredModels: AsrModelDefinition[] = [];
|
|
8
|
+
private _focused = true;
|
|
9
|
+
private cachedLines?: string[];
|
|
10
|
+
private cachedWidth = -1;
|
|
11
|
+
|
|
12
|
+
get focused() {
|
|
13
|
+
return this._focused;
|
|
14
|
+
}
|
|
15
|
+
set focused(value: boolean) {
|
|
16
|
+
this._focused = value;
|
|
17
|
+
this.searchInput.focused = value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private tui: any,
|
|
22
|
+
private theme: any,
|
|
23
|
+
private models: AsrModelDefinition[],
|
|
24
|
+
private currentModelId: string,
|
|
25
|
+
private onSelect: (model: AsrModelDefinition) => void,
|
|
26
|
+
private onCancel: () => void,
|
|
27
|
+
private getLoginStatus?: (provider: string) => boolean,
|
|
28
|
+
) {
|
|
29
|
+
this.filteredModels = [...models];
|
|
30
|
+
const curIdx = models.findIndex((m) => m.id === currentModelId);
|
|
31
|
+
this.selectedIndex = curIdx >= 0 ? curIdx : 0;
|
|
32
|
+
this.searchInput.focused = true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private refresh() {
|
|
36
|
+
this.cachedLines = undefined;
|
|
37
|
+
this.tui.requestRender?.();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
filterModels(query: string) {
|
|
41
|
+
const q = query.trim().toLowerCase();
|
|
42
|
+
if (q) {
|
|
43
|
+
this.filteredModels = fuzzyFilter(
|
|
44
|
+
this.models,
|
|
45
|
+
q,
|
|
46
|
+
(m) => `${m.id} ${m.name} ${m.provider} ${m.description}`,
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
this.filteredModels = [...this.models];
|
|
50
|
+
}
|
|
51
|
+
this.selectedIndex = q ? 0 : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
|
52
|
+
this.refresh();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render(width: number): string[] {
|
|
56
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
57
|
+
|
|
58
|
+
const lines: string[] = [];
|
|
59
|
+
const border = this.theme.fg("accent", "─".repeat(Math.max(10, width)));
|
|
60
|
+
|
|
61
|
+
lines.push(border);
|
|
62
|
+
lines.push("");
|
|
63
|
+
|
|
64
|
+
// Scope & credentials status line (matching Pi native selector screenshot)
|
|
65
|
+
const scopeTitle = `${this.theme.fg("muted", "Scope: ")}${this.theme.fg("accent", "all")}${this.theme.fg("muted", " | dashscope")}`;
|
|
66
|
+
lines.push(` ${scopeTitle}`);
|
|
67
|
+
|
|
68
|
+
if (this.getLoginStatus) {
|
|
69
|
+
const loggedIn = this.getLoginStatus("dashscope");
|
|
70
|
+
const authStatus = loggedIn
|
|
71
|
+
? this.theme.fg("success", "✓ DashScope credentials: ready (via /login dashscope)")
|
|
72
|
+
: this.theme.fg("warning", "! DashScope credentials: not logged in (run /login dashscope)");
|
|
73
|
+
lines.push(` ${authStatus}`);
|
|
74
|
+
}
|
|
75
|
+
lines.push("");
|
|
76
|
+
|
|
77
|
+
// Search input line (> █)
|
|
78
|
+
for (const l of this.searchInput.render(Math.max(1, width - 4))) {
|
|
79
|
+
lines.push(` ${l}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push("");
|
|
82
|
+
|
|
83
|
+
// Model list (<cursor><check><model-id> [<provider>] [badges])
|
|
84
|
+
if (this.filteredModels.length === 0) {
|
|
85
|
+
lines.push(` ${this.theme.fg("warning", "No matching ASR models found")}`);
|
|
86
|
+
} else {
|
|
87
|
+
const maxVisible = 6;
|
|
88
|
+
const total = this.filteredModels.length;
|
|
89
|
+
const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
|
|
90
|
+
const endIndex = Math.min(startIndex + maxVisible, total);
|
|
91
|
+
|
|
92
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
93
|
+
const item = this.filteredModels[i];
|
|
94
|
+
const isSelected = i === this.selectedIndex;
|
|
95
|
+
const isCurrent = item.id === this.currentModelId;
|
|
96
|
+
|
|
97
|
+
const cursor = isSelected ? this.theme.fg("accent", "→ ") : " ";
|
|
98
|
+
const check = isCurrent ? this.theme.fg("accent", "✓ ") : " ";
|
|
99
|
+
const modelText = isSelected
|
|
100
|
+
? (this.theme.bold ? this.theme.bold(this.theme.fg("accent", item.id)) : this.theme.fg("accent", item.id))
|
|
101
|
+
: this.theme.fg("text", item.id);
|
|
102
|
+
const providerBadge = this.theme.fg("muted", `[${item.provider}]`);
|
|
103
|
+
const currentBadge = isCurrent ? this.theme.fg("muted", " · current") : "";
|
|
104
|
+
|
|
105
|
+
lines.push(` ${cursor}${check}${modelText} ${providerBadge}${currentBadge}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (startIndex > 0 || endIndex < total) {
|
|
109
|
+
lines.push(` ${this.theme.fg("muted", `(${this.selectedIndex + 1}/${total})`)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
|
|
114
|
+
// Selected model detail
|
|
115
|
+
const selected = this.filteredModels[this.selectedIndex];
|
|
116
|
+
if (selected) {
|
|
117
|
+
lines.push(` ${this.theme.fg("muted", "Model Name: ")}${selected.name}`);
|
|
118
|
+
lines.push(` ${this.theme.fg("muted", "Description: ")}${selected.description}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push("");
|
|
121
|
+
|
|
122
|
+
// Keybinding footer hints
|
|
123
|
+
lines.push(this.theme.fg("dim", " Enter to select · Escape/Ctrl+C to cancel · ↑↓ to navigate"));
|
|
124
|
+
lines.push(border);
|
|
125
|
+
|
|
126
|
+
this.cachedLines = lines;
|
|
127
|
+
this.cachedWidth = width;
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
invalidate() {
|
|
132
|
+
this.cachedLines = undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
handleInput(keyData: string) {
|
|
136
|
+
const kb = getKeybindings();
|
|
137
|
+
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
|
|
138
|
+
if (this.filteredModels.length === 0) return;
|
|
139
|
+
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
|
|
140
|
+
this.refresh();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
|
|
144
|
+
if (this.filteredModels.length === 0) return;
|
|
145
|
+
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
|
|
146
|
+
this.refresh();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
|
|
150
|
+
const selected = this.filteredModels[this.selectedIndex];
|
|
151
|
+
if (selected) {
|
|
152
|
+
this.onSelect(selected);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (kb.matches(keyData, "tui.select.cancel") || keyData === "\x1b" || keyData === "\x03") {
|
|
157
|
+
this.onCancel();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.searchInput.handleInput(keyData);
|
|
161
|
+
this.filterModels(this.searchInput.getValue());
|
|
162
|
+
}
|
|
163
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rol1n/tui-asr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "tui-asr extension from pi-kit",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/RolinShmily/pi-kit.git",
|
|
12
|
+
"directory": "tui-asr"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/RolinShmily/pi-kit#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/RolinShmily/pi-kit/issues"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.19.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@rol1n/custom-providers": "^0.1.1"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-ai": "*",
|
|
29
|
+
"@earendil-works/pi-agent-core": "*",
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*",
|
|
32
|
+
"typebox": "*"
|
|
33
|
+
},
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./index.ts"
|
|
37
|
+
],
|
|
38
|
+
"skills": [],
|
|
39
|
+
"prompts": [],
|
|
40
|
+
"themes": []
|
|
41
|
+
}
|
|
42
|
+
}
|
package/status.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const BRAILLE_FRAMES = ["⠀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷", "⣿"];
|
|
4
|
+
const METER_CELLS = 4;
|
|
5
|
+
const METER_TICK_MS = 80;
|
|
6
|
+
|
|
7
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
8
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
9
|
+
|
|
10
|
+
function rmsToBlock(rms: number): string {
|
|
11
|
+
const norm = Math.min(1, Math.max(0, rms / 8000));
|
|
12
|
+
const idx = Math.min(BRAILLE_FRAMES.length - 1, Math.floor(norm * BRAILLE_FRAMES.length));
|
|
13
|
+
return BRAILLE_FRAMES[idx];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Own animation timers for one dictation controller. */
|
|
17
|
+
export function createStatusDisplay(getActiveContext: () => ExtensionContext | null) {
|
|
18
|
+
// Meter animation
|
|
19
|
+
let meter: number[] = new Array(METER_CELLS).fill(0);
|
|
20
|
+
let currentLevel = 0;
|
|
21
|
+
let meterTimer: NodeJS.Timeout | null = null;
|
|
22
|
+
|
|
23
|
+
// Spinner animation
|
|
24
|
+
let spinnerFrame = 0;
|
|
25
|
+
let spinnerTimer: NodeJS.Timeout | null = null;
|
|
26
|
+
|
|
27
|
+
const setStatus = (msg: string | undefined) => {
|
|
28
|
+
const activeCtx = getActiveContext();
|
|
29
|
+
if (!activeCtx) return;
|
|
30
|
+
activeCtx.ui.setStatus("tui-asr", msg);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const stopSpinner = () => {
|
|
34
|
+
if (spinnerTimer) {
|
|
35
|
+
clearInterval(spinnerTimer);
|
|
36
|
+
spinnerTimer = null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const stopMeter = () => {
|
|
41
|
+
if (meterTimer) {
|
|
42
|
+
clearInterval(meterTimer);
|
|
43
|
+
meterTimer = null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Start braille audio level meter animation */
|
|
48
|
+
const startMeter = () => {
|
|
49
|
+
stopMeter();
|
|
50
|
+
meter = new Array(METER_CELLS).fill(0);
|
|
51
|
+
currentLevel = 0;
|
|
52
|
+
const render = () => {
|
|
53
|
+
const dot = getActiveContext()?.ui.theme.fg("error", "●") ?? "●";
|
|
54
|
+
setStatus(`${dot} ${meter.map(rmsToBlock).join("")} listening…`);
|
|
55
|
+
};
|
|
56
|
+
render();
|
|
57
|
+
meterTimer = setInterval(() => {
|
|
58
|
+
meter.shift();
|
|
59
|
+
meter.push(currentLevel);
|
|
60
|
+
render();
|
|
61
|
+
}, METER_TICK_MS);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Start finalizing spinner */
|
|
65
|
+
const startSpinner = (suffix: string) => {
|
|
66
|
+
stopSpinner();
|
|
67
|
+
spinnerFrame = 0;
|
|
68
|
+
setStatus(`${SPINNER_FRAMES[0]} ${suffix}`);
|
|
69
|
+
spinnerTimer = setInterval(() => {
|
|
70
|
+
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
71
|
+
setStatus(`${SPINNER_FRAMES[spinnerFrame]} ${suffix}`);
|
|
72
|
+
}, SPINNER_INTERVAL_MS);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
setStatus, startMeter, stopMeter, startSpinner, stopSpinner,
|
|
77
|
+
setLevel(level: number) { currentLevel = level; },
|
|
78
|
+
};
|
|
79
|
+
}
|