@phenx-inc/ctlsurf 0.7.0 → 0.8.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 (33) hide show
  1. package/out/headless/index.mjs +26 -10
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +31 -9
  4. package/out/preload/index.js +8 -0
  5. package/out/renderer/assets/{cssMode-eTXVdAkZ.js → cssMode-BQN8v2ok.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-B5BKaiK4.js → freemarker2-DbxGYYVp.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BIdLd2wU.js → handlebars-3auU1CAd.js} +1 -1
  8. package/out/renderer/assets/{html-BXL4cnLS.js → html-D8xFiRmI.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-46N3XG2c.js → htmlMode-M3MApZ4n.js} +3 -3
  10. package/out/renderer/assets/{index-dRvutfbl.js → index---H6cxNl.js} +696 -33
  11. package/out/renderer/assets/{index-Cf-RsxoC.css → index-B-iM7dFC.css} +195 -0
  12. package/out/renderer/assets/{javascript-n_iZZzDX.js → javascript-BO_ViZM5.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DXDczSNu.js → jsonMode-CKp2zvZu.js} +3 -3
  14. package/out/renderer/assets/{liquid-B1QweUh7.js → liquid-C1eHcrht.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-DqzMqkRk.js → lspLanguageFeatures-CHWJx_Tl.js} +1 -1
  16. package/out/renderer/assets/{mdx-BCv8lm5e.js → mdx-Qqdtk7fL.js} +1 -1
  17. package/out/renderer/assets/{python-BLNzYwDv.js → python-DKu7rNbs.js} +1 -1
  18. package/out/renderer/assets/{razor-CvAww8bG.js → razor-BOMpCo6z.js} +1 -1
  19. package/out/renderer/assets/{tsMode-C7m6Kr5E.js → tsMode-yAjlPR-D.js} +1 -1
  20. package/out/renderer/assets/{typescript-DhPw4VVg.js → typescript-BiJRCUcL.js} +1 -1
  21. package/out/renderer/assets/{xml-B0WLFJ2U.js → xml-D4PvYeQq.js} +1 -1
  22. package/out/renderer/assets/{yaml-BWyn9Wd7.js → yaml-BeHVkmnS.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/index.ts +7 -0
  26. package/src/main/orchestrator.ts +38 -9
  27. package/src/preload/index.ts +11 -0
  28. package/src/renderer/App.tsx +5 -0
  29. package/src/renderer/components/SpeakControls.tsx +235 -0
  30. package/src/renderer/components/VoiceInput.tsx +159 -3
  31. package/src/renderer/lib/localWhisper.ts +48 -4
  32. package/src/renderer/lib/speech.ts +299 -0
  33. package/src/renderer/styles.css +195 -0
@@ -8119,6 +8119,201 @@ html, body, #root {
8119
8119
  margin-bottom: 8px;
8120
8120
  }
8121
8121
 
