@phenx-inc/ctlsurf 0.6.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 (34) 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-DbMmcl1h.js → cssMode-BQN8v2ok.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CvaHiy92.js → freemarker2-DbxGYYVp.js} +1 -1
  7. package/out/renderer/assets/{handlebars-D58lUIOu.js → handlebars-3auU1CAd.js} +1 -1
  8. package/out/renderer/assets/{html-D1h1aJbM.js → html-D8xFiRmI.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-BdkAp9qr.js → htmlMode-M3MApZ4n.js} +3 -3
  10. package/out/renderer/assets/{index-B60JU1yI.js → index---H6cxNl.js} +854 -38
  11. package/out/renderer/assets/{index-DJFYmHjz.css → index-B-iM7dFC.css} +269 -0
  12. package/out/renderer/assets/{javascript-CXqZcnvb.js → javascript-BO_ViZM5.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BuVr-eSl.js → jsonMode-CKp2zvZu.js} +3 -3
  14. package/out/renderer/assets/{liquid-LKu0Wd0B.js → liquid-C1eHcrht.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Cjr_4HGs.js → lspLanguageFeatures-CHWJx_Tl.js} +1 -1
  16. package/out/renderer/assets/{mdx-Bl84ILla.js → mdx-Qqdtk7fL.js} +1 -1
  17. package/out/renderer/assets/{python-0sFd9G1k.js → python-DKu7rNbs.js} +1 -1
  18. package/out/renderer/assets/{razor-Cqcu1rLJ.js → razor-BOMpCo6z.js} +1 -1
  19. package/out/renderer/assets/{tsMode-CYd3NUkW.js → tsMode-yAjlPR-D.js} +1 -1
  20. package/out/renderer/assets/{typescript-rkc9lhpi.js → typescript-BiJRCUcL.js} +1 -1
  21. package/out/renderer/assets/{xml-EsHEUps1.js → xml-D4PvYeQq.js} +1 -1
  22. package/out/renderer/assets/{yaml-B9-nQ_s2.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 +39 -6
  29. package/src/renderer/components/FloatingMic.tsx +128 -0
  30. package/src/renderer/components/SpeakControls.tsx +235 -0
  31. package/src/renderer/components/VoiceInput.tsx +170 -6
  32. package/src/renderer/lib/localWhisper.ts +48 -4
  33. package/src/renderer/lib/speech.ts +299 -0
  34. package/src/renderer/styles.css +269 -0
@@ -8045,6 +8045,275 @@ html, body, #root {
8045
8045
  white-space: normal;
8046
8046
  }
8047
8047
 
