@phenx-inc/ctlsurf 0.8.0 → 0.10.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/out/headless/index.mjs +339 -2
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +441 -20
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BbCVOTaJ.js} +3 -3
- package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-DryLywQv.js} +1 -1
- package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-CdUMtSdr.js} +1 -1
- package/out/renderer/assets/{html-D8xFiRmI.js → html-Cwkf2GMH.js} +1 -1
- package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-C7PzhXxH.js} +3 -3
- package/out/renderer/assets/{index-B-iM7dFC.css → index-BIceGcz3.css} +52 -0
- package/out/renderer/assets/{index---H6cxNl.js → index-Dg1vMspf.js} +274 -244
- package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-Cy7ROXJl.js} +2 -2
- package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BtZtt3QW.js} +3 -3
- package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-xReAQeYs.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
- package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-YMNFO1Zz.js} +1 -1
- package/out/renderer/assets/{python-DKu7rNbs.js → python-BExaDVxv.js} +1 -1
- package/out/renderer/assets/{razor-BOMpCo6z.js → razor-HL8ANmQ4.js} +1 -1
- package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
- package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-Dqd8vdLE.js} +1 -1
- package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-Dysz1PTX.js} +1 -1
- package/out/renderer/assets/{xml-D4PvYeQq.js → xml-BHp5Z9nc.js} +1 -1
- package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-CPDHxVup.js} +1 -1
- package/out/renderer/index.html +64 -2
- package/out/repro-toggle.mjs +305 -0
- package/package.json +1 -1
- package/src/main/agentState.ts +329 -0
- package/src/main/index.ts +107 -19
- package/src/main/orchestrator.ts +34 -2
- package/src/main/transcripts.ts +134 -0
- package/src/preload/index.ts +11 -0
- package/src/renderer/App.tsx +48 -13
- package/src/renderer/components/FloatingMic.tsx +48 -0
- package/src/renderer/components/VoiceInput.tsx +36 -149
- package/src/renderer/index.html +62 -0
- package/src/renderer/lib/localWhisper.ts +46 -2
- package/src/renderer/lib/speech.ts +18 -0
- package/src/renderer/styles.css +52 -0
package/src/renderer/index.html
CHANGED
|
@@ -4,9 +4,71 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ctlsurf-worker</title>
|
|
7
|
+
<!-- Boot splash styles are inlined here so they paint on the first frame,
|
|
8
|
+
before the JS bundle and styles.css have loaded. Without this the window
|
|
9
|
+
shows an empty dark pane while React mounts and the startup IPC runs. -->
|
|
10
|
+
<style>
|
|
11
|
+
#boot-splash {
|
|
12
|
+
position: fixed;
|
|
13
|
+
inset: 0;
|
|
14
|
+
z-index: 9999;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
gap: 16px;
|
|
20
|
+
background: #1a1b26;
|
|
21
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
22
|
+
transition: opacity 0.3s ease;
|
|
23
|
+
-webkit-app-region: drag; /* keep the frameless window draggable during load */
|
|
24
|
+
}
|
|
25
|
+
#boot-splash.boot-splash-hidden {
|
|
26
|
+
opacity: 0;
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
}
|
|
29
|
+
.boot-spinner {
|
|
30
|
+
width: 34px;
|
|
31
|
+
height: 34px;
|
|
32
|
+
border-radius: 50%;
|
|
33
|
+
border: 3px solid rgba(122, 162, 247, 0.22);
|
|
34
|
+
border-top-color: #7aa2f7;
|
|
35
|
+
animation: boot-spin 0.8s linear infinite;
|
|
36
|
+
}
|
|
37
|
+
@keyframes boot-spin {
|
|
38
|
+
to { transform: rotate(360deg); }
|
|
39
|
+
}
|
|
40
|
+
.boot-splash-title {
|
|
41
|
+
font-size: 15px;
|
|
42
|
+
font-weight: 600;
|
|
43
|
+
letter-spacing: 0.02em;
|
|
44
|
+
color: #c0caf5;
|
|
45
|
+
}
|
|
46
|
+
.boot-splash-label {
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
letter-spacing: 0.04em;
|
|
49
|
+
color: #565f89;
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
7
52
|
</head>
|
|
8
53
|
<body>
|
|
54
|
+
<div id="boot-splash">
|
|
55
|
+
<div class="boot-spinner"></div>
|
|
56
|
+
<div class="boot-splash-title">ctlsurf</div>
|
|
57
|
+
<div class="boot-splash-label">Starting up…</div>
|
|
58
|
+
</div>
|
|
9
59
|
<div id="root"></div>
|
|
60
|
+
<script>
|
|
61
|
+
// Fades out and removes the boot splash. Called by the React app once its
|
|
62
|
+
// startup IPC has settled (see App.tsx). The timeout is a safety net so a
|
|
63
|
+
// hung startup never leaves the user stuck behind the splash forever.
|
|
64
|
+
window.__dismissSplash = function () {
|
|
65
|
+
var el = document.getElementById('boot-splash')
|
|
66
|
+
if (!el) return
|
|
67
|
+
el.classList.add('boot-splash-hidden')
|
|
68
|
+
setTimeout(function () { el.remove() }, 350)
|
|
69
|
+
}
|
|
70
|
+
setTimeout(function () { window.__dismissSplash() }, 10000)
|
|
71
|
+
</script>
|
|
10
72
|
<script type="module" src="./main.tsx"></script>
|
|
11
73
|
</body>
|
|
12
74
|
</html>
|
|
@@ -107,7 +107,14 @@ async function blobToPcm16k(blob: Blob): Promise<Float32Array | null> {
|
|
|
107
107
|
return rendered.getChannelData(0)
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export async function transcribeBlob(
|
|
110
|
+
export async function transcribeBlob(
|
|
111
|
+
blob: Blob,
|
|
112
|
+
onProgress?: (p: ModelProgress) => void,
|
|
113
|
+
// Optional live preview: called with the transcript-so-far as the model
|
|
114
|
+
// decodes, for progressive on-screen feedback. The return value (below) is
|
|
115
|
+
// still the authoritative, de-duplicated text used for the actual insert.
|
|
116
|
+
onPartial?: (text: string) => void,
|
|
117
|
+
): Promise<string> {
|
|
111
118
|
// Decode first so a silent clip never triggers the (one-time, ~75MB) model
|
|
112
119
|
// download just to be thrown away below.
|
|
113
120
|
const pcm = await blobToPcm16k(blob)
|
|
@@ -119,7 +126,44 @@ export async function transcribeBlob(blob: Blob, onProgress?: (p: ModelProgress)
|
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
const transcriber = await loadTranscriber(onProgress)
|
|
122
|
-
|
|
129
|
+
|
|
130
|
+
// Stream decoded text to onPartial as it's generated. `committed` holds text
|
|
131
|
+
// from finished chunks; `current` is the in-progress chunk — keeping them
|
|
132
|
+
// separate avoids the stride overlap duplicating words across 30s boundaries
|
|
133
|
+
// in the preview. Best-effort: if the streamer can't be built we just skip the
|
|
134
|
+
// preview and still return the final text.
|
|
135
|
+
let streamer: unknown
|
|
136
|
+
if (onPartial) {
|
|
137
|
+
try {
|
|
138
|
+
const { WhisperTextStreamer } = await import('@huggingface/transformers')
|
|
139
|
+
const tokenizer = (transcriber as unknown as {
|
|
140
|
+
tokenizer: ConstructorParameters<typeof WhisperTextStreamer>[0]
|
|
141
|
+
}).tokenizer
|
|
142
|
+
let committed = ''
|
|
143
|
+
let current = ''
|
|
144
|
+
streamer = new WhisperTextStreamer(tokenizer, {
|
|
145
|
+
on_chunk_start: () => { current = '' },
|
|
146
|
+
callback_function: (delta: string) => {
|
|
147
|
+
current += delta
|
|
148
|
+
onPartial((committed + current).replace(/\s+/g, ' ').trim())
|
|
149
|
+
},
|
|
150
|
+
on_chunk_end: () => { committed = (committed + current).trim() + ' '; current = '' },
|
|
151
|
+
})
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.warn('[voice] streaming preview unavailable', err)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Whisper only attends to a 30s window at a time. Without chunk_length_s the
|
|
158
|
+
// pipeline truncates to that first window, silently dropping anything spoken
|
|
159
|
+
// past ~30s. Chunking transcribes the full clip in overlapping 30s windows
|
|
160
|
+
// (stride gives overlap so words aren't lost at the seams) so long speeches
|
|
161
|
+
// are captured in full.
|
|
162
|
+
const result = await transcriber(pcm, {
|
|
163
|
+
chunk_length_s: 30,
|
|
164
|
+
stride_length_s: 5,
|
|
165
|
+
...(streamer ? { streamer } : {}),
|
|
166
|
+
})
|
|
123
167
|
const text = (Array.isArray(result)
|
|
124
168
|
? result.map((r) => r.text).join(' ')
|
|
125
169
|
: result?.text || '').trim()
|
|
@@ -164,6 +164,23 @@ class SpeechController {
|
|
|
164
164
|
onActivityChange: ((active: boolean) => void) | null = null
|
|
165
165
|
onError: ((message: string) => void) | null = null
|
|
166
166
|
|
|
167
|
+
// Multiple UI surfaces (titlebar SpeakControls + floating read button) need
|
|
168
|
+
// the speaking state at once, so beyond the single onActivityChange slot we
|
|
169
|
+
// also keep a listener set.
|
|
170
|
+
private activityListeners = new Set<(active: boolean) => void>()
|
|
171
|
+
|
|
172
|
+
get isActive(): boolean {
|
|
173
|
+
return this.active
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Subscribe to speaking-state changes. Fires immediately with the current
|
|
177
|
+
// state so a late subscriber syncs. Returns an unsubscribe function.
|
|
178
|
+
onActivity(fn: (active: boolean) => void): () => void {
|
|
179
|
+
this.activityListeners.add(fn)
|
|
180
|
+
fn(this.active)
|
|
181
|
+
return () => { this.activityListeners.delete(fn) }
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
// Prime/resume the AudioContext from a user gesture so neural playback isn't
|
|
168
185
|
// blocked by the browser autoplay policy (the System engine is unaffected).
|
|
169
186
|
unlock(): void {
|
|
@@ -216,6 +233,7 @@ class SpeechController {
|
|
|
216
233
|
if (this.active === a) return
|
|
217
234
|
this.active = a
|
|
218
235
|
this.onActivityChange?.(a)
|
|
236
|
+
this.activityListeners.forEach((fn) => fn(a))
|
|
219
237
|
}
|
|
220
238
|
|
|
221
239
|
private async drain(): Promise<void> {
|
package/src/renderer/styles.css
CHANGED
|
@@ -202,6 +202,24 @@ html, body, #root {
|
|
|
202
202
|
background: #565f89;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/* Agent is waiting on the user — pulse to draw the eye across tabs. */
|
|
206
|
+
.terminal-tab-dot.awaiting {
|
|
207
|
+
background: #e0af68; /* amber: waiting for next prompt */
|
|
208
|
+
color: #e0af68; /* drives the box-shadow glow via currentColor */
|
|
209
|
+
animation: tab-dot-pulse 1.1s ease-in-out infinite;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.terminal-tab-dot.decision {
|
|
213
|
+
background: #f7768e; /* red: blocked on an approval/permission prompt */
|
|
214
|
+
color: #f7768e;
|
|
215
|
+
animation: tab-dot-pulse 1.1s ease-in-out infinite;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@keyframes tab-dot-pulse {
|
|
219
|
+
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
|
|
220
|
+
50% { opacity: 0.35; box-shadow: 0 0 4px 1px currentColor; }
|
|
221
|
+
}
|
|
222
|
+
|
|
205
223
|
.terminal-tab-close {
|
|
206
224
|
background: none;
|
|
207
225
|
border: none;
|
|
@@ -701,6 +719,40 @@ html, body, #root {
|
|
|
701
719
|
}
|
|
702
720
|
.floating-mic-hide:hover { color: #f7768e; }
|
|
703
721
|
|
|
722
|
+
/* Smaller sibling of the round mic: read the last agent reply aloud. */
|
|
723
|
+
.floating-mic-read {
|
|
724
|
+
width: 30px;
|
|
725
|
+
height: 30px;
|
|
726
|
+
border-radius: 50%;
|
|
727
|
+
display: inline-flex;
|
|
728
|
+
align-items: center;
|
|
729
|
+
justify-content: center;
|
|
730
|
+
background: #2a2b3d;
|
|
731
|
+
border: 1px solid #3b3d57;
|
|
732
|
+
color: #a9b1d6;
|
|
733
|
+
cursor: pointer;
|
|
734
|
+
transition: all 0.15s;
|
|
735
|
+
}
|
|
736
|
+
.floating-mic-read:hover { border-color: #565f89; color: #c0caf5; }
|
|
737
|
+
.floating-mic-read.speaking {
|
|
738
|
+
color: #7aa2f7;
|
|
739
|
+
border-color: #7aa2f7;
|
|
740
|
+
}
|
|
741
|
+
.floating-mic-note {
|
|
742
|
+
position: absolute;
|
|
743
|
+
bottom: -22px;
|
|
744
|
+
left: 50%;
|
|
745
|
+
transform: translateX(-50%);
|
|
746
|
+
white-space: nowrap;
|
|
747
|
+
font-size: 11px;
|
|
748
|
+
color: #565f89;
|
|
749
|
+
background: #16161e;
|
|
750
|
+
border: 1px solid #3b3d57;
|
|
751
|
+
border-radius: 6px;
|
|
752
|
+
padding: 2px 6px;
|
|
753
|
+
pointer-events: none;
|
|
754
|
+
}
|
|
755
|
+
|
|
704
756
|
.voice-btn-floating {
|
|
705
757
|
width: 48px;
|
|
706
758
|
height: 48px;
|