@phenx-inc/ctlsurf 0.8.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 (37) hide show
  1. package/out/headless/index.mjs +144 -1
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +144 -0
  4. package/out/preload/index.js +2 -0
  5. package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BdYp0fp9.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-Cya-9zr-.js} +1 -1
  7. package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-EJs9W5Ub.js} +1 -1
  8. package/out/renderer/assets/{html-D8xFiRmI.js → html-Ex4qy2lh.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-CCm0aJ6C.js} +3 -3
  10. package/out/renderer/assets/{index---H6cxNl.js → index-Cj7K9-Ba.js} +265 -242
  11. package/out/renderer/assets/{index-B-iM7dFC.css → index-nvsNYP5c.css} +34 -0
  12. package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-B2niCouI.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BgCUHaFo.js} +3 -3
  14. package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-DOQmSo4G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
  16. package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-DuKdGNKE.js} +1 -1
  17. package/out/renderer/assets/{python-DKu7rNbs.js → python-BUa4XFG-.js} +1 -1
  18. package/out/renderer/assets/{razor-BOMpCo6z.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-yAjlPR-D.js → tsMode-8FLRnobw.js} +1 -1
  21. package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-rdwAjCi4.js} +1 -1
  22. package/out/renderer/assets/{xml-D4PvYeQq.js → xml-HOzT59Qg.js} +1 -1
  23. package/out/renderer/assets/{yaml-BeHVkmnS.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 +2 -0
  28. package/src/main/orchestrator.ts +13 -1
  29. package/src/main/transcripts.ts +134 -0
  30. package/src/preload/index.ts +3 -0
  31. package/src/renderer/App.tsx +25 -11
  32. package/src/renderer/components/FloatingMic.tsx +48 -0
  33. package/src/renderer/components/VoiceInput.tsx +36 -149
  34. package/src/renderer/index.html +62 -0
  35. package/src/renderer/lib/localWhisper.ts +46 -2
  36. package/src/renderer/lib/speech.ts +18 -0
  37. package/src/renderer/styles.css +34 -0
@@ -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---H6cxNl.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-Cj7K9-Ba.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---H6cxNl.js";
1
+ import { l as languages } from "./index-Cj7K9-Ba.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---H6cxNl.js";
1
+ import { l as languages } from "./index-Cj7K9-Ba.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index---H6cxNl.js";
1
+ import { l as languages } from "./index-Cj7K9-Ba.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -4,10 +4,72 @@
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---H6cxNl.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-B-iM7dFC.css">
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>
52
+ <script type="module" crossorigin src="./assets/index-Cj7K9-Ba.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-nvsNYP5c.css">
9
54
  </head>
10
55
  <body>
56
+ <div id="boot-splash">
57
+ <div class="boot-spinner"></div>
58
+ <div class="boot-splash-title">ctlsurf</div>
59
+ <div class="boot-splash-label">Starting up…</div>
60
+ </div>
11
61
  <div id="root"></div>
62
+ <script>
63
+ // Fades out and removes the boot splash. Called by the React app once its
64
+ // startup IPC has settled (see App.tsx). The timeout is a safety net so a
65
+ // hung startup never leaves the user stuck behind the splash forever.
66
+ window.__dismissSplash = function () {
67
+ var el = document.getElementById('boot-splash')
68
+ if (!el) return
69
+ el.classList.add('boot-splash-hidden')
70
+ setTimeout(function () { el.remove() }, 350)
71
+ }
72
+ setTimeout(function () { window.__dismissSplash() }, 10000)
73
+ </script>
12
74
  </body>
13
75
  </html>
@@ -0,0 +1,305 @@
1
+ // repro-toggle.ts
2
+ import fs2 from "fs";
3
+ import os2 from "os";
4
+ import path2 from "path";
5
+
6
+ // src/main/transcripts.ts
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+ var POLL_INTERVAL_MS = 1e3;
11
+ var DISCOVERY_SLACK_MS = 1e4;
12
+ var READ_CHUNK_BYTES = 64 * 1024;
13
+ var MAX_ENTRY_CHARS = 2e4;
14
+ var TranscriptTailer = class {
15
+ agentId;
16
+ cwd;
17
+ sink;
18
+ claudeProjectsDir;
19
+ codexSessionsDir;
20
+ files = /* @__PURE__ */ new Map();
21
+ pollTimer = null;
22
+ sinceMs = 0;
23
+ constructor(options) {
24
+ this.agentId = options.agentId;
25
+ this.cwd = stripTrailingSep(options.cwd);
26
+ this.sink = options.sink;
27
+ this.claudeProjectsDir = options.claudeProjectsDir || path.join(os.homedir(), ".claude", "projects");
28
+ this.codexSessionsDir = options.codexSessionsDir || path.join(os.homedir(), ".codex", "sessions");
29
+ }
30
+ start() {
31
+ if (this.pollTimer) return;
32
+ this.sinceMs = Date.now();
33
+ this.files.clear();
34
+ this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
35
+ console.log(`[transcripts] Tailing ${this.agentId} transcripts for ${this.cwd}`);
36
+ }
37
+ stop() {
38
+ if (!this.pollTimer) return;
39
+ clearInterval(this.pollTimer);
40
+ this.pollTimer = null;
41
+ this.poll();
42
+ this.files.clear();
43
+ console.log("[transcripts] Stopped");
44
+ }
45
+ poll() {
46
+ try {
47
+ this.discover();
48
+ for (const [filePath, tail] of this.files) {
49
+ if (!tail.excluded) this.drainFile(filePath, tail);
50
+ }
51
+ } catch (err) {
52
+ console.error("[transcripts] Poll error:", err);
53
+ }
54
+ }
55
+ // ─── Discovery ──────────────────────────────────
56
+ /**
57
+ * Track every transcript file with recent activity, not just the first
58
+ * match: /clear (Claude) or /new (Codex) starts a new session file in the
59
+ * middle of one PTY run, and tailing all active candidates handles the
60
+ * switch without special cases. Old idle files never match (stale mtime).
61
+ */
62
+ discover() {
63
+ const dirs = this.agentId === "claude" ? [this.claudeProjectsDirForCwd()] : this.codexDateDirs();
64
+ for (const dir of dirs) {
65
+ let names;
66
+ try {
67
+ names = fs.readdirSync(dir);
68
+ } catch {
69
+ continue;
70
+ }
71
+ for (const name of names) {
72
+ if (!name.endsWith(".jsonl")) continue;
73
+ if (this.agentId === "codex" && !name.startsWith("rollout-")) continue;
74
+ const filePath = path.join(dir, name);
75
+ if (this.files.has(filePath)) continue;
76
+ try {
77
+ const stat = fs.statSync(filePath);
78
+ if (stat.mtimeMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
79
+ this.files.set(filePath, { offset: 0, remainder: "", excluded: false });
80
+ }
81
+ } catch {
82
+ }
83
+ }
84
+ }
85
+ }
86
+ claudeProjectsDirForCwd() {
87
+ const slug = this.cwd.replace(/[^a-zA-Z0-9]/g, "-");
88
+ return path.join(this.claudeProjectsDir, slug);
89
+ }
90
+ codexDateDirs() {
91
+ const dirs = /* @__PURE__ */ new Set();
92
+ for (const ms of [this.sinceMs, Date.now()]) {
93
+ const d = new Date(ms);
94
+ const yyyy = String(d.getFullYear());
95
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
96
+ const dd = String(d.getDate()).padStart(2, "0");
97
+ dirs.add(path.join(this.codexSessionsDir, yyyy, mm, dd));
98
+ }
99
+ return [...dirs];
100
+ }
101
+ // ─── Tailing ────────────────────────────────────
102
+ drainFile(filePath, tail) {
103
+ let size;
104
+ try {
105
+ size = fs.statSync(filePath).size;
106
+ } catch {
107
+ return;
108
+ }
109
+ if (size <= tail.offset) return;
110
+ let fd;
111
+ try {
112
+ fd = fs.openSync(filePath, "r");
113
+ } catch {
114
+ return;
115
+ }
116
+ try {
117
+ const buf = Buffer.alloc(READ_CHUNK_BYTES);
118
+ while (tail.offset < size && !tail.excluded) {
119
+ const bytesRead = fs.readSync(fd, buf, 0, READ_CHUNK_BYTES, tail.offset);
120
+ if (bytesRead <= 0) break;
121
+ tail.offset += bytesRead;
122
+ tail.remainder += buf.toString("utf-8", 0, bytesRead);
123
+ const lines = tail.remainder.split("\n");
124
+ tail.remainder = lines.pop() || "";
125
+ for (const line2 of lines) {
126
+ this.handleLine(line2, tail);
127
+ if (tail.excluded) break;
128
+ }
129
+ }
130
+ } catch (err) {
131
+ console.error(`[transcripts] Read error for ${filePath}:`, err);
132
+ } finally {
133
+ try {
134
+ fs.closeSync(fd);
135
+ } catch {
136
+ }
137
+ }
138
+ }
139
+ handleLine(line2, tail) {
140
+ const trimmed = line2.trim();
141
+ if (!trimmed) return;
142
+ let obj;
143
+ try {
144
+ obj = JSON.parse(trimmed);
145
+ } catch {
146
+ return;
147
+ }
148
+ const entry = this.agentId === "claude" ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail);
149
+ if (!entry) return;
150
+ const ms = Date.parse(entry.ts);
151
+ if (Number.isFinite(ms) && ms < this.sinceMs - DISCOVERY_SLACK_MS) return;
152
+ this.sink({ ...entry, content: capLength(entry.content) });
153
+ }
154
+ // ─── Claude Code format ─────────────────────────
155
+ parseClaudeLine(obj) {
156
+ if (!obj || typeof obj !== "object") return null;
157
+ if (obj.isMeta) return null;
158
+ if (obj.type !== "user" && obj.type !== "assistant") return null;
159
+ if (typeof obj.cwd === "string" && stripTrailingSep(obj.cwd) !== this.cwd) return null;
160
+ if (obj.isSidechain) return null;
161
+ const message = obj.message;
162
+ if (!message) return null;
163
+ const text = extractClaudeText(message.content, obj.type === "user");
164
+ if (!text) return null;
165
+ return {
166
+ ts: typeof obj.timestamp === "string" ? obj.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
167
+ type: obj.type === "user" ? "user_input" : "terminal_output",
168
+ content: text
169
+ };
170
+ }
171
+ // ─── Codex CLI format ───────────────────────────
172
+ parseCodexLine(obj, tail) {
173
+ if (!obj || typeof obj !== "object") return null;
174
+ const payload = obj.payload;
175
+ if (obj.type === "session_meta") {
176
+ const metaCwd = payload?.cwd;
177
+ if (typeof metaCwd === "string" && stripTrailingSep(metaCwd) !== this.cwd) {
178
+ tail.excluded = true;
179
+ }
180
+ return null;
181
+ }
182
+ if (obj.type !== "event_msg" || !payload || typeof payload !== "object") return null;
183
+ let type;
184
+ if (payload.type === "user_message") {
185
+ type = "user_input";
186
+ } else if (payload.type === "agent_message") {
187
+ type = "terminal_output";
188
+ } else {
189
+ return null;
190
+ }
191
+ const text = typeof payload.message === "string" ? payload.message.trim() : "";
192
+ if (!text || isCodexNoise(text)) return null;
193
+ return {
194
+ ts: typeof obj.timestamp === "string" ? obj.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
195
+ type,
196
+ content: text
197
+ };
198
+ }
199
+ };
200
+ var CLAUDE_NOISE_PREFIXES = [
201
+ "<local-command-caveat>",
202
+ "<command-name>",
203
+ "<command-message>",
204
+ "<local-command-stdout>",
205
+ "<bash-input>",
206
+ "<bash-stdout>",
207
+ "<bash-stderr>",
208
+ "<system-reminder>",
209
+ "<task-notification>",
210
+ "caveat: the messages below",
211
+ "[request interrupted"
212
+ ];
213
+ var CODEX_NOISE_PREFIXES = [
214
+ "<environment_context>",
215
+ "<user_instructions>",
216
+ "<permissions instructions>",
217
+ "<turn_aborted>"
218
+ ];
219
+ function isClaudeNoise(text) {
220
+ const lower = text.trimStart().toLowerCase();
221
+ return CLAUDE_NOISE_PREFIXES.some((p) => lower.startsWith(p));
222
+ }
223
+ function isCodexNoise(text) {
224
+ const lower = text.trimStart().toLowerCase();
225
+ return CODEX_NOISE_PREFIXES.some((p) => lower.startsWith(p));
226
+ }
227
+ function extractClaudeText(content, isUser) {
228
+ if (typeof content === "string") {
229
+ const text = content.trim();
230
+ return text && !isClaudeNoise(text) ? text : "";
231
+ }
232
+ if (!Array.isArray(content)) return "";
233
+ if (isUser && content.some((b) => b?.type === "tool_result")) return "";
234
+ const parts = [];
235
+ for (const block of content) {
236
+ if (block?.type !== "text" || typeof block.text !== "string") continue;
237
+ const text = block.text.trim();
238
+ if (text && !isClaudeNoise(text)) parts.push(text);
239
+ }
240
+ return parts.join("\n\n");
241
+ }
242
+ function stripTrailingSep(p) {
243
+ return p.length > 1 ? p.replace(/[/\\]+$/, "") : p;
244
+ }
245
+ function capLength(str) {
246
+ if (str.length <= MAX_ENTRY_CHARS) return str;
247
+ return str.slice(0, MAX_ENTRY_CHARS) + `\u2026 [truncated, ${str.length} total chars]`;
248
+ }
249
+
250
+ // repro-toggle.ts
251
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
252
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
253
+ var line = (o) => JSON.stringify(o) + "\n";
254
+ var tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), "toggle-test-"));
255
+ var claudeDir = path2.join(tmp, "claude-projects");
256
+ var cwd = "/Users/test/projects/my_app";
257
+ var projDir = path2.join(claudeDir, "-Users-test-projects-my-app");
258
+ fs2.mkdirSync(projDir, { recursive: true });
259
+ var sessionFile = path2.join(projDir, "session-1.jsonl");
260
+ var era = "enable#1";
261
+ var emitted = [];
262
+ var sink = (e) => emitted.push({ era, type: e.type, content: e.content });
263
+ function makeTailer() {
264
+ return new TranscriptTailer({ agentId: "claude", cwd, sink, claudeProjectsDir: claudeDir });
265
+ }
266
+ async function main() {
267
+ let tailer = makeTailer();
268
+ tailer.start();
269
+ await sleep(200);
270
+ fs2.appendFileSync(sessionFile, line({
271
+ type: "assistant",
272
+ cwd,
273
+ timestamp: now(),
274
+ message: { role: "assistant", content: [{ type: "text", text: "Reply ONE \u2014 while first enabled." }] }
275
+ }));
276
+ await sleep(1500);
277
+ tailer.stop();
278
+ era = "between";
279
+ await sleep(800);
280
+ era = "enable#2";
281
+ tailer = makeTailer();
282
+ tailer.start();
283
+ await sleep(200);
284
+ fs2.appendFileSync(sessionFile, line({
285
+ type: "assistant",
286
+ cwd,
287
+ timestamp: now(),
288
+ message: { role: "assistant", content: [{ type: "text", text: "Reply TWO \u2014 after off then on." }] }
289
+ }));
290
+ await sleep(1500);
291
+ tailer.stop();
292
+ console.log("\nEmitted entries:");
293
+ for (const e of emitted) console.log(` [${e.era}] ${e.type}: ${JSON.stringify(e.content)}`);
294
+ const two = emitted.filter((e) => e.content.includes("Reply TWO"));
295
+ console.log("\n--- VERDICT ---");
296
+ console.log(`Reply ONE emitted while enabled#1: ${emitted.some((e) => e.era === "enable#1" && e.content.includes("Reply ONE"))}`);
297
+ console.log(`Reply TWO emitted after off\u2192on: ${two.length > 0} (count=${two.length}, eras=${JSON.stringify(two.map((e) => e.era))})`);
298
+ if (two.length === 0) {
299
+ console.log(">> BUG REPRODUCED: new reply after toggle is NEVER forwarded by the tailer.");
300
+ } else {
301
+ console.log(">> Tailer re-emits fine \u2014 bug is NOT in the main-process layer.");
302
+ }
303
+ fs2.rmSync(tmp, { recursive: true, force: true });
304
+ }
305
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.8.0",
3
+ "version": "0.9.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
@@ -420,6 +420,8 @@ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
420
420
  orchestrator.setSpeakReplies(!!enabled)