8122
+ /* Mic source picker (small caret badge on the round mic + dropdown) */
8123
+ .voice-source-btn {
8124
+ position: absolute;
8125
+ bottom: -3px;
8126
+ right: -3px;
8127
+ width: 18px;
8128
+ height: 18px;
8129
+ border-radius: 50%;
8130
+ display: inline-flex;
8131
+ align-items: center;
8132
+ justify-content: center;
8133
+ padding: 0;
8134
+ font-size: 10px;
8135
+ line-height: 1;
8136
+ background: #2a2b3d;
8137
+ border: 1px solid #3b3d57;
8138
+ color: #a9b1d6;
8139
+ cursor: pointer;
8140
+ z-index: 60;
8141
+ }
8142
+ .voice-source-btn:hover {
8143
+ border-color: #7aa2f7;
8144
+ color: #c0caf5;
8145
+ }
8146
+ .voice-source-menu {
8147
+ position: absolute;
8148
+ bottom: 100%;
8149
+ right: 0;
8150
+ margin-bottom: 10px;
8151
+ min-width: 200px;
8152
+ max-width: 280px;
8153
+ padding: 4px;
8154
+ background: #16161e;
8155
+ border: 1px solid #3b3d57;
8156
+ border-radius: 8px;
8157
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
8158
+ z-index: 300;
8159
+ }
8160
+ .voice-source-head {
8161
+ font-size: 10px;
8162
+ text-transform: uppercase;
8163
+ letter-spacing: 0.04em;
8164
+ color: #565f89;
8165
+ padding: 4px 8px 6px;
8166
+ }
8167
+ .voice-source-item {
8168
+ display: flex;
8169
+ align-items: center;
8170
+ gap: 6px;
8171
+ width: 100%;
8172
+ padding: 5px 8px;
8173
+ border: none;
8174
+ border-radius: 5px;
8175
+ background: transparent;
8176
+ color: #a9b1d6;
8177
+ font-size: 12px;
8178
+ text-align: left;
8179
+ cursor: pointer;
8180
+ }
8181
+ .voice-source-item:hover { background: #1f2335; }
8182
+ .voice-source-item.active { color: #7aa2f7; }
8183
+ .voice-source-check {
8184
+ flex: 0 0 12px;
8185
+ width: 12px;
8186
+ color: #7aa2f7;
8187
+ font-size: 11px;
8188
+ }
8189
+ .voice-source-label {
8190
+ overflow: hidden;
8191
+ text-overflow: ellipsis;
8192
+ white-space: nowrap;
8193
+ }
8194
+ .voice-source-empty {
8195
+ padding: 6px 8px;
8196
+ font-size: 11px;
8197
+ color: #565f89;
8198
+ }
8199
+
8200
+ /* Spoken-replies titlebar control */
8201
+ .speak-controls {
8202
+ position: relative;
8203
+ display: inline-flex;
8204
+ align-items: center;
8205
+ }
8206
+ .speak-btn.active {
8207
+ color: #7aa2f7;
8208
+ border-color: #7aa2f7;
8209
+ }
8210
+ .speak-pct {
8211
+ font-size: 9px;
8212
+ margin-left: 3px;
8213
+ color: #e0af68;
8214
+ }
8215
+ .speak-caret {
8216
+ padding: 0 3px;
8217
+ font-size: 10px;
8218
+ min-width: 0;
8219
+ }
8220
+ .speak-stop {
8221
+ color: #f7768e;
8222
+ border-color: #f7768e;
8223
+ }
8224
+ .speak-stop:hover { background: #2d2030; }
8225
+ .speak-error {
8226
+ position: absolute;
8227
+ top: 100%;
8228
+ right: 0;
8229
+ margin-top: 6px;
8230
+ max-width: 340px;
8231
+ padding: 4px 9px;
8232
+ border-radius: 5px;
8233
+ font-size: 11px;
8234
+ line-height: 1.3;
8235
+ white-space: normal;
8236
+ word-break: break-word;
8237
+ background: #2d2030;
8238
+ color: #f7768e;
8239
+ border: 1px solid #f7768e;
8240
+ z-index: 60;
8241
+ }
8242
+ .speak-menu {
8243
+ position: absolute;
8244
+ top: 100%;
8245
+ right: 0;
8246
+ margin-top: 6px;
8247
+ min-width: 220px;
8248
+ max-width: 280px;
8249
+ padding: 6px;
8250
+ background: #16161e;
8251
+ border: 1px solid #3b3d57;
8252
+ border-radius: 8px;
8253
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
8254
+ z-index: 300;
8255
+ }
8256
+ .speak-menu-head {
8257
+ font-size: 10px;
8258
+ text-transform: uppercase;
8259
+ letter-spacing: 0.04em;
8260
+ color: #565f89;
8261
+ padding: 6px 6px 4px;
8262
+ }
8263
+ .speak-menu-item {
8264
+ display: flex;
8265
+ align-items: center;
8266
+ gap: 6px;
8267
+ width: 100%;
8268
+ padding: 5px 6px;
8269
+ border: none;
8270
+ border-radius: 5px;
8271
+ background: transparent;
8272
+ color: #a9b1d6;
8273
+ font-size: 12px;
8274
+ text-align: left;
8275
+ cursor: pointer;
8276
+ }
8277
+ .speak-menu-item:hover { background: #1f2335; }
8278
+ .speak-menu-item.active { color: #7aa2f7; }
8279
+ .speak-menu-check {
8280
+ flex: 0 0 12px;
8281
+ width: 12px;
8282
+ color: #7aa2f7;
8283
+ font-size: 11px;
8284
+ }
8285
+ .speak-select {
8286
+ width: 100%;
8287
+ margin: 2px 0 4px;
8288
+ padding: 4px 6px;
8289
+ background: #1f2335;
8290
+ color: #a9b1d6;
8291
+ border: 1px solid #3b3d57;
8292
+ border-radius: 5px;
8293
+ font-size: 12px;
8294
+ }
8295
+ .speak-rate {
8296
+ width: 100%;
8297
+ margin: 2px 0 6px;
8298
+ accent-color: #7aa2f7;
8299
+ }
8300
+ .speak-menu-row {
8301
+ display: flex;
8302
+ gap: 6px;
8303
+ padding: 2px 0 0;
8304
+ }
8305
+ .speak-menu-btn {
8306
+ flex: 1;
8307
+ padding: 5px 6px;
8308
+ background: #2a2b3d;
8309
+ color: #a9b1d6;
8310
+ border: 1px solid #3b3d57;
8311
+ border-radius: 5px;
8312
+ font-size: 12px;
8313
+ cursor: pointer;
8314
+ }
8315
+ .speak-menu-btn:hover { border-color: #7aa2f7; color: #c0caf5; }
8316
+
8122
8317
  /* Editor panel */
8123
8318
  .editor-panel {
8124
8319
  display: flex;
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-DhPw4VVg.js";
2
- import "./index-dRvutfbl.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-BiJRCUcL.js";
2
+ import "./index---H6cxNl.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-dRvutfbl.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-DqzMqkRk.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-DqzMqkRk.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index---H6cxNl.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-CHWJx_Tl.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-CHWJx_Tl.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index---H6cxNl.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-dRvutfbl.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index---H6cxNl.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index---H6cxNl.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index---H6cxNl.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index---H6cxNl.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-dRvutfbl.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index---H6cxNl.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index---H6cxNl.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index---H6cxNl.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index---H6cxNl.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -4,8 +4,8 @@
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
- <script type="module" crossorigin src="./assets/index-dRvutfbl.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-Cf-RsxoC.css">
7
+ <script type="module" crossorigin src="./assets/index---H6cxNl.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-B-iM7dFC.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
package/src/main/index.ts CHANGED
@@ -106,6 +106,7 @@ const orchestrator = new Orchestrator(
106
106
  onWorkerMessage: (message) => mainWindow?.webContents.send('worker:message', message),
107
107
  onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
108
108
  onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
109
+ onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
109
110
  onCwdChanged: () => {
110
111
  mainWindow?.webContents.send('app:cwdChanged')
111
112
  updateProjectBadge(orchestrator.cwd)
@@ -414,6 +415,12 @@ ipcMain.handle('logchat:set', (_event, enabled: boolean) => {
414
415
  return { enabled: orchestrator.logChatEnabled }
415
416
  })
416
417
 
418
+ ipcMain.handle('speak:get', () => ({ enabled: orchestrator.speakRepliesEnabled }))
419
+ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
420
+ orchestrator.setSpeakReplies(!!enabled)
421
+ return { enabled: orchestrator.speakRepliesEnabled }
422
+ })
423
+
417
424
  // ─── Tracking IPC ─────────────────────────────────
418
425
 
419
426
  ipcMain.handle('tracking:get', () => ({ active: orchestrator.isActiveTabTracking() }))
@@ -32,6 +32,9 @@ export interface SettingsData {
32
32
  ctlsurfBaseUrl?: string
33
33
  ctlsurfDataspacePageId?: string
34
34
  logChat?: boolean
35
+ // Speak agent replies aloud (Electron desktop only). Drives the transcript
36
+ // tailer the same way logChat does, independent of chat logging.
37
+ speakReplies?: boolean
35
38
  }
36
39
 
37
40
  export interface OrchestratorEvents {
@@ -44,6 +47,10 @@ export interface OrchestratorEvents {
44
47
  // Human-readable name of the connected ctlsurf project (folder), or null
45
48
  // when no project is connected. Optional — only the desktop header uses it.
46
49
  onProjectChanged?: (name: string | null) => void
50
+ // A clean assistant reply from the active agent's transcript, forwarded for
51
+ // text-to-speech. Optional and wired only by the Electron entry — the
52
+ // headless build leaves it unset, so replies are never spoken in a terminal.
53
+ onAgentMessage?: (text: string) => void
47
54
  }
48
55
 
49
56
  interface TabState {
@@ -148,16 +155,30 @@ export class Orchestrator {
148
155
  return !!this.settings.logChat
149
156
  }
150
157
 
158
+ get speakRepliesEnabled(): boolean {
159
+ return !!this.settings.speakReplies
160
+ }
161
+
151
162
  setLogChat(enabled: boolean): void {
152
163
  this.settings.logChat = enabled
153
164
  this.saveSettings()
154
165
  this.bridge.setLoggingEnabled(enabled)
155
- if (!enabled) {
156
- this.stopChatLogging()
157
- } else if (this.activeTabId) {
158
- const tab = this.tabs.get(this.activeTabId)
159
- if (tab) this.startChatLogging(tab.agent, tab.cwd)
160
- }
166
+ this.restartActiveChatLogging()
167
+ }
168
+
169
+ setSpeakReplies(enabled: boolean): void {
170
+ this.settings.speakReplies = enabled
171
+ this.saveSettings()
172
+ this.restartActiveChatLogging()
173
+ }
174
+
175
+ // Re-evaluate the transcript tailer for the active tab against the current
176
+ // logChat / speakReplies flags. Both setters funnel through here.
177
+ private restartActiveChatLogging(): void {
178
+ this.stopChatLogging()
179
+ if (!this.activeTabId) return
180
+ const tab = this.tabs.get(this.activeTabId)
181
+ if (tab) this.startChatLogging(tab.agent, tab.cwd)
161
182
  }
162
183
 
163
184
  // Agents with native session transcripts (Claude Code, Codex) get exact
@@ -165,16 +186,24 @@ export class Orchestrator {
165
186
  // the terminal screen-scraper bridge.
166
187
  private startChatLogging(agent: AgentConfig, cwd: string): void {
167
188
  this.stopChatLogging()
168
- if (!this.settings.logChat) return
189
+ if (!this.settings.logChat && !this.settings.speakReplies) return
169
190
 
170
191
  if (supportsTranscriptLogging(agent.id)) {
171
192
  this.transcriptTailer = new TranscriptTailer({
172
193
  agentId: agent.id,
173
194
  cwd,
174
- sink: (entry) => this.workerWs.sendChatLog(entry),
195
+ sink: (entry) => {
196
+ if (this.settings.logChat) this.workerWs.sendChatLog(entry)
197
+ // Only assistant replies are spoken; user_input entries are skipped.
198
+ if (this.settings.speakReplies && entry.type === 'terminal_output') {
199
+ this.events.onAgentMessage?.(entry.content)
200
+ }
201
+ },
175
202
  })
176
203
  this.transcriptTailer.start()
177
- } else {
204
+ } else if (this.settings.logChat) {
205
+ // The screen-scraper feeds ctlsurf chat logging only; TTS needs the clean
206
+ // transcript path, so non-transcript agents aren't spoken aloud.
178
207
  this.bridge.startSession()
179
208
  }
180
209
  }
@@ -109,6 +109,17 @@ 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
+
112
123
  // Filesystem
113
124
  readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
114
125
  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,9 @@ 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
59
63
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
60
64
  getWebviewInfo: () => Promise<{
61
65
  frontendUrl: string; pageUrl?: string; authenticated: boolean;
@@ -504,6 +508,7 @@ export default function App() {
504
508
  <line x1="8" y1="23" x2="16" y2="23" />
505
509
  </svg>
506
510
  </button>
511
+ <SpeakControls />
507
512
  <span className="titlebar-separator" />
508
513
  {agents.map(a => {
509
514
  const activeTab = tabs.find(t => t.id === activeTabId)