@phenx-inc/ctlsurf 0.7.0 → 0.9.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 +169 -10
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +175 -9
  4. package/out/preload/index.js +10 -0
  5. package/out/renderer/assets/{cssMode-eTXVdAkZ.js → cssMode-BdYp0fp9.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-B5BKaiK4.js → freemarker2-Cya-9zr-.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BIdLd2wU.js → handlebars-EJs9W5Ub.js} +1 -1
  8. package/out/renderer/assets/{html-BXL4cnLS.js → html-Ex4qy2lh.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-46N3XG2c.js → htmlMode-CCm0aJ6C.js} +3 -3
  10. package/out/renderer/assets/{index-dRvutfbl.js → index-Cj7K9-Ba.js} +823 -137
  11. package/out/renderer/assets/{index-Cf-RsxoC.css → index-nvsNYP5c.css} +229 -0
  12. package/out/renderer/assets/{javascript-n_iZZzDX.js → javascript-B2niCouI.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DXDczSNu.js → jsonMode-BgCUHaFo.js} +3 -3
  14. package/out/renderer/assets/{liquid-B1QweUh7.js → liquid-DOQmSo4G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-DqzMqkRk.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
  16. package/out/renderer/assets/{mdx-BCv8lm5e.js → mdx-DuKdGNKE.js} +1 -1
  17. package/out/renderer/assets/{python-BLNzYwDv.js → python-BUa4XFG-.js} +1 -1
  18. package/out/renderer/assets/{razor-CvAww8bG.js → razor-BUw8jho0.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-C7m6Kr5E.js → tsMode-8FLRnobw.js} +1 -1
  21. package/out/renderer/assets/{typescript-DhPw4VVg.js → typescript-rdwAjCi4.js} +1 -1
  22. package/out/renderer/assets/{xml-B0WLFJ2U.js → xml-HOzT59Qg.js} +1 -1
  23. package/out/renderer/assets/{yaml-BWyn9Wd7.js → yaml-B81JfLsW.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/index.ts +9 -0
  28. package/src/main/orchestrator.ts +51 -10
  29. package/src/main/transcripts.ts +134 -0
  30. package/src/preload/index.ts +14 -0
  31. package/src/renderer/App.tsx +30 -11
  32. package/src/renderer/components/FloatingMic.tsx +48 -0
  33. package/src/renderer/components/SpeakControls.tsx +235 -0
  34. package/src/renderer/components/VoiceInput.tsx +183 -140
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +94 -6
  37. package/src/renderer/lib/speech.ts +317 -0
  38. package/src/renderer/styles.css +229 -0
@@ -109,6 +109,20 @@ const api = {
109
109
  setLogChat: (enabled: boolean): Promise<{ enabled: boolean }> =>
110
110
  ipcRenderer.invoke('logchat:set', enabled),
111
111
 
112
+ // Speak agent replies (global, Electron-only)
113
+ getSpeakReplies: (): Promise<{ enabled: boolean }> =>
114
+ ipcRenderer.invoke('speak:get'),
115
+ setSpeakReplies: (enabled: boolean): Promise<{ enabled: boolean }> =>
116
+ ipcRenderer.invoke('speak:set', enabled),
117
+ onAgentMessage: (callback: (text: string) => void) => {
118
+ const listener = (_event: Electron.IpcRendererEvent, text: string) => callback(text)
119
+ ipcRenderer.on('agent:message', listener)
120
+ return () => ipcRenderer.removeListener('agent:message', listener)
121
+ },
122
+ // Latest assistant reply from the active tab's transcript (null if none).
123
+ getLastReply: (): Promise<string | null> =>
124
+ ipcRenderer.invoke('agent:getLastReply'),
125
+
112
126
  // Filesystem
113
127
  readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
114
128
  ipcRenderer.invoke('fs:readDir', dirPath),
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback, useRef } from 'react'
2
2
  import { TerminalPanel, destroyTerminal, focusTerminal } from './components/TerminalPanel'
3
3
  import { FloatingMic } from './components/FloatingMic'
4
+ import { SpeakControls } from './components/SpeakControls'
4
5
  import { CtlsurfPanel } from './components/CtlsurfPanel'
5
6
  import { EditorPanel } from './components/EditorPanel'
6
7
  import { AgentPicker } from './components/AgentPicker'
@@ -56,6 +57,10 @@ declare global {
56
57
  setTracking: (enabled: boolean) => Promise<{ active: boolean }>
57
58
  getLogChat: () => Promise<{ enabled: boolean }>
58
59
  setLogChat: (enabled: boolean) => Promise<{ enabled: boolean }>
60
+ getSpeakReplies: () => Promise<{ enabled: boolean }>
61
+ setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
62
+ onAgentMessage: (callback: (text: string) => void) => () => void
63
+ getLastReply: () => Promise<string | null>
59
64
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
60
65
  getWebviewInfo: () => Promise<{
61
66
  frontendUrl: string; pageUrl?: string; authenticated: boolean;
@@ -151,16 +156,22 @@ export default function App() {
151
156
 
152
157
  useEffect(() => {
153
158
  async function init() {
154
- const list = await window.worker.listAgents()
155
- setAgents(list)
156
- const status = await window.worker.getWorkerStatus()
157
- setWsStatus(status)
158
- // Load initial cwd for the picker
159
- const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath())
160
- setCwd(initialCwd)
161
- cwdRef.current = initialCwd
162
- // Connected ctlsurf project name for the header (null until a worker registers)
163
- window.worker.getProjectName().then(setProjectName).catch(() => { /* ignore */ })
159
+ try {
160
+ const list = await window.worker.listAgents()
161
+ setAgents(list)
162
+ const status = await window.worker.getWorkerStatus()
163
+ setWsStatus(status)
164
+ // Load initial cwd for the picker
165
+ const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath())
166
+ setCwd(initialCwd)
167
+ cwdRef.current = initialCwd
168
+ // Connected ctlsurf project name for the header (null until a worker registers)
169
+ window.worker.getProjectName().then(setProjectName).catch(() => { /* ignore */ })
170
+ } finally {
171
+ // Dismiss the boot splash painted by index.html now that startup IPC has
172
+ // settled. In `finally` so a failed IPC call never leaves it stuck on.
173
+ ;(window as unknown as { __dismissSplash?: () => void }).__dismissSplash?.()
174
+ }
164
175
  }
165
176
  init()
166
177
  }, [])
@@ -221,8 +232,15 @@ export default function App() {
221
232
  const handleVoiceTranscript = useCallback((text: string) => {
222
233
  const trimmed = text.trim()
223
234
  if (!trimmed) return
224
- window.worker.writePty(activeTabId, trimmed + '\r')
235
+ // Send the text and the submit Enter as TWO separate writes. A long transcript
236
+ // glued to its trailing \r arrives as one fast burst, which the agent's input
237
+ // box treats as a paste and turns the carriage return into a literal newline
238
+ // instead of submitting. Delaying the Enter lets the text be consumed first so
239
+ // \r registers as a real submit keystroke. (80ms is the tunable knob if very
240
+ // long input ever needs more time to drain.)
241
+ window.worker.writePty(activeTabId, trimmed)
225
242
  focusTerminal(activeTabId)
243
+ setTimeout(() => window.worker.writePty(activeTabId, '\r'), 80)
226
244
  }, [activeTabId])
227
245
 
228
246
  const cwdRef = useRef<string | null>(null)
@@ -504,6 +522,7 @@ export default function App() {
504
522
  <line x1="8" y1="23" x2="16" y2="23" />
505
523
  </svg>
506
524
  </button>
525
+ <SpeakControls />
507
526
  <span className="titlebar-separator" />
508
527
  {agents.map(a => {
509
528
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { VoiceInput } from './VoiceInput'
3
+ import { speech } from '../lib/speech'
3
4
 
4
5
  // A draggable, dismissable push-to-talk mic that floats over the panes. It wraps
5
6
  // the same <VoiceInput> push-to-talk logic used in the titlebar; only the chrome
@@ -36,6 +37,29 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
36
37
  // Pointer-to-element offset captured at drag start; null when not dragging.
37
38
  const dragRef = useRef<{ dx: number; dy: number } | null>(null)
38
39
 
40
+ // Read-aloud (TTS) of the last agent reply. `speaking` mirrors the shared
41
+ // speech singleton; `note` is a transient message (e.g. nothing to read yet).
42
+ const [speaking, setSpeaking] = useState(false)
43
+ const [note, setNote] = useState<string | null>(null)
44
+ useEffect(() => speech.onActivity(setSpeaking), [])
45
+ useEffect(() => {
46
+ if (!note) return
47
+ const t = setTimeout(() => setNote(null), 3000)
48
+ return () => clearTimeout(t)
49
+ }, [note])
50
+
51
+ const onReadAloud = useCallback(async () => {
52
+ // Toggle: a second click while speaking stops it.
53
+ if (speaking) { speech.stop(); return }
54
+ // Prime the audio context from this click (synchronously, before the await)
55
+ // so neural playback isn't blocked by the autoplay policy.
56
+ speech.unlock()
57
+ let text: string | null = null
58
+ try { text = await window.worker.getLastReply() } catch { /* ignore */ }
59
+ if (text) speech.enqueue(text)
60
+ else setNote('No reply to read yet')
61
+ }, [speaking])
62
+
39
63
  // Keep the button fully inside the viewport (used on drag, mount, and resize).
40
64
  const clamp = useCallback((x: number, y: number): Pos => {
41
65
  const el = elRef.current
@@ -123,6 +147,30 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
123
147
  </button>
124
148
  </div>
125
149
  <VoiceInput variant="floating" onTranscript={onTranscript} />
150
+ <button
151
+ type="button"
152
+ className={`floating-mic-read ${speaking ? 'speaking' : ''}`}
153
+ // Don't let a click on the button start a drag.
154
+ onPointerDown={(e) => e.stopPropagation()}
155
+ onClick={onReadAloud}
156
+ title={speaking ? 'Stop reading' : 'Read last agent reply aloud'}
157
+ aria-label={speaking ? 'Stop reading' : 'Read last agent reply aloud'}
158
+ aria-pressed={speaking}
159
+ >
160
+ {speaking ? (
161
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
162
+ <rect x="6" y="6" width="12" height="12" rx="2" />
163
+ </svg>
164
+ ) : (
165
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor"
166
+ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
167
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
168
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
169
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
170
+ </svg>
171
+ )}
172
+ </button>
173
+ {note && <div className="floating-mic-note">{note}</div>}
126
174
  </div>
127
175
  )
128
176
  }
@@ -0,0 +1,235 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ speech,
4
+ getEngine,
5
+ setEngine as persistEngine,
6
+ getVoiceURI,
7
+ setVoiceURI as persistVoiceURI,
8
+ getRate,
9
+ setRate as persistRate,
10
+ listWebVoices,
11
+ type TtsEngineId,
12
+ type TtsModelProgress,
13
+ } from '../lib/speech'
14
+
15
+ // Titlebar control for "speak agent replies" (Electron desktop only). A speaker
16
+ // toggle drives the main-process speakReplies setting (which runs the transcript
17
+ // tailer); a ▾ dropdown picks the engine, voice, and rate.
18
+
19
+ const SAMPLE = 'This is how spoken agent replies will sound.'
20
+
21
+ export function SpeakControls() {
22
+ const [enabled, setEnabled] = useState(false)
23
+ const [engine, setEngineState] = useState<TtsEngineId>(getEngine)
24
+ const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
25
+ const [voiceURI, setVoiceURIState] = useState<string | null>(getVoiceURI)
26
+ const [rate, setRateState] = useState<number>(getRate)
27
+ const [showMenu, setShowMenu] = useState(false)
28
+ const [modelPct, setModelPct] = useState<number | null>(null)
29
+ const [speaking, setSpeaking] = useState(false)
30
+ const [error, setError] = useState<string | null>(null)
31
+ const wrapRef = useRef<HTMLDivElement>(null)
32
+
33
+ // Reflect the persisted main-process setting on mount.
34
+ useEffect(() => {
35
+ let alive = true
36
+ window.worker.getSpeakReplies().then((r) => { if (alive) setEnabled(!!r.enabled) }).catch(() => {})
37
+ return () => { alive = false }
38
+ }, [])
39
+
40
+ // Speak replies as they arrive. Main only forwards when the setting is on, so
41
+ // this is a no-op while disabled.
42
+ useEffect(() => {
43
+ const off = window.worker.onAgentMessage((text) => speech.enqueue(text))
44
+ return off
45
+ }, [])
46
+
47
+ // Surface neural-model download/transcribe progress on the button.
48
+ useEffect(() => {
49
+ speech.onModelProgress = (p: TtsModelProgress | null) => {
50
+ if (p && p.status === 'progress' && typeof p.progress === 'number') {
51
+ setModelPct(Math.min(100, Math.round(p.progress)))
52
+ } else if (!p) {
53
+ setModelPct(null)
54
+ }
55
+ }
56
+ speech.onActivityChange = (a) => setSpeaking(a)
57
+ speech.onError = (msg) => setError(msg)
58
+ return () => {
59
+ speech.onModelProgress = null
60
+ speech.onActivityChange = null
61
+ speech.onError = null
62
+ }
63
+ }, [])
64
+
65
+ // Auto-dismiss the error chip.
66
+ useEffect(() => {
67
+ if (!error) return
68
+ const t = setTimeout(() => setError(null), 4000)
69
+ return () => clearTimeout(t)
70
+ }, [error])
71
+
72
+ // Web Speech voices populate asynchronously.
73
+ useEffect(() => {
74
+ const load = () => setVoices(listWebVoices())
75
+ load()
76
+ if (typeof speechSynthesis !== 'undefined') {
77
+ speechSynthesis.addEventListener('voiceschanged', load)
78
+ return () => speechSynthesis.removeEventListener('voiceschanged', load)
79
+ }
80
+ }, [])
81
+
82
+ // Close the menu on an outside click.
83
+ useEffect(() => {
84
+ if (!showMenu) return
85
+ const onDown = (e: MouseEvent) => {
86
+ if (!wrapRef.current?.contains(e.target as Node)) setShowMenu(false)
87
+ }
88
+ document.addEventListener('mousedown', onDown)
89
+ return () => document.removeEventListener('mousedown', onDown)
90
+ }, [showMenu])
91
+
92
+ const toggle = useCallback(async () => {
93
+ const next = !enabled
94
+ setEnabled(next)
95
+ // Prime the audio context from this click so neural playback isn't blocked
96
+ // by autoplay policy when a reply later arrives without a gesture.
97
+ if (next) speech.unlock()
98
+ else speech.stop()
99
+ try { await window.worker.setSpeakReplies(next) } catch { /* ignore */ }
100
+ }, [enabled])
101
+
102
+ const chooseEngine = useCallback((id: TtsEngineId) => {
103
+ persistEngine(id)
104
+ setEngineState(id)
105
+ speech.stop()
106
+ // Preload the neural model so the first reply isn't slowed by download.
107
+ if (id === 'neural') speech.warmup()
108
+ }, [])
109
+
110
+ const chooseVoice = useCallback((uri: string) => {
111
+ const v = uri || null
112
+ persistVoiceURI(v)
113
+ setVoiceURIState(v)
114
+ }, [])
115
+
116
+ const changeRate = useCallback((r: number) => {
117
+ persistRate(r)
118
+ setRateState(r)
119
+ }, [])
120
+
121
+ const testVoice = useCallback(() => {
122
+ speech.unlock()
123
+ speech.stop()
124
+ speech.enqueue(SAMPLE)
125
+ }, [])
126
+
127
+ const title = enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
128
+
129
+ return (
130
+ <div className="speak-controls" ref={wrapRef}>
131
+ <button
132
+ type="button"
133
+ className={`titlebar-btn titlebar-icon-btn speak-btn ${enabled ? 'active' : ''}`}
134
+ onClick={toggle}
135
+ title={title}
136
+ aria-label="Toggle spoken agent replies"
137
+ aria-pressed={enabled}
138
+ >
139
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor"
140
+ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
141
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
142
+ {enabled ? (
143
+ <>
144
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
145
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
146
+ </>
147
+ ) : (
148
+ <line x1="23" y1="9" x2="17" y2="15" />
149
+ )}
150
+ </svg>
151
+ {modelPct !== null && <span className="speak-pct">{modelPct}%</span>}
152
+ </button>
153
+ {speaking && (
154
+ <button
155
+ type="button"
156
+ className="titlebar-btn titlebar-icon-btn speak-stop"
157
+ onClick={() => speech.stop()}
158
+ title="Stop speaking"
159
+ aria-label="Stop speaking"
160
+ >
161
+ <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true">
162
+ <rect x="5" y="5" width="14" height="14" rx="2" />
163
+ </svg>
164
+ </button>
165
+ )}
166
+ <button
167
+ type="button"
168
+ className="titlebar-btn speak-caret"
169
+ onClick={() => setShowMenu((v) => !v)}
170
+ title="Voice options"
171
+ aria-label="Voice options"
172
+ aria-expanded={showMenu}
173
+ >
174
+ ▾
175
+ </button>
176
+
177
+ {showMenu && (
178
+ <div className="speak-menu" role="menu">
179
+ <div className="speak-menu-head">Engine</div>
180
+ <button
181
+ type="button"
182
+ className={`speak-menu-item ${engine === 'web' ? 'active' : ''}`}
183
+ onClick={() => chooseEngine('web')}
184
+ >
185
+ <span className="speak-menu-check">{engine === 'web' ? '✓' : ''}</span>
186
+ <span>System voice (instant)</span>
187
+ </button>
188
+ <button
189
+ type="button"
190
+ className={`speak-menu-item ${engine === 'neural' ? 'active' : ''}`}
191
+ onClick={() => chooseEngine('neural')}
192
+ >
193
+ <span className="speak-menu-check">{engine === 'neural' ? '✓' : ''}</span>
194
+ <span>Neural voice (downloads)</span>
195
+ </button>
196
+
197
+ {engine === 'web' && (
198
+ <>
199
+ <div className="speak-menu-head">Voice</div>
200
+ <select
201
+ className="speak-select"
202
+ value={voiceURI || ''}
203
+ onChange={(e) => chooseVoice(e.target.value)}
204
+ >
205
+ <option value="">System default</option>
206
+ {voices.map((v) => (
207
+ <option key={v.voiceURI} value={v.voiceURI}>
208
+ {v.name} {v.lang ? `(${v.lang})` : ''}
209
+ </option>
210
+ ))}
211
+ </select>
212
+ </>
213
+ )}
214
+
215
+ <div className="speak-menu-head">Rate · {rate.toFixed(1)}×</div>
216
+ <input
217
+ className="speak-rate"
218
+ type="range"
219
+ min={0.5}
220
+ max={2}
221
+ step={0.1}
222
+ value={rate}
223
+ onChange={(e) => changeRate(Number(e.target.value))}
224
+ />
225
+
226
+ <div className="speak-menu-row">
227
+ <button type="button" className="speak-menu-btn" onClick={testVoice}>Test</button>
228
+ <button type="button" className="speak-menu-btn" onClick={() => speech.stop()}>Stop</button>
229
+ </div>
230
+ </div>
231
+ )}
232
+ {error && <div className="speak-error">{error}</div>}
233
+ </div>
234
+ )
235
+ }