8048
+ /* Floating push-to-talk mic (draggable, dismissable FAB) */
8049
+ .floating-mic {
8050
+ position: fixed;
8051
+ z-index: 200;
8052
+ display: flex;
8053
+ flex-direction: column;
8054
+ align-items: center;
8055
+ gap: 4px;
8056
+ padding: 4px 4px 6px;
8057
+ background: #16161e;
8058
+ border: 1px solid #3b3d57;
8059
+ border-radius: 12px;
8060
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
8061
+ user-select: none;
8062
+ -webkit-user-select: none;
8063
+ }
8064
+ .floating-mic-handle {
8065
+ display: flex;
8066
+ align-items: center;
8067
+ justify-content: space-between;
8068
+ width: 100%;
8069
+ cursor: grab;
8070
+ touch-action: none;
8071
+ }
8072
+ .floating-mic-handle:active { cursor: grabbing; }
8073
+ .floating-mic-grip {
8074
+ color: #565f89;
8075
+ font-size: 12px;
8076
+ line-height: 1;
8077
+ padding: 0 2px;
8078
+ }
8079
+ .floating-mic-hide {
8080
+ background: transparent;
8081
+ border: none;
8082
+ color: #565f89;
8083
+ font-size: 15px;
8084
+ line-height: 1;
8085
+ cursor: pointer;
8086
+ padding: 0 2px;
8087
+ }
8088
+ .floating-mic-hide:hover { color: #f7768e; }
8089
+
8090
+ .voice-btn-floating {
8091
+ width: 48px;
8092
+ height: 48px;
8093
+ border-radius: 50%;
8094
+ display: inline-flex;
8095
+ align-items: center;
8096
+ justify-content: center;
8097
+ background: #2a2b3d;
8098
+ border: 1px solid #3b3d57;
8099
+ color: #a9b1d6;
8100
+ cursor: pointer;
8101
+ position: relative;
8102
+ transition: all 0.15s;
8103
+ }
8104
+ .voice-btn-floating:hover { border-color: #565f89; }
8105
+ .voice-btn-floating .voice-icon { font-size: 22px; }
8106
+ .voice-btn-floating .voice-dot {
8107
+ position: absolute;
8108
+ top: 5px;
8109
+ right: 5px;
8110
+ }
8111
+ /* Floating chip sits above the round button rather than below it. */
8112
+ .voice-chip-floating {
8113
+ top: auto;
8114
+ bottom: 100%;
8115
+ right: auto;
8116
+ left: 50%;
8117
+ transform: translateX(-50%);
8118
+ margin-top: 0;
8119
+ margin-bottom: 8px;
8120
+ }
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
+
8048
8317
  /* Editor panel */
8049
8318
  .editor-panel {
8050
8319
  display: flex;
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-rkc9lhpi.js";
2
- import "./index-B60JU1yI.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-B60JU1yI.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-Cjr_4HGs.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Cjr_4HGs.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.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-B60JU1yI.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-DJFYmHjz.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.6.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
- import { VoiceInput } from './components/VoiceInput'
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;
@@ -133,6 +137,14 @@ export default function App() {
133
137
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
134
138
  const [trackingActive, setTrackingActive] = useState(false)
135
139
  const [showTicketPanel, setShowTicketPanel] = useState(false)
140
+ // Draggable on-canvas push-to-talk mic; visibility persists across launches.
141
+ const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
142
+ try { return localStorage.getItem('ctlsurf.floatingMicVisible') !== 'false' } catch { return true }
143
+ })
144
+ const setFloatingMicVisible = useCallback((v: boolean) => {
145
+ setShowFloatingMic(v)
146
+ try { localStorage.setItem('ctlsurf.floatingMicVisible', String(v)) } catch { /* ignore */ }
147
+ }, [])
136
148
 
137
149
  // Agent picker state: which tab is being configured (null = initial picker for first tab)
138
150
  const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
@@ -207,13 +219,13 @@ export default function App() {
207
219
  }
208
220
  }, [trackingActive])
209
221
 
210
- // Voice typing: inject the transcribed text into the active terminal exactly
211
- // as if it were typed (no auto-submit), then refocus so the user can press
212
- // Enter to send it.
222
+ // Voice typing: inject the transcribed text into the active terminal as if it
223
+ // were typed, then send a carriage return to submit it (same as pressing Enter
224
+ // after typing), and refocus the terminal.
213
225
  const handleVoiceTranscript = useCallback((text: string) => {
214
226
  const trimmed = text.trim()
215
227
  if (!trimmed) return
216
- window.worker.writePty(activeTabId, trimmed)
228
+ window.worker.writePty(activeTabId, trimmed + '\r')
217
229
  focusTerminal(activeTabId)
218
230
  }, [activeTabId])
219
231
 
@@ -482,7 +494,21 @@ export default function App() {
482
494
  </svg>
483
495
  <span>Tickets</span>
484
496
  </button>
485
- <VoiceInput onTranscript={handleVoiceTranscript} />
497
+ <button
498
+ className={`titlebar-btn titlebar-icon-btn ${showFloatingMic ? 'active' : ''}`}
499
+ onClick={() => setFloatingMicVisible(!showFloatingMic)}
500
+ title={showFloatingMic ? 'Hide floating mic' : 'Show floating mic'}
501
+ aria-label="Toggle floating mic"
502
+ >
503
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor"
504
+ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
505
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
506
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
507
+ <line x1="12" y1="19" x2="12" y2="23" />
508
+ <line x1="8" y1="23" x2="16" y2="23" />
509
+ </svg>
510
+ </button>
511
+ <SpeakControls />
486
512
  <span className="titlebar-separator" />
487
513
  {agents.map(a => {
488
514
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -544,6 +570,13 @@ export default function App() {
544
570
  }}
545
571
  />
546
572
  )}
573
+
574
+ {showFloatingMic && (
575
+ <FloatingMic
576
+ onTranscript={handleVoiceTranscript}
577
+ onHide={() => setFloatingMicVisible(false)}
578
+ />
579
+ )}
547
580
  </div>
548
581
  )
549
582
  }