421
421
  return { enabled: orchestrator.speakRepliesEnabled }
422
422
  })
423
+ // Latest assistant reply from the active tab's transcript, for on-demand TTS.
424
+ ipcMain.handle('agent:getLastReply', () => orchestrator.getLastReply())
423
425
 
424
426
  // ─── Tracking IPC ─────────────────────────────────
425
427
 
@@ -6,7 +6,7 @@ import { PtyManager } from './pty'
6
6
  import { AgentConfig, isCodingAgent } from './agents'
7
7
  import { CtlsurfApi } from './ctlsurfApi'
8
8
  import { ConversationBridge } from './bridge'
9
- import { TranscriptTailer, supportsTranscriptLogging } from './transcripts'
9
+ import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
10
10
  import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
11
11
  import { TimeTracker } from './timeTracker'
12
12
  import { TicketStore, type TicketInput } from './ticketStore'
@@ -159,6 +159,18 @@ export class Orchestrator {
159
159
  return !!this.settings.speakReplies
160
160
  }
161
161
 
162
+ // Latest assistant reply from a tab's transcript, for on-demand "read aloud".
163
+ // Defaults to the active tab. Returns null when the tab's agent has no native
164
+ // transcript (only Claude Code / Codex do) or nothing has been said yet. Reads
165
+ // the transcript directly, so it works regardless of the speakReplies setting.
166
+ getLastReply(tabId?: string): string | null {
167
+ const id = tabId || this.activeTabId
168
+ if (!id) return null
169
+ const tab = this.tabs.get(id)
170
+ if (!tab || !supportsTranscriptLogging(tab.agent.id)) return null
171
+ return readLatestAssistantReply({ agentId: tab.agent.id, cwd: tab.cwd })
172
+ }
173
+
162
174
  setLogChat(enabled: boolean): void {
163
175
  this.settings.logChat = enabled
164
176
  this.saveSettings()
@@ -339,3 +339,137 @@ function capLength(str: string): string {
339
339
  if (str.length <= MAX_ENTRY_CHARS) return str
340
340
  return str.slice(0, MAX_ENTRY_CHARS) + `… [truncated, ${str.length} total chars]`
341
341
  }
342
+
343
+ // ─── One-shot latest-reply read (on-demand "read aloud") ──────────
344
+ //
345
+ // Unlike the tailer, this does a single synchronous scan to return the most
346
+ // recent assistant reply for an agent + cwd, independent of whether the tailer
347
+ // is running. Used by the "read last response aloud" button so it works even
348
+ // when the speak-replies setting is off. Returns null when there's no
349
+ // transcript or no assistant message yet.
350
+ export function readLatestAssistantReply(opts: {
351
+ agentId: TranscriptAgentId
352
+ cwd: string
353
+ claudeProjectsDir?: string
354
+ codexSessionsDir?: string
355
+ }): string | null {
356
+ try {
357
+ const cwd = stripTrailingSep(opts.cwd)
358
+ if (opts.agentId === 'claude') {
359
+ const file = mostRecentClaudeFile(cwd, opts.claudeProjectsDir)
360
+ if (!file) return null
361
+ const text = lastClaudeAssistant(file, cwd)
362
+ return text ? capLength(text) : null
363
+ }
364
+ // Codex: the active session is the most recently written rollout for this cwd.
365
+ const codexRoot = opts.codexSessionsDir || path.join(os.homedir(), '.codex', 'sessions')
366
+ for (const file of recentCodexRollouts(codexRoot)) {
367
+ const text = lastCodexAgentMessage(file, cwd)
368
+ if (text) return capLength(text)
369
+ }
370
+ return null
371
+ } catch {
372
+ return null
373
+ }
374
+ }
375
+
376
+ // Whole-file read is fine here: transcripts are at most a few MB and we only do
377
+ // this on an explicit button click, not on a poll.
378
+ function readLines(file: string): string[] {
379
+ try {
380
+ return fs.readFileSync(file, 'utf-8').split('\n')
381
+ } catch {
382
+ return []
383
+ }
384
+ }
385
+
386
+ function mostRecentClaudeFile(cwd: string, root?: string): string | null {
387
+ const projectsRoot = root || path.join(os.homedir(), '.claude', 'projects')
388
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, '-')
389
+ const dir = path.join(projectsRoot, slug)
390
+ let names: string[]
391
+ try {
392
+ names = fs.readdirSync(dir)
393
+ } catch {
394
+ return null
395
+ }
396
+ let best: string | null = null
397
+ let bestMs = -Infinity
398
+ for (const name of names) {
399
+ if (!name.endsWith('.jsonl')) continue
400
+ const fp = path.join(dir, name)
401
+ try {
402
+ const ms = fs.statSync(fp).mtimeMs
403
+ if (ms > bestMs) { bestMs = ms; best = fp }
404
+ } catch { /* ignore */ }
405
+ }
406
+ return best
407
+ }
408
+
409
+ function recentCodexRollouts(root: string): string[] {
410
+ // Today and yesterday's session dirs (covers sessions spanning midnight),
411
+ // newest file first.
412
+ const dirs = new Set<string>()
413
+ const now = Date.now()
414
+ for (const ms of [now, now - 24 * 60 * 60 * 1000]) {
415
+ const d = new Date(ms)
416
+ const yyyy = String(d.getFullYear())
417
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
418
+ const dd = String(d.getDate()).padStart(2, '0')
419
+ dirs.add(path.join(root, yyyy, mm, dd))
420
+ }
421
+ const files: Array<{ fp: string; ms: number }> = []
422
+ for (const dir of dirs) {
423
+ let names: string[]
424
+ try { names = fs.readdirSync(dir) } catch { continue }
425
+ for (const name of names) {
426
+ if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue
427
+ const fp = path.join(dir, name)
428
+ try { files.push({ fp, ms: fs.statSync(fp).mtimeMs }) } catch { /* ignore */ }
429
+ }
430
+ }
431
+ return files.sort((a, b) => b.ms - a.ms).map((f) => f.fp)
432
+ }
433
+
434
+ function lastClaudeAssistant(file: string, cwd: string): string | null {
435
+ const lines = readLines(file)
436
+ for (let i = lines.length - 1; i >= 0; i--) {
437
+ const trimmed = lines[i].trim()
438
+ if (!trimmed) continue
439
+ let obj: any
440
+ try { obj = JSON.parse(trimmed) } catch { continue }
441
+ if (!obj || typeof obj !== 'object') continue
442
+ if (obj.isMeta || obj.isSidechain || obj.type !== 'assistant') continue
443
+ if (typeof obj.cwd === 'string' && stripTrailingSep(obj.cwd) !== cwd) continue
444
+ const text = extractClaudeText(obj.message?.content, false)
445
+ if (text) return text
446
+ }
447
+ return null
448
+ }
449
+
450
+ function lastCodexAgentMessage(file: string, cwd: string): string | null {
451
+ const lines = readLines(file)
452
+ // The date dir is shared across projects, so confirm this rollout belongs to
453
+ // our cwd via its session_meta before trusting any of its messages.
454
+ for (const line of lines) {
455
+ const trimmed = line.trim()
456
+ if (!trimmed) continue
457
+ let obj: any
458
+ try { obj = JSON.parse(trimmed) } catch { continue }
459
+ if (obj?.type === 'session_meta') {
460
+ const metaCwd = obj.payload?.cwd
461
+ if (typeof metaCwd === 'string' && stripTrailingSep(metaCwd) !== cwd) return null
462
+ break
463
+ }
464
+ }
465
+ for (let i = lines.length - 1; i >= 0; i--) {
466
+ const trimmed = lines[i].trim()
467
+ if (!trimmed) continue
468
+ let obj: any
469
+ try { obj = JSON.parse(trimmed) } catch { continue }
470
+ if (obj?.type !== 'event_msg' || obj.payload?.type !== 'agent_message') continue
471
+ const text = typeof obj.payload.message === 'string' ? obj.payload.message.trim() : ''
472
+ if (text && !isCodexNoise(text)) return text
473
+ }
474
+ return null
475
+ }
@@ -119,6 +119,9 @@ const api = {
119
119
  ipcRenderer.on('agent:message', listener)
120
120
  return () => ipcRenderer.removeListener('agent:message', listener)
121
121
  },
