@phenx-inc/ctlsurf 0.3.4 → 0.3.5

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 (35) hide show
  1. package/electron-vite.config.ts +2 -0
  2. package/out/headless/index.mjs +5670 -11
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +5950 -196
  5. package/out/preload/index.js +7 -0
  6. package/out/renderer/assets/{cssMode-BW-SuYuP.js → cssMode-DQW-brNd.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-2YWYzawi.js → freemarker2-DxgOckH2.js} +1 -1
  8. package/out/renderer/assets/{handlebars-EwtUQRsf.js → handlebars-BX1Wpk_3.js} +1 -1
  9. package/out/renderer/assets/{html-BNZkIDb9.js → html-t-KXioI0.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-C2dZKrOy.js → htmlMode-Dya7iUjr.js} +3 -3
  11. package/out/renderer/assets/{index-CrTu3Z4M.css → index-D6JBcQ20.css} +13 -0
  12. package/out/renderer/assets/{index-Bm_rbVP-.js → index-DNqZidnO.js} +49 -25
  13. package/out/renderer/assets/{javascript-busdVZMv.js → javascript-DZzW2adn.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BaVI6jAw.js → jsonMode-D_Wv7XH8.js} +3 -3
  15. package/out/renderer/assets/{liquid-DG08un1Q.js → liquid-BJAHAm2T.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-peGVtLxi.js → lspLanguageFeatures-BgMd-KJk.js} +1 -1
  17. package/out/renderer/assets/{mdx-DogBhUxZ.js → mdx-B6Zod3ry.js} +1 -1
  18. package/out/renderer/assets/{python-Bf-INYXh.js → python-Cgt13-KH.js} +1 -1
  19. package/out/renderer/assets/{razor-DLrZ2hsF.js → razor-BcwFJGYS.js} +1 -1
  20. package/out/renderer/assets/{tsMode-B4oEmliC.js → tsMode-BTjzM6fl.js} +1 -1
  21. package/out/renderer/assets/{typescript-CjkgfhVK.js → typescript-DZYDQEUb.js} +1 -1
  22. package/out/renderer/assets/{xml-0FAXmuVg.js → xml-CloiUoIW.js} +1 -1
  23. package/out/renderer/assets/{yaml-DWxnPuy8.js → yaml-CdKdpE-z.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +3 -1
  26. package/src/main/bridge.ts +269 -20
  27. package/src/main/index.ts +76 -0
  28. package/src/main/orchestrator.ts +1 -1
  29. package/src/main/timeTracker.ts +36 -0
  30. package/src/main/workerWs.ts +7 -1
  31. package/src/main/xterm-headless.d.ts +3 -0
  32. package/src/preload/index.ts +9 -0
  33. package/src/renderer/App.tsx +21 -1
  34. package/src/renderer/components/StatusBar.tsx +26 -1
  35. package/src/renderer/styles.css +13 -0
@@ -1,28 +1,34 @@
1
1
  import { WorkerWsClient } from './workerWs'
2
+ import { Terminal as HeadlessTerminal } from '@xterm/headless/lib-headless/xterm-headless.js'
3
+ import { SerializeAddon } from '@xterm/addon-serialize'
2
4
 
3
5
  /**
4
6
  * Conversation Bridge
5
7
  *
6
- * Captures PTY output, strips ANSI codes and terminal artifacts,
7
- * and sends cleaned text to the backend via WebSocket for chat logging.
8
- * Uses a simple buffer + byte threshold approach (no timers, since
9
- * setTimeout/setInterval don't reliably fire under TUI raw mode).
8
+ * Captures PTY output, renders it through a headless terminal,
9
+ * and sends cleaned text snapshots to the backend via WebSocket for chat logging.
10
+ * Flushes only after output settles so TUI redraws don't leak every
11
+ * intermediate repaint frame into chat logs.
10
12
  */
11
13
  export class ConversationBridge {
12
14
  private wsClient: WorkerWsClient | null = null
13
15
  private sessionActive: boolean = false
14
16
  private inputBuffer: string = ''
15
17
  private outputBuffer: string = ''
16
-
17
- private readonly FLUSH_BYTES = 500
18
+ private outputFlushTimer: ReturnType<typeof setTimeout> | null = null
19
+ private pendingCandidate = ''
20
+ private pendingCandidateChangedAt = 0
21
+ private readonly terminalCapture = new TerminalCapture()
18
22
 
19
23
  setWsClient(ws: WorkerWsClient): void {
20
24
  this.wsClient = ws
21
25
  }
22
26
 
23
27
  startSession(): void {
28
+ this.clearOutputTimers()
24
29
  this.outputBuffer = ''
25
30
  this.inputBuffer = ''
31
+ this.terminalCapture.reset()
26
32
  this.sessionActive = true
27
33
  console.log('[bridge] Session started')
28
34
  }
@@ -31,10 +37,8 @@ export class ConversationBridge {
31
37
  if (!this.sessionActive) return
32
38
 
33
39
  this.outputBuffer += data
34
-
35
- if (this.outputBuffer.length >= this.FLUSH_BYTES) {
36
- this.flush()
37
- }
40
+ this.terminalCapture.write(data)
41
+ this.scheduleOutputFlush()
38
42
  }
39
43
 
40
44
  feedInput(data: string): void {
@@ -50,17 +54,17 @@ export class ConversationBridge {
50
54
  }
51
55
  }
52
56
 
53
- resize(_cols: number, _rows: number): void {
54
- // no-op now (was used for xterm)
57
+ resize(cols: number, rows: number): void {
58
+ this.terminalCapture.resize(cols, rows)
55
59
  }
56
60
 
57
61
  private flush(): void {
62
+ this.clearOutputTimers()
58
63
  if (this.outputBuffer.length === 0) return
59
64
 
60
- const raw = this.outputBuffer
61
65
  this.outputBuffer = ''
62
66
 
63
- const cleaned = cleanOutput(raw)
67
+ const cleaned = this.terminalCapture.readCommittedDelta()
64
68
  if (cleaned.length === 0) return
65
69
 
66
70
  this.sendEntry('terminal_output', cleaned)
@@ -75,6 +79,50 @@ export class ConversationBridge {
75
79
  })
76
80
  }
77
81
 
82
+ private scheduleOutputFlush(): void {
83
+ if (!this.outputFlushTimer) {
84
+ this.outputFlushTimer = setTimeout(() => {
85
+ this.checkOutputStability()
86
+ }, OUTPUT_STABILITY_CHECK_MS)
87
+ }
88
+ }
89
+
90
+ private checkOutputStability(): void {
91
+ this.outputFlushTimer = null
92
+ if (this.outputBuffer.length === 0) return
93
+
94
+ const candidate = this.terminalCapture.currentCandidate()
95
+ const now = Date.now()
96
+
97
+ if (candidate.length === 0) {
98
+ this.outputBuffer = ''
99
+ return
100
+ }
101
+
102
+ if (candidate !== this.pendingCandidate) {
103
+ this.pendingCandidate = candidate
104
+ this.pendingCandidateChangedAt = now
105
+ }
106
+
107
+ if (now - this.pendingCandidateChangedAt >= OUTPUT_STABLE_MS) {
108
+ this.flush()
109
+ return
110
+ }
111
+
112
+ this.outputFlushTimer = setTimeout(() => {
113
+ this.checkOutputStability()
114
+ }, OUTPUT_STABILITY_CHECK_MS)
115
+ }
116
+
117
+ private clearOutputTimers(): void {
118
+ if (this.outputFlushTimer) {
119
+ clearTimeout(this.outputFlushTimer)
120
+ this.outputFlushTimer = null
121
+ }
122
+ this.pendingCandidate = ''
123
+ this.pendingCandidateChangedAt = 0
124
+ }
125
+
78
126
  endSession(): void {
79
127
  if (!this.sessionActive) return
80
128
 
@@ -87,6 +135,114 @@ export class ConversationBridge {
87
135
  }
88
136
  }
89
137
 
138
+ const DEFAULT_COLS = 80
139
+ const DEFAULT_ROWS = 24
140
+ const OUTPUT_STABILITY_CHECK_MS = 250
141
+ const OUTPUT_STABLE_MS = 1800
142
+ const MAX_SNAPSHOT_LINES = 200
143
+ const MAX_LOG_CHARS = 20_000
144
+
145
+ class TerminalCapture {
146
+ private terminal: HeadlessTerminal
147
+ private serializeAddon: SerializeAddon
148
+ private lastCommittedCandidate = ''
149
+
150
+ constructor() {
151
+ this.terminal = this.createTerminal(DEFAULT_COLS, DEFAULT_ROWS)
152
+ this.serializeAddon = this.createSerializeAddon(this.terminal)
153
+ }
154
+
155
+ reset(): void {
156
+ this.terminal.dispose()
157
+ this.terminal = this.createTerminal(DEFAULT_COLS, DEFAULT_ROWS)
158
+ this.serializeAddon = this.createSerializeAddon(this.terminal)
159
+ this.lastCommittedCandidate = ''
160
+ }
161
+
162
+ resize(cols: number, rows: number): void {
163
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return
164
+
165
+ const nextCols = Math.max(10, Math.floor(cols))
166
+ const nextRows = Math.max(5, Math.floor(rows))
167
+ this.terminal.resize(nextCols, nextRows)
168
+ }
169
+
170
+ write(data: string): void {
171
+ const writable = this.terminal as HeadlessTerminal & {
172
+ writeSync?: (data: string) => void
173
+ _core?: { writeSync?: (data: string) => void }
174
+ }
175
+ if (typeof writable.writeSync === 'function') {
176
+ writable.writeSync(data)
177
+ } else if (typeof writable._core?.writeSync === 'function') {
178
+ // The public headless API only exposes async write(). The bridge flushes
179
+ // synchronously on byte thresholds/session end, so use xterm's sync core
180
+ // writer for deterministic chat-log snapshots.
181
+ writable._core.writeSync(data)
182
+ } else {
183
+ this.terminal.write(data)
184
+ }
185
+ }
186
+
187
+ readCommittedDelta(): string {
188
+ const candidate = this.currentCandidate()
189
+ const delta = diffSnapshots(this.lastCommittedCandidate, candidate)
190
+ this.lastCommittedCandidate = candidate
191
+ return trimToLogLimit(delta)
192
+ }
193
+
194
+ currentCandidate(): string {
195
+ return extractTranscriptCandidate(normalizeTerminalText(this.readSnapshot()))
196
+ }
197
+
198
+ private createTerminal(cols: number, rows: number): HeadlessTerminal {
199
+ return new HeadlessTerminal({
200
+ cols,
201
+ rows,
202
+ scrollback: MAX_SNAPSHOT_LINES,
203
+ allowProposedApi: true,
204
+ logLevel: 'off',
205
+ })
206
+ }
207
+
208
+ private createSerializeAddon(terminal: HeadlessTerminal): SerializeAddon {
209
+ const addon = new SerializeAddon()
210
+ terminal.loadAddon(addon as any)
211
+ return addon
212
+ }
213
+
214
+ private readSnapshot(): string {
215
+ try {
216
+ return this.readPlainBufferSnapshot()
217
+ } catch {
218
+ return this.serializeAddon.serialize({
219
+ scrollback: MAX_SNAPSHOT_LINES,
220
+ excludeModes: true,
221
+ })
222
+ }
223
+ }
224
+
225
+ private readPlainBufferSnapshot(): string {
226
+ const buffer = this.terminal.buffer.active
227
+ const start = Math.max(0, buffer.length - MAX_SNAPSHOT_LINES)
228
+ const lines: string[] = []
229
+
230
+ for (let i = start; i < buffer.length; i++) {
231
+ const line = buffer.getLine(i)
232
+ if (!line) continue
233
+
234
+ const text = line.translateToString(true)
235
+ if (line.isWrapped && lines.length > 0) {
236
+ lines[lines.length - 1] += text
237
+ } else {
238
+ lines.push(text)
239
+ }
240
+ }
241
+
242
+ return lines.join('\n')
243
+ }
244
+ }
245
+
90
246
  /**
91
247
  * Strip ANSI escape codes from a string.
92
248
  */
@@ -131,18 +287,111 @@ function cleanInput(str: string): string {
131
287
  }
132
288
 
133
289
  /**
134
- * Clean terminal output: strip ANSI, remove control chars, collapse noise.
290
+ * Clean rendered terminal text: strip any remaining ANSI, remove control chars, collapse noise.
135
291
  */
136
- function cleanOutput(str: string): string {
292
+ function normalizeTerminalText(str: string): string {
137
293
  let cleaned = stripAnsi(str)
138
- // Remove carriage returns
139
- cleaned = cleaned.replace(/\r/g, '')
294
+ cleaned = cleaned.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
140
295
  // Remove control characters except newline/tab
141
296
  // eslint-disable-next-line no-control-regex
142
297
  cleaned = cleaned.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
298
+ cleaned = cleaned.replace(/[ \t]+\n/g, '\n')
143
299
  // Collapse 3+ consecutive newlines into 2
144
300
  cleaned = cleaned.replace(/\n{3,}/g, '\n\n')
145
- // Remove lines that are only whitespace
146
- cleaned = cleaned.split('\n').filter(line => line.trim().length > 0).join('\n')
147
301
  return cleaned.trim()
148
302
  }
303
+
304
+ function extractTranscriptCandidate(snapshot: string): string {
305
+ const lines = snapshot.split('\n')
306
+ const kept: string[] = []
307
+ let skipTipUrl = false
308
+
309
+ for (const rawLine of lines) {
310
+ const line = rawLine.trim()
311
+
312
+ if (skipTipUrl && isLikelyTipContinuation(line)) {
313
+ continue
314
+ }
315
+ skipTipUrl = false
316
+
317
+ if (line.length === 0) {
318
+ if (kept.length > 0 && kept[kept.length - 1] !== '') {
319
+ kept.push('')
320
+ }
321
+ continue
322
+ }
323
+
324
+ if (isTipLine(line)) {
325
+ skipTipUrl = true
326
+ continue
327
+ }
328
+
329
+ if (isTerminalChromeLine(line) || isBusyScreenLine(line)) {
330
+ continue
331
+ }
332
+
333
+ kept.push(line)
334
+ }
335
+
336
+ return kept
337
+ .join('\n')
338
+ .replace(/\n{3,}/g, '\n\n')
339
+ .trim()
340
+ }
341
+
342
+ function diffSnapshots(previous: string, current: string): string {
343
+ if (current.length === 0 || current === previous) return ''
344
+ if (previous.length === 0) return current
345
+ if (current.startsWith(previous)) return current.slice(previous.length).trim()
346
+
347
+ const previousLines = previous.split('\n')
348
+ const currentLines = current.split('\n')
349
+
350
+ const maxOverlap = Math.min(previousLines.length, currentLines.length)
351
+ for (let overlap = maxOverlap; overlap > 0; overlap--) {
352
+ const previousTail = previousLines.slice(previousLines.length - overlap)
353
+ const currentHead = currentLines.slice(0, overlap)
354
+ if (linesEqual(previousTail, currentHead)) {
355
+ return currentLines.slice(overlap).join('\n').trim()
356
+ }
357
+ }
358
+
359
+ return current
360
+ }
361
+
362
+ function isTerminalChromeLine(line: string): boolean {
363
+ return (
364
+ /^[╭╰╮╯│─\s]+$/.test(line) ||
365
+ line.includes('OpenAI Codex') ||
366
+ line.includes('model:') ||
367
+ line.includes('directory:') ||
368
+ line.includes('/model to change') ||
369
+ /^gpt-[\w.-]+ .*·/.test(line) ||
370
+ /^›\s*/.test(line) ||
371
+ line === 'Write tests for @filename' ||
372
+ line === 'Implement {feature}' ||
373
+ /^[•◦∙·]$/.test(line)
374
+ )
375
+ }
376
+
377
+ function isBusyScreenLine(line: string): boolean {
378
+ return /\bWorking \(\d+s\b/.test(line) || line.includes('esc to interrupt')
379
+ }
380
+
381
+ function isTipLine(line: string): boolean {
382
+ return line.startsWith('Tip:')
383
+ }
384
+
385
+ function isLikelyTipContinuation(line: string): boolean {
386
+ return line.startsWith('http://') || line.startsWith('https://') || line.includes('chatgpt.com/codex')
387
+ }
388
+
389
+ function linesEqual(a: string[], b: string[]): boolean {
390
+ if (a.length !== b.length) return false
391
+ return a.every((line, index) => line === b[index])
392
+ }
393
+
394
+ function trimToLogLimit(str: string): string {
395
+ if (str.length <= MAX_LOG_CHARS) return str
396
+ return `[truncated]\n${str.slice(str.length - MAX_LOG_CHARS)}`
397
+ }
package/src/main/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { app, BrowserWindow, ipcMain, dialog, nativeImage } from 'electron'
2
2
  import path from 'path'
3
3
  import fs from 'fs'
4
+ import https from 'https'
4
5
 
5
6
  // Prevent EPIPE crashes when stdout pipe is closed
6
7
  process.stdout?.on?.('error', () => {})
@@ -174,6 +175,77 @@ ipcMain.handle('app:browseCwd', async () => {
174
175
  return result.filePaths[0]
175
176
  })
176
177
 
178
+ // ─── Version + npm update check ───────────────────
179
+
180
+ const NPM_PACKAGE = '@phenx-inc/ctlsurf'
181
+ const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
182
+
183
+ interface UpdateInfo {
184
+ current: string
185
+ latest: string | null
186
+ hasUpdate: boolean
187
+ checkedAt: number | null
188
+ }
189
+
190
+ let updateInfo: UpdateInfo = {
191
+ current: app.getVersion(),
192
+ latest: null,
193
+ hasUpdate: false,
194
+ checkedAt: null,
195
+ }
196
+
197
+ function compareSemver(a: string, b: string): number {
198
+ const pa = a.split('.').map(n => parseInt(n, 10) || 0)
199
+ const pb = b.split('.').map(n => parseInt(n, 10) || 0)
200
+ for (let i = 0; i < 3; i++) {
201
+ const ai = pa[i] || 0
202
+ const bi = pb[i] || 0
203
+ if (ai !== bi) return ai - bi
204
+ }
205
+ return 0
206
+ }
207
+
208
+ function fetchLatestNpmVersion(): Promise<string | null> {
209
+ return new Promise((resolve) => {
210
+ const url = `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE)}/latest`
211
+ const req = https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => {
212
+ if (res.statusCode !== 200) {
213
+ res.resume()
214
+ resolve(null)
215
+ return
216
+ }
217
+ let body = ''
218
+ res.setEncoding('utf8')
219
+ res.on('data', (chunk) => { body += chunk })
220
+ res.on('end', () => {
221
+ try {
222
+ const json = JSON.parse(body)
223
+ resolve(typeof json?.version === 'string' ? json.version : null)
224
+ } catch {
225
+ resolve(null)
226
+ }
227
+ })
228
+ })
229
+ req.on('error', () => resolve(null))
230
+ req.setTimeout(8000, () => { req.destroy(); resolve(null) })
231
+ })
232
+ }
233
+
234
+ async function checkForUpdate(): Promise<void> {
235
+ const latest = await fetchLatestNpmVersion()
236
+ updateInfo = {
237
+ current: app.getVersion(),
238
+ latest,
239
+ hasUpdate: !!latest && compareSemver(latest, app.getVersion()) > 0,
240
+ checkedAt: Date.now(),
241
+ }
242
+ log(`[update] current=${updateInfo.current} latest=${latest ?? 'unknown'} hasUpdate=${updateInfo.hasUpdate}`)
243
+ mainWindow?.webContents.send('app:updateInfo', updateInfo)
244
+ }
245
+
246
+ ipcMain.handle('app:getVersion', () => app.getVersion())
247
+ ipcMain.handle('app:getUpdateInfo', () => updateInfo)
248
+
177
249
  // ─── Filesystem IPC ───────────────────────────────
178
250
 
179
251
  ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
@@ -368,6 +440,10 @@ app.whenReady().then(() => {
368
440
 
369
441
  orchestrator.loadSettings()
370
442
  createWindow()
443
+
444
+ // Kick off an initial npm update check shortly after launch, then poll periodically.
445
+ setTimeout(() => { void checkForUpdate() }, 3000)
446
+ setInterval(() => { void checkForUpdate() }, UPDATE_CHECK_INTERVAL_MS)
371
447
  })
372
448
 
373
449
  app.on('window-all-closed', () => {
@@ -104,7 +104,7 @@ export class Orchestrator {
104
104
  const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
105
105
  if (activeTab) {
106
106
  activeTab.ptyManager.write(message.content + '\r')
107
- this.bridge.feedInput(message.content)
107
+ this.bridge.feedInput(message.content + '\r')
108
108
  }
109
109
  }
110
110
  },
@@ -33,6 +33,8 @@ interface SessionState {
33
33
  blockId: string
34
34
  rowId: string
35
35
  cwd: string
36
+ agentName: string
37
+ idleTimeoutMin: number
36
38
  startedAt: number
37
39
  lastActivity: number
38
40
  activeMs: number
@@ -42,6 +44,16 @@ interface SessionState {
42
44
  ended: boolean
43
45
  }
44
46
 
47
+ function isSameLocalDay(a: number, b: number): boolean {
48
+ const da = new Date(a)
49
+ const db = new Date(b)
50
+ return (
51
+ da.getFullYear() === db.getFullYear() &&
52
+ da.getMonth() === db.getMonth() &&
53
+ da.getDate() === db.getDate()
54
+ )
55
+ }
56
+
45
57
  function log(...args: unknown[]): void {
46
58
  try { console.log('[time-tracker]', ...args) } catch { /* EPIPE safe */ }
47
59
  }
@@ -61,6 +73,7 @@ export class TimeTracker {
61
73
  private api: CtlsurfApi
62
74
  private sessions = new Map<string, SessionState>()
63
75
  private blockCache = new Map<string, string>()
76
+ private rollingOver = new Set<string>()
64
77
 
65
78
  constructor(api: CtlsurfApi) {
66
79
  this.api = api
@@ -96,6 +109,8 @@ export class TimeTracker {
96
109
  blockId,
97
110
  rowId,
98
111
  cwd,
112
+ agentName,
113
+ idleTimeoutMin,
99
114
  startedAt,
100
115
  lastActivity: startedAt,
101
116
  activeMs: 0,
@@ -129,6 +144,10 @@ export class TimeTracker {
129
144
  const s = this.sessions.get(tabId)
130
145
  if (!s || s.ended) return
131
146
  const now = Date.now()
147
+ if (!isSameLocalDay(s.startedAt, now)) {
148
+ void this.rollover(tabId)
149
+ return
150
+ }
132
151
  const delta = now - s.lastActivity
133
152
  if (delta < s.idleTimeoutMs) {
134
153
  s.activeMs += delta
@@ -136,6 +155,23 @@ export class TimeTracker {
136
155
  s.lastActivity = now
137
156
  }
138
157
 
158
+ private async rollover(tabId: string): Promise<void> {
159
+ if (this.rollingOver.has(tabId)) return
160
+ const s = this.sessions.get(tabId)
161
+ if (!s || s.ended) return
162
+ this.rollingOver.add(tabId)
163
+ const { cwd, agentName, idleTimeoutMin } = s
164
+ log(`Day rolled over for tab=${tabId}; ending session and starting fresh`)
165
+ try {
166
+ await this.endSession(tabId)
167
+ await this.startSession(tabId, cwd, agentName, idleTimeoutMin)
168
+ } catch (err: any) {
169
+ log(`rollover failed: ${err?.message || err}`)
170
+ } finally {
171
+ this.rollingOver.delete(tabId)
172
+ }
173
+ }
174
+
139
175
  async endSession(tabId: string): Promise<void> {
140
176
  const s = this.sessions.get(tabId)
141
177
  if (!s || s.ended) return
@@ -18,7 +18,7 @@ export interface WorkerRegistration {
18
18
  cwd: string
19
19
  repo_url?: string
20
20
  agent: string
21
- fingerprint: string
21
+ fingerprint?: string
22
22
  }
23
23
 
24
24
  export interface WorkerWsEvents {
@@ -135,6 +135,12 @@ export class WorkerWsClient {
135
135
  }
136
136
 
137
137
  sendChatLog(entry: { type: string; content: string; ts?: string }): void {
138
+ log('[worker-ws] chat_log DEBUG', {
139
+ type: entry.type,
140
+ chars: entry.content.length,
141
+ lines: entry.content.split('\n').length,
142
+ preview: entry.content.slice(0, 500),
143
+ })
138
144
  this.send({ type: 'chat_log', entry })
139
145
  }
140
146
 
@@ -0,0 +1,3 @@
1
+ declare module '@xterm/headless/lib-headless/xterm-headless.js' {
2
+ export * from '@xterm/headless'
3
+ }
@@ -46,6 +46,15 @@ const api = {
46
46
  ipcRenderer.invoke('app:cwd'),
47
47
  browseCwd: (): Promise<string | null> =>
48
48
  ipcRenderer.invoke('app:browseCwd'),
49
+ getVersion: (): Promise<string> =>
50
+ ipcRenderer.invoke('app:getVersion'),
51
+ getUpdateInfo: (): Promise<{ current: string; latest: string | null; hasUpdate: boolean; checkedAt: number | null }> =>
52
+ ipcRenderer.invoke('app:getUpdateInfo'),
53
+ onUpdateInfo: (callback: (info: { current: string; latest: string | null; hasUpdate: boolean; checkedAt: number | null }) => void) => {
54
+ const listener = (_event: Electron.IpcRendererEvent, info: any) => callback(info)
55
+ ipcRenderer.on('app:updateInfo', listener)
56
+ return () => ipcRenderer.removeListener('app:updateInfo', listener)
57
+ },
49
58
 
50
59
  // Settings (legacy)
51
60
  getSetting: (key: string): Promise<string | null> =>
@@ -59,10 +59,20 @@ declare global {
59
59
  readDir: (dirPath: string) => Promise<Array<{ name: string; path: string; isDirectory: boolean }>>
60
60
  readFile: (filePath: string) => Promise<{ ok: boolean; content?: string; error?: string }>
61
61
  writeFile: (filePath: string, content: string) => Promise<{ ok: boolean; error?: string }>
62
+ getVersion: () => Promise<string>
63
+ getUpdateInfo: () => Promise<UpdateInfo>
64
+ onUpdateInfo: (callback: (info: UpdateInfo) => void) => () => void
62
65
  }
63
66
  }
64
67
  }
65
68
 
69
+ interface UpdateInfo {
70
+ current: string
71
+ latest: string | null
72
+ hasUpdate: boolean
73
+ checkedAt: number | null
74
+ }
75
+
66
76
  const DEFAULT_LAYOUT: LayoutNode = {
67
77
  type: 'split',
68
78
  direction: 'horizontal',
@@ -95,6 +105,7 @@ export default function App() {
95
105
  const [showSettings, setShowSettings] = useState(false)
96
106
  const [wsStatus, setWsStatus] = useState('disconnected')
97
107
  const [cwd, setCwd] = useState<string | null>(null)
108
+ const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
98
109
 
99
110
  // Multi-tab state
100
111
  const [tabs, setTabs] = useState<TabInfo[]>(() => {
@@ -130,6 +141,15 @@ export default function App() {
130
141
  return unsub
131
142
  }, [])
132
143
 
144
+ useEffect(() => {
145
+ let cancelled = false
146
+ window.worker.getUpdateInfo().then(info => {
147
+ if (!cancelled) setUpdateInfo(info)
148
+ }).catch(() => { /* ignore */ })
149
+ const unsub = window.worker.onUpdateInfo((info) => setUpdateInfo(info))
150
+ return () => { cancelled = true; unsub() }
151
+ }, [])
152
+
133
153
  useEffect(() => {
134
154
  const unsub = window.worker.onWorkerMessage((message) => {
135
155
  console.log('[worker] Received message:', message)
@@ -433,7 +453,7 @@ export default function App() {
433
453
  )}
434
454
  </div>
435
455
 
436
- <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} />
456
+ <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
437
457
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
438
458
 
439
459
  {showAgentPicker && agents.length > 0 && (
@@ -1,12 +1,20 @@
1
1
  import { useState } from 'react'
2
2
 
3
+ interface UpdateInfo {
4
+ current: string
5
+ latest: string | null
6
+ hasUpdate: boolean
7
+ checkedAt: number | null
8
+ }
9
+
3
10
  interface StatusBarProps {
4
11
  wsStatus: string
5
12
  cwd: string | null
6
13
  onChangeCwd: () => void
14
+ updateInfo: UpdateInfo | null
7
15
  }
8
16
 
9
- export function StatusBar({ wsStatus, cwd, onChangeCwd }: StatusBarProps) {
17
+ export function StatusBar({ wsStatus, cwd, onChangeCwd, updateInfo }: StatusBarProps) {
10
18
  const [creating, setCreating] = useState(false)
11
19
 
12
20
  const wsLabel = {
@@ -44,6 +52,9 @@ export function StatusBar({ wsStatus, cwd, onChangeCwd }: StatusBarProps) {
44
52
  const home = '/Users/' + (cwd?.split('/')[2] || '')
45
53
  const displayPath = cwd?.replace(home, '~') || ''
46
54
 
55
+ const versionLabel = updateInfo?.current ? `v${updateInfo.current}` : ''
56
+ const hasUpdate = !!updateInfo?.hasUpdate && !!updateInfo.latest
57
+
47
58
  return (
48
59
  <div className="statusbar">
49
60
  <div className="statusbar-section">
@@ -67,6 +78,20 @@ export function StatusBar({ wsStatus, cwd, onChangeCwd }: StatusBarProps) {
67
78
  ) : (
68
79
  <span>ctlsurf: {wsLabel}</span>
69
80
  )}
81
+ {versionLabel && (
82
+ <span
83
+ className={`statusbar-version${hasUpdate ? ' has-update' : ''}`}
84
+ title={
85
+ hasUpdate
86
+ ? `Update available: ${updateInfo!.latest}\nRun: npm i -g @phenx-inc/ctlsurf`
87
+ : '@phenx-inc/ctlsurf'
88
+ }
89
+ >
90
+ {hasUpdate
91
+ ? `${versionLabel} → ${updateInfo!.latest} available`
92
+ : versionLabel}
93
+ </span>
94
+ )}
70
95
  </div>
71
96
  </div>
72
97
  )