@bojackduy/opencode-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.
@@ -0,0 +1,308 @@
1
+ // Focused-editor range adapter for local streaming dictation (STAGE 2).
2
+ //
3
+ // Owns ONLY the dictated range inside the focused editable field:
4
+ // prefix (surrounding text before the cursor at record start) +
5
+ // dictated (stable + tentative, replaced as hypotheses evolve) +
6
+ // suffix (surrounding text after the cursor at record start).
7
+ //
8
+ // Verified renderer APIs (installed @opencode-ai/plugin 1.18.25,
9
+ // .opencode/node_modules/@opencode-ai/plugin/dist/tui.d.ts):
10
+ // - `api.renderer` is typed as untyped `CliRenderer` from @opentui/core.
11
+ // `currentFocusedRenderable` does NOT appear in the plugin types at all -
12
+ // it is a runtime-only property.
13
+ // - The ONLY observed runtime methods on the focused renderable are
14
+ // `insertText(text)` and `submit()` (see lib/stt.js insertIntoFocusedInput).
15
+ // - `TuiPromptRef` (via session_prompt/home_prompt slots, which this repo
16
+ // does not use) offers `current.input` + `set()` + `submit()` - also no
17
+ // selection, cursor, or change-notification APIs.
18
+ // - No selection/cursor/change-notification behavior exists in the installed
19
+ // types. This adapter therefore MUST NOT assume any range-deletion,
20
+ // selection, or edit-notification API on the live renderer.
21
+ //
22
+ // Probing rule: a target supports live range replacement ONLY when it
23
+ // exposes BOTH `getText()` and `setRange(start, end, text)` functions
24
+ // (plus optional `getSelection()` and `isAlive()`). Anything else -
25
+ // including the real renderer, which exposes only `insertText` - takes the
26
+ // fallback path: live preview toast plus a SINGLE insert at finalize.
27
+ // Fallback inserts use the existing `insertIntoFocusedInput` helper and
28
+ // never fall through to primary-chat `appendPrompt`/`submitPrompt`.
29
+ //
30
+ // Safety rules (both paths):
31
+ // - The captured target is fixed for the session. If `getFocused()` returns
32
+ // a different identity, or `isAlive()` reports false, live mutations stop
33
+ // immediately; the transcript is retained for recovery and never silently
34
+ // redirected into another field.
35
+ // - If the user edits inside the dictated range (detected by comparing the
36
+ // field text against prefix + lastWritten + suffix), live mutations stop
37
+ // immediately; the transcript is retained.
38
+ // - `cancel()` removes ONLY plugin-owned dictated text via `setRange`
39
+ // (live path) and is a no-op when nothing was inserted (fallback path).
40
+ // User-typed prefix/suffix text is never touched.
41
+ // - Submission uses ONLY the captured target's own `submit()` when present.
42
+ // There is deliberately no fallback to primary-chat submit for a custom
43
+ // field. Callers implement this via `resolveStreamingSubmit(target)`.
44
+ // - No LLM calls anywhere in this module (local-only, zero quota).
45
+
46
+ export function canLiveReplace(target) {
47
+ return !!target && typeof target.getText === "function" && typeof target.setRange === "function";
48
+ }
49
+
50
+ export function hasOwnSubmit(target) {
51
+ return !!target && typeof target.submit === "function";
52
+ }
53
+
54
+ // Submission-safety decision for a captured target. Returns
55
+ // `{ ok: true, via: "focused-submit" }` only when the captured target
56
+ // itself can submit; otherwise `{ ok: false, reason }` and the caller must
57
+ // NOT fall through to primary-chat submit.
58
+ export function resolveStreamingSubmit(target) {
59
+ if (!target) return { ok: false, reason: "no-target" };
60
+ if (hasOwnSubmit(target)) return { ok: true, via: "focused-submit" };
61
+ return { ok: false, reason: "target-cannot-submit" };
62
+ }
63
+
64
+ function joinDictated(stableText, tentativeText) {
65
+ return `${stableText || ""} ${tentativeText || ""}`.trim();
66
+ }
67
+
68
+ export function createStreamingEditorAdapter({ toast = null, getFocused = null } = {}) {
69
+ let active = false;
70
+ let fallback = true;
71
+ let detached = false;
72
+ let detachReason = null;
73
+ let target = null;
74
+ let prefix = "";
75
+ let suffix = "";
76
+ let lastWritten = "";
77
+ let dictatedText = "";
78
+
79
+ function currentFocused() {
80
+ try {
81
+ return typeof getFocused === "function" ? getFocused() : target;
82
+ } catch {
83
+ return target;
84
+ }
85
+ }
86
+
87
+ function snapshotTarget(t) {
88
+ // Append-mode anchoring: without a selection API the cursor position is
89
+ // unknowable, so the entire pre-existing text is treated as prefix and
90
+ // the dictated range starts at its end. Nothing pre-existing is ever
91
+ // overwritten.
92
+ try {
93
+ const text = t.getText() ?? "";
94
+ if (typeof t.getSelection === "function") {
95
+ const sel = t.getSelection() || {};
96
+ const start = Number(sel.start ?? sel.anchor ?? text.length);
97
+ const end = Number(sel.end ?? sel.head ?? start);
98
+ const s = Number.isFinite(start) ? Math.max(0, Math.min(start, text.length)) : text.length;
99
+ const e = Number.isFinite(end) ? Math.max(s, Math.min(end, text.length)) : s;
100
+ return { prefix: text.slice(0, s), suffix: text.slice(e) };
101
+ }
102
+ return { prefix: String(text), suffix: "" };
103
+ } catch {
104
+ return { prefix: "", suffix: "" };
105
+ }
106
+ }
107
+
108
+ function begin(capturedTarget) {
109
+ if (active) return { ok: false, reason: "already-active" };
110
+ if (!capturedTarget) {
111
+ // No editable field at record start: fallback session with no target.
112
+ // Partials become preview toasts; finalize has nowhere to insert and
113
+ // reports `no-target` instead of redirecting into primary chat.
114
+ active = true;
115
+ fallback = true;
116
+ detached = false;
117
+ detachReason = null;
118
+ target = null;
119
+ prefix = "";
120
+ suffix = "";
121
+ lastWritten = "";
122
+ dictatedText = "";
123
+ return { ok: true, fallback: true, reason: "no-target" };
124
+ }
125
+ active = true;
126
+ detached = false;
127
+ detachReason = null;
128
+ target = capturedTarget;
129
+ dictatedText = "";
130
+ lastWritten = "";
131
+ if (canLiveReplace(capturedTarget)) {
132
+ const snap = snapshotTarget(capturedTarget);
133
+ prefix = snap.prefix;
134
+ suffix = snap.suffix;
135
+ fallback = false;
136
+ return { ok: true, fallback: false };
137
+ }
138
+ // Insert-capable (or unknown) field without range APIs: preview + single
139
+ // insert at finalize. Nothing is written until finalize.
140
+ prefix = "";
141
+ suffix = "";
142
+ fallback = true;
143
+ return { ok: true, fallback: true, reason: "no-range-api" };
144
+ }
145
+
146
+ function detach(reason) {
147
+ if (!active || detached) return dictatedText;
148
+ detached = true;
149
+ detachReason = reason || "detached";
150
+ return dictatedText;
151
+ }
152
+
153
+ function checkAttached() {
154
+ if (!active || fallback || detached) return null;
155
+ const now = currentFocused();
156
+ if (now !== target) {
157
+ detach(now ? "focus-moved" : "target-closed");
158
+ return detachReason;
159
+ }
160
+ try {
161
+ if (typeof target.isAlive === "function" && target.isAlive() === false) {
162
+ detach("target-closed");
163
+ return detachReason;
164
+ }
165
+ } catch {
166
+ detach("target-closed");
167
+ return detachReason;
168
+ }
169
+ // User-edit detection: the field must still read prefix +
170
+ // lastWritten + suffix. Any deviation inside the dictated range (or of
171
+ // the anchors) means the user typed there - freeze live mutations.
172
+ try {
173
+ const current = target.getText() ?? "";
174
+ const expected = `${prefix}${lastWritten}${suffix}`;
175
+ if (current !== expected) {
176
+ detach("user-edit");
177
+ return detachReason;
178
+ }
179
+ } catch {
180
+ detach("target-closed");
181
+ return detachReason;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function applyPartial({ stableText = "", tentativeText = "" } = {}) {
187
+ if (!active) return { status: "inactive", text: "" };
188
+ dictatedText = joinDictated(stableText, tentativeText);
189
+ if (fallback || detached) {
190
+ if (!detached && dictatedText) {
191
+ try {
192
+ toast?.(`🎙 ${dictatedText}`, "info");
193
+ } catch {}
194
+ }
195
+ return { status: detached ? "detached" : "preview", text: dictatedText };
196
+ }
197
+ const lost = checkAttached();
198
+ if (lost) return { status: "detached", text: dictatedText, reason: lost };
199
+ try {
200
+ target.setRange(prefix.length, prefix.length + lastWritten.length, dictatedText);
201
+ lastWritten = dictatedText;
202
+ return { status: "applied", text: dictatedText };
203
+ } catch {
204
+ detach("write-failed");
205
+ return { status: "detached", text: dictatedText, reason: detachReason };
206
+ }
207
+ }
208
+
209
+ // Commit state at finalize. Live path already shows the full text; the
210
+ // caller performs the single insert for the fallback path via the existing
211
+ // focused-input helper (never primary-chat append).
212
+ function finalize(fullText) {
213
+ if (!active) return { text: fullText || dictatedText, inserted: false, fallback };
214
+ const text = typeof fullText === "string" ? fullText : dictatedText;
215
+ dictatedText = text;
216
+ if (!fallback && !detached) {
217
+ const lost = checkAttached();
218
+ if (!lost) {
219
+ try {
220
+ target.setRange(prefix.length, prefix.length + lastWritten.length, text);
221
+ lastWritten = text;
222
+ } catch {
223
+ detach("write-failed");
224
+ }
225
+ }
226
+ }
227
+ const out = {
228
+ text,
229
+ inserted: !fallback && !detached,
230
+ fallback,
231
+ detached,
232
+ reason: detachReason,
233
+ };
234
+ active = false;
235
+ return out;
236
+ }
237
+
238
+ // Remove ONLY plugin-owned dictated text. Live path: clear the owned
239
+ // range, restoring prefix + suffix. Fallback path: nothing was inserted,
240
+ // so there is nothing to remove. Never touches user-typed text.
241
+ function cancel() {
242
+ if (!active) return { removedChars: 0, fallback };
243
+ let removedChars = 0;
244
+ if (!fallback && !detached && target) {
245
+ try {
246
+ const now = currentFocused();
247
+ if (now === target) {
248
+ const alive = typeof target.isAlive !== "function" || target.isAlive() !== false;
249
+ if (alive) {
250
+ const current = target.getText() ?? "";
251
+ if (current === `${prefix}${lastWritten}${suffix}` && lastWritten) {
252
+ target.setRange(prefix.length, prefix.length + lastWritten.length, "");
253
+ removedChars = lastWritten.length;
254
+ }
255
+ }
256
+ }
257
+ } catch {}
258
+ }
259
+ const wasFallback = fallback;
260
+ active = false;
261
+ detached = false;
262
+ detachReason = null;
263
+ target = null;
264
+ lastWritten = "";
265
+ dictatedText = "";
266
+ prefix = "";
267
+ suffix = "";
268
+ return { removedChars, fallback: wasFallback };
269
+ }
270
+
271
+ function reset() {
272
+ active = false;
273
+ fallback = true;
274
+ detached = false;
275
+ detachReason = null;
276
+ target = null;
277
+ prefix = "";
278
+ suffix = "";
279
+ lastWritten = "";
280
+ dictatedText = "";
281
+ }
282
+
283
+ function getState() {
284
+ return {
285
+ active,
286
+ fallback,
287
+ detached,
288
+ reason: detachReason,
289
+ dictatedText,
290
+ lastWritten,
291
+ prefixChars: prefix.length,
292
+ suffixChars: suffix.length,
293
+ hasTarget: target !== null,
294
+ };
295
+ }
296
+
297
+ return {
298
+ begin,
299
+ applyPartial,
300
+ finalize,
301
+ cancel,
302
+ reset,
303
+ detach,
304
+ getState,
305
+ getTarget: () => target,
306
+ getTranscript: () => dictatedText,
307
+ };
308
+ }