@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.
Files changed (38) hide show
  1. package/out/headless/index.mjs +339 -2
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +441 -20
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BbCVOTaJ.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-DryLywQv.js} +1 -1
  7. package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-CdUMtSdr.js} +1 -1
  8. package/out/renderer/assets/{html-D8xFiRmI.js → html-Cwkf2GMH.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-C7PzhXxH.js} +3 -3
  10. package/out/renderer/assets/{index-B-iM7dFC.css → index-BIceGcz3.css} +52 -0
  11. package/out/renderer/assets/{index---H6cxNl.js → index-Dg1vMspf.js} +274 -244
  12. package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-Cy7ROXJl.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BtZtt3QW.js} +3 -3
  14. package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-xReAQeYs.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
  16. package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-YMNFO1Zz.js} +1 -1
  17. package/out/renderer/assets/{python-DKu7rNbs.js → python-BExaDVxv.js} +1 -1
  18. package/out/renderer/assets/{razor-BOMpCo6z.js → razor-HL8ANmQ4.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-Dqd8vdLE.js} +1 -1
  21. package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-Dysz1PTX.js} +1 -1
  22. package/out/renderer/assets/{xml-D4PvYeQq.js → xml-BHp5Z9nc.js} +1 -1
  23. package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-CPDHxVup.js} +1 -1
  24. package/out/renderer/index.html +64 -2
  25. package/out/repro-toggle.mjs +305 -0
  26. package/package.json +1 -1
  27. package/src/main/agentState.ts +329 -0
  28. package/src/main/index.ts +107 -19
  29. package/src/main/orchestrator.ts +34 -2
  30. package/src/main/transcripts.ts +134 -0
  31. package/src/preload/index.ts +11 -0
  32. package/src/renderer/App.tsx +48 -13
  33. package/src/renderer/components/FloatingMic.tsx +48 -0
  34. package/src/renderer/components/VoiceInput.tsx +36 -149
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +46 -2
  37. package/src/renderer/lib/speech.ts +18 -0
  38. package/src/renderer/styles.css +52 -0
@@ -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(blob: Blob, onProgress?: (p: ModelProgress) => void): Promise<string> {
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
- const result = await transcriber(pcm)
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> {
@@ -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;