122
+ // Latest assistant reply from the active tab's transcript (null if none).
123
+ getLastReply: (): Promise<string | null> =>
124
+ ipcRenderer.invoke('agent:getLastReply'),
122
125
 
123
126
  // Filesystem
124
127
  readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
@@ -60,6 +60,7 @@ declare global {
60
60
  getSpeakReplies: () => Promise<{ enabled: boolean }>
61
61
  setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
62
62
  onAgentMessage: (callback: (text: string) => void) => () => void
63
+ getLastReply: () => Promise<string | null>
63
64
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
64
65
  getWebviewInfo: () => Promise<{
65
66
  frontendUrl: string; pageUrl?: string; authenticated: boolean;
@@ -155,16 +156,22 @@ export default function App() {
155
156
 
156
157
  useEffect(() => {
157
158
  async function init() {
158
- const list = await window.worker.listAgents()
159
- setAgents(list)
160
- const status = await window.worker.getWorkerStatus()
161
- setWsStatus(status)
162
- // Load initial cwd for the picker
163
- const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath())
164
- setCwd(initialCwd)
165
- cwdRef.current = initialCwd
166
- // Connected ctlsurf project name for the header (null until a worker registers)
167
- 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
+ }
168
175
  }
169
176
  init()
170
177
  }, [])
@@ -225,8 +232,15 @@ export default function App() {
225
232
  const handleVoiceTranscript = useCallback((text: string) => {
226
233
  const trimmed = text.trim()
227
234
  if (!trimmed) return
228
- 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)
229
242
  focusTerminal(activeTabId)
243
+ setTimeout(() => window.worker.writePty(activeTabId, '\r'), 80)
230
244
  }, [activeTabId])
231
245
 
232
246
  const cwdRef = useRef<string | null>(null)