@phenx-inc/ctlsurf 0.3.7 → 0.3.9

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 +254 -13
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +130 -51
  4. package/out/preload/index.js +3 -0
  5. package/out/renderer/assets/{cssMode-DQW-brNd.js → cssMode-CYoo4t9f.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DxgOckH2.js → freemarker2--UQnPZsn.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BX1Wpk_3.js → handlebars-DVDrmX0C.js} +1 -1
  8. package/out/renderer/assets/{html-t-KXioI0.js → html-D1-cXoLy.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-Dya7iUjr.js → htmlMode-f5nBuprq.js} +3 -3
  10. package/out/renderer/assets/{index-D6JBcQ20.css → index-65hyKM_8.css} +16 -0
  11. package/out/renderer/assets/{index-DNqZidnO.js → index-D23nru43.js} +64 -23
  12. package/out/renderer/assets/{javascript-DZzW2adn.js → javascript-CcarFzBL.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-D_Wv7XH8.js → jsonMode-BvF-xK9U.js} +3 -3
  14. package/out/renderer/assets/{liquid-BJAHAm2T.js → liquid-CHLtUKl2.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-BgMd-KJk.js → lspLanguageFeatures-B9aNeatS.js} +1 -1
  16. package/out/renderer/assets/{mdx-B6Zod3ry.js → mdx-HGDrkifZ.js} +1 -1
  17. package/out/renderer/assets/{python-Cgt13-KH.js → python-B_dPzjJ6.js} +1 -1
  18. package/out/renderer/assets/{razor-BcwFJGYS.js → razor-CHheM4ot.js} +1 -1
  19. package/out/renderer/assets/{tsMode-BTjzM6fl.js → tsMode-CdC3i1gG.js} +1 -1
  20. package/out/renderer/assets/{typescript-DZYDQEUb.js → typescript-BX6guVRK.js} +1 -1
  21. package/out/renderer/assets/{xml-CloiUoIW.js → xml-CpS-pOPE.js} +1 -1
  22. package/out/renderer/assets/{yaml-CdKdpE-z.js → yaml-Du0AjOHW.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/bridge.ts +9 -3
  26. package/src/main/headless.ts +35 -2
  27. package/src/main/index.ts +10 -39
  28. package/src/main/orchestrator.ts +74 -1
  29. package/src/main/tui.ts +20 -8
  30. package/src/main/updateCheck.ts +40 -0
  31. package/src/preload/index.ts +6 -0
  32. package/src/renderer/App.tsx +2 -0
  33. package/src/renderer/components/AgentPicker.tsx +38 -3
  34. package/src/renderer/styles.css +16 -0
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-DZYDQEUb.js";
2
- import "./index-DNqZidnO.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-BX6guVRK.js";
2
+ import "./index-D23nru43.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-DNqZidnO.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-BgMd-KJk.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BgMd-KJk.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-D23nru43.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-B9aNeatS.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-B9aNeatS.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-DNqZidnO.js";
1
+ import { l as languages } from "./index-D23nru43.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-DNqZidnO.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-D23nru43.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-DNqZidnO.js";
1
+ import { l as languages } from "./index-D23nru43.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-DNqZidnO.js";
1
+ import { l as languages } from "./index-D23nru43.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-DNqZidnO.js";
1
+ import { l as languages } from "./index-D23nru43.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-DNqZidnO.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-D23nru43.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-DNqZidnO.js";
1
+ import { l as languages } from "./index-D23nru43.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-DNqZidnO.js";
1
+ import { l as languages } from "./index-D23nru43.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-DNqZidnO.js";
1
+ import { l as languages } from "./index-D23nru43.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-DNqZidnO.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-D6JBcQ20.css">
7
+ <script type="module" crossorigin src="./assets/index-D23nru43.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-65hyKM_8.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.3.7",
3
+ "version": "0.3.9",
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": {
@@ -13,6 +13,7 @@ import { SerializeAddon } from '@xterm/addon-serialize'
13
13
  export class ConversationBridge {
14
14
  private wsClient: WorkerWsClient | null = null
15
15
  private sessionActive: boolean = false
16
+ private loggingEnabled: boolean = false
16
17
  private inputBuffer: string = ''
17
18
  private outputBuffer: string = ''
18
19
  private outputFlushTimer: ReturnType<typeof setTimeout> | null = null
@@ -24,7 +25,12 @@ export class ConversationBridge {
24
25
  this.wsClient = ws
25
26
  }
26
27
 
28
+ setLoggingEnabled(enabled: boolean): void {
29
+ this.loggingEnabled = enabled
30
+ }
31
+
27
32
  startSession(): void {
33
+ if (!this.loggingEnabled) return
28
34
  this.clearOutputTimers()
29
35
  this.outputBuffer = ''
30
36
  this.inputBuffer = ''
@@ -34,7 +40,7 @@ export class ConversationBridge {
34
40
  }
35
41
 
36
42
  feedOutput(data: string): void {
37
- if (!this.sessionActive) return
43
+ if (!this.sessionActive || !this.loggingEnabled) return
38
44
 
39
45
  this.outputBuffer += data
40
46
  this.terminalCapture.write(data)
@@ -42,7 +48,7 @@ export class ConversationBridge {
42
48
  }
43
49
 
44
50
  feedInput(data: string): void {
45
- if (!this.sessionActive) return
51
+ if (!this.sessionActive || !this.loggingEnabled) return
46
52
  this.inputBuffer += data
47
53
 
48
54
  if (data.includes('\r') || data.includes('\n')) {
@@ -71,7 +77,7 @@ export class ConversationBridge {
71
77
  }
72
78
 
73
79
  private sendEntry(type: string, content: string): void {
74
- if (!this.wsClient) return
80
+ if (!this.wsClient || !this.loggingEnabled) return
75
81
  this.wsClient.sendChatLog({
76
82
  ts: new Date().toISOString(),
77
83
  type,
@@ -23,8 +23,19 @@ process.on('uncaughtException', (err) => {
23
23
 
24
24
  import { Orchestrator } from './orchestrator'
25
25
  import { getSettingsDir } from './settingsDir'
26
- import { getBuiltinAgents, getDefaultAgent, isCodingAgent, type AgentConfig } from './agents'
26
+ import { getBuiltinAgents, isCodingAgent, type AgentConfig } from './agents'
27
27
  import { Tui } from './tui'
28
+ import { fetchLatestNpmVersion, compareSemver, NPM_PACKAGE } from './updateCheck'
29
+
30
+ function getCurrentVersion(): string {
31
+ try {
32
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
33
+ const pkg = require('../../package.json')
34
+ return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
35
+ } catch {
36
+ return '0.0.0'
37
+ }
38
+ }
28
39
 
29
40
  // ─── CLI arg parsing ──────────────────────────────
30
41
 
@@ -63,10 +74,28 @@ function parseArgs(argv: string[]): CliArgs {
63
74
 
64
75
  // ─── Main ─────────────────────────────────────────
65
76
 
77
+ async function checkVersionAndNotify(): Promise<void> {
78
+ const current = getCurrentVersion()
79
+ const latest = await fetchLatestNpmVersion(3000)
80
+ if (!latest) return
81
+ if (compareSemver(latest, current) <= 0) return
82
+
83
+ const Y = '\x1b[33m'
84
+ const G = '\x1b[32m'
85
+ const D = '\x1b[90m'
86
+ const R = '\x1b[0m'
87
+ process.stdout.write(
88
+ `\n${Y}A new version of ctlsurf is available${R} ${D}(${current} → ${latest})${R}\n` +
89
+ ` Update: ${G}npm i -g ${NPM_PACKAGE}${R}\n\n`
90
+ )
91
+ }
92
+
66
93
  async function main() {
67
94
  const args = parseArgs(process.argv.slice(2))
68
95
  const settingsDir = getSettingsDir(false)
69
96
 
97
+ await checkVersionAndNotify()
98
+
70
99
  const tui = new Tui()
71
100
  const agents = getBuiltinAgents()
72
101
 
@@ -115,9 +144,13 @@ async function main() {
115
144
  }
116
145
  } else {
117
146
  const initialTrackTime = orchestrator.getActiveProfile().trackTime !== false
118
- const picked = await tui.showAgentPicker(agents, { initialTrackTime })
147
+ const initialLogChat = orchestrator.logChatEnabled
148
+ const picked = await tui.showAgentPicker(agents, { initialTrackTime, initialLogChat })
119
149
  agent = agents[picked.agentIdx]
120
150
  trackTimeOverride = picked.trackTime
151
+ if (picked.logChat !== orchestrator.logChatEnabled) {
152
+ orchestrator.setLogChat(picked.logChat)
153
+ }
121
154
  }
122
155
 
123
156
  // ─── Start TUI + agent ─────────────────────────
package/src/main/index.ts CHANGED
@@ -1,7 +1,8 @@
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
+ import { fetchLatestNpmVersion, compareSemver } from './updateCheck'
5
6
 
6
7
  // Prevent EPIPE crashes when stdout pipe is closed
7
8
  process.stdout?.on?.('error', () => {})
@@ -177,7 +178,6 @@ ipcMain.handle('app:browseCwd', async () => {
177
178
 
178
179
  // ─── Version + npm update check ───────────────────
179
180
 
180
- const NPM_PACKAGE = '@phenx-inc/ctlsurf'
181
181
  const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
182
182
 
183
183
  interface UpdateInfo {
@@ -194,43 +194,6 @@ let updateInfo: UpdateInfo = {
194
194
  checkedAt: null,
195
195
  }
196
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
197
  async function checkForUpdate(): Promise<void> {
235
198
  const latest = await fetchLatestNpmVersion()
236
199
  updateInfo = {
@@ -381,6 +344,14 @@ ipcMain.handle('profiles:save', (_event, id: string, data: any) => {
381
344
  ipcMain.handle('profiles:switch', (_event, id: string) => orchestrator.switchProfile(id))
382
345
  ipcMain.handle('profiles:delete', (_event, id: string) => orchestrator.deleteProfile(id))
383
346
 
347
+ // ─── Chat logging IPC ─────────────────────────────
348
+
349
+ ipcMain.handle('logchat:get', () => ({ enabled: orchestrator.logChatEnabled }))
350
+ ipcMain.handle('logchat:set', (_event, enabled: boolean) => {
351
+ orchestrator.setLogChat(!!enabled)
352
+ return { enabled: orchestrator.logChatEnabled }
353
+ })
354
+
384
355
  // ─── Tracking IPC ─────────────────────────────────
385
356
 
386
357
  ipcMain.handle('tracking:get', () => ({ active: orchestrator.isActiveTabTracking() }))
@@ -32,6 +32,7 @@ export interface SettingsData {
32
32
  ctlsurfApiKey?: string
33
33
  ctlsurfBaseUrl?: string
34
34
  ctlsurfDataspacePageId?: string
35
+ logChat?: boolean
35
36
  }
36
37
 
37
38
  export interface OrchestratorEvents {
@@ -65,6 +66,7 @@ const DEFAULT_PROFILES: Record<string, Profile> = {
65
66
  }
66
67
 
67
68
  const TERM_STREAM_INTERVAL_MS = 50
69
+ const NO_PROJECT_POLL_MS = 5_000
68
70
 
69
71
  export class Orchestrator {
70
72
  private settingsDir: string
@@ -84,7 +86,10 @@ export class Orchestrator {
84
86
  private settings: SettingsData = {
85
87
  activeProfile: 'production',
86
88
  profiles: { ...DEFAULT_PROFILES },
89
+ logChat: false,
87
90
  }
91
+ private noProjectPollTimer: ReturnType<typeof setInterval> | null = null
92
+ private noProjectPollCwd: string | null = null
88
93
 
89
94
  constructor(settingsDir: string, events: OrchestratorEvents) {
90
95
  this.settingsDir = settingsDir
@@ -113,6 +118,11 @@ export class Orchestrator {
113
118
  events.onWorkerRegistered(data)
114
119
  if (!data.folder_id) {
115
120
  events.onWorkerStatus('no_project')
121
+ if (this.currentCwd && data.status !== 'pending_approval') {
122
+ this.startNoProjectPolling(this.currentCwd)
123
+ }
124
+ } else {
125
+ this.stopNoProjectPolling()
116
126
  }
117
127
  },
118
128
  onTerminalInput: (data: string) => {
@@ -122,6 +132,24 @@ export class Orchestrator {
122
132
  })
123
133
 
124
134
  this.bridge.setWsClient(this.workerWs)
135
+ this.bridge.setLoggingEnabled(!!this.settings.logChat)
136
+ }
137
+
138
+ // ─── Chat logging ───────────────────────────────
139
+
140
+ get logChatEnabled(): boolean {
141
+ return !!this.settings.logChat
142
+ }
143
+
144
+ setLogChat(enabled: boolean): void {
145
+ this.settings.logChat = enabled
146
+ this.saveSettings()
147
+ this.bridge.setLoggingEnabled(enabled)
148
+ if (!enabled) {
149
+ this.bridge.endSession()
150
+ } else if (this.activeTabId) {
151
+ this.bridge.startSession()
152
+ }
125
153
  }
126
154
 
127
155
  // ─── Settings ───────────────────────────────────
@@ -179,6 +207,7 @@ export class Orchestrator {
179
207
  dataspacePageId: raw.ctlsurfDataspacePageId || '',
180
208
  },
181
209
  },
210
+ logChat: !!raw.logChat,
182
211
  }
183
212
  this.saveSettings()
184
213
  log('[settings] Migrated legacy settings to profiles')
@@ -187,16 +216,21 @@ export class Orchestrator {
187
216
  if (!this.settings.profiles.production) {
188
217
  this.settings.profiles.production = { ...DEFAULT_PROFILES.production }
189
218
  }
219
+ if (this.settings.logChat === undefined) {
220
+ this.settings.logChat = false
221
+ }
190
222
  }
191
223
  }
192
224
  } catch {
193
225
  this.settings = {
194
226
  activeProfile: 'production',
195
227
  profiles: { ...DEFAULT_PROFILES },
228
+ logChat: false,
196
229
  }
197
230
  }
198
231
 
199
232
  this.applyProfile(this.getActiveProfile())
233
+ this.bridge.setLoggingEnabled(!!this.settings.logChat)
200
234
  }
201
235
 
202
236
  saveSettings(): void {
@@ -354,7 +388,9 @@ export class Orchestrator {
354
388
  if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
355
389
  })
356
390
 
357
- this.bridge.startSession()
391
+ if (this.settings.logChat) {
392
+ this.bridge.startSession()
393
+ }
358
394
 
359
395
  const profile = this.getActiveProfile()
360
396
  const shouldTrack = opts?.trackTime !== undefined ? opts.trackTime : (profile.trackTime !== false)
@@ -370,6 +406,7 @@ export class Orchestrator {
370
406
  if (isCodingAgent(agent)) {
371
407
  this.connectWorkerWs(agent, cwd)
372
408
  } else {
409
+ this.stopNoProjectPolling()
373
410
  this.workerWs.disconnect()
374
411
  this.checkProjectStatus(cwd)
375
412
  }
@@ -456,6 +493,7 @@ export class Orchestrator {
456
493
  return
457
494
  }
458
495
 
496
+ this.stopNoProjectPolling()
459
497
  this.workerWs.connect({
460
498
  machine: os.hostname(),
461
499
  cwd,
@@ -463,6 +501,40 @@ export class Orchestrator {
463
501
  })
464
502
  }
465
503
 
504
+ private startNoProjectPolling(cwd: string): void {
505
+ if (this.noProjectPollTimer && this.noProjectPollCwd === cwd) return
506
+ this.stopNoProjectPolling()
507
+ this.noProjectPollCwd = cwd
508
+ log(`[worker-ws] Polling for project folder at ${cwd}`)
509
+ this.noProjectPollTimer = setInterval(() => { void this.checkForProjectFolder(cwd) }, NO_PROJECT_POLL_MS)
510
+ }
511
+
512
+ private stopNoProjectPolling(): void {
513
+ if (this.noProjectPollTimer) {
514
+ clearInterval(this.noProjectPollTimer)
515
+ this.noProjectPollTimer = null
516
+ this.noProjectPollCwd = null
517
+ }
518
+ }
519
+
520
+ private async checkForProjectFolder(cwd: string): Promise<void> {
521
+ if (this.currentCwd !== cwd || !this.currentAgent) {
522
+ this.stopNoProjectPolling()
523
+ return
524
+ }
525
+ if (!this.ctlsurfApi.getApiKey()) return
526
+ try {
527
+ const folder = await this.ctlsurfApi.findFolderByPath(cwd)
528
+ if (folder?.id && this.currentCwd === cwd && this.currentAgent) {
529
+ log(`[worker-ws] Project folder appeared (${folder.id}); reconnecting`)
530
+ const agent = this.currentAgent
531
+ this.stopNoProjectPolling()
532
+ this.workerWs.disconnect()
533
+ this.connectWorkerWs(agent, cwd)
534
+ }
535
+ } catch { /* ignore — retry on next tick */ }
536
+ }
537
+
466
538
  private async checkProjectStatus(cwd: string): Promise<void> {
467
539
  if (!this.ctlsurfApi.getApiKey()) {
468
540
  this.events.onWorkerStatus('no_project')
@@ -496,6 +568,7 @@ export class Orchestrator {
496
568
  // ─── Shutdown ───────────────────────────────────
497
569
 
498
570
  async shutdown(): Promise<void> {
571
+ this.stopNoProjectPolling()
499
572
  this.bridge.endSession()
500
573
  await this.timeTracker.endAll()
501
574
  for (const [, tab] of this.tabs) {
package/src/main/tui.ts CHANGED
@@ -134,14 +134,15 @@ export class Tui {
134
134
  */
135
135
  showAgentPicker(
136
136
  agents: { name: string; description: string }[],
137
- options: { initialTrackTime: boolean },
138
- ): Promise<{ agentIdx: number; trackTime: boolean }> {
137
+ options: { initialTrackTime: boolean; initialLogChat: boolean },
138
+ ): Promise<{ agentIdx: number; trackTime: boolean; logChat: boolean }> {
139
139
  return new Promise((resolve) => {
140
140
  let selected = 0
141
141
  let trackTime = options.initialTrackTime
142
+ let logChat = options.initialLogChat
142
143
  const modalWidth = 44
143
- // +4 for borders/title/sep, +2 for track-time separator + row
144
- const modalHeight = agents.length + 4 + 2
144
+ // +4 for borders/title/sep, +3 for separator + track-time row + log-chat row
145
+ const modalHeight = agents.length + 4 + 3
145
146
  const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2))
146
147
  const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2))
147
148
 
@@ -197,10 +198,18 @@ export class Tui {
197
198
  const trackPad = ' '.repeat(Math.max(0, modalWidth - 2 - trackContentLen))
198
199
  this.write(`${CSI}${trackRow};${startCol}H${BG_MODAL}${FG_DIM}│${RESET}${BG_MODAL}${trackContent}${trackPad}${FG_DIM}│${RESET}`)
199
200
 
200
- const botRow = trackRow + 1
201
+ const logRow = trackRow + 1
202
+ const logCheckbox = logChat ? `${FG_GREEN}[\u2713]${RESET}${BG_MODAL}` : `${FG_DIM}[ ]${RESET}${BG_MODAL}`
203
+ const logLabelFg = logChat ? FG_WHITE : FG_DIM
204
+ const logContent = ` ${logCheckbox} ${logLabelFg}Log chat${RESET}${BG_MODAL}`
205
+ const logContentLen = 2 + 3 + 1 + 'Log chat'.length
206
+ const logPad = ' '.repeat(Math.max(0, modalWidth - 2 - logContentLen))
207
+ this.write(`${CSI}${logRow};${startCol}H${BG_MODAL}${FG_DIM}\u2502${RESET}${BG_MODAL}${logContent}${logPad}${FG_DIM}\u2502${RESET}`)
208
+
209
+ const botRow = logRow + 1
201
210
  this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
202
211
 
203
- const hint = '\u2191\u2193 navigate \u00B7 Enter select \u00B7 t track \u00B7 q quit'
212
+ const hint = '\u2191\u2193 nav \u00B7 Enter \u00B7 t track \u00B7 l log \u00B7 q quit'
204
213
  const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
205
214
  this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
206
215
  }
@@ -221,12 +230,15 @@ export class Tui {
221
230
  } else if (key === '\x1b[B' || key === 'j') {
222
231
  selected = (selected + 1) % agents.length
223
232
  drawModal()
224
- } else if (key === 't' || key === 'T' || key === ' ') {
233
+ } else if (key === 't' || key === 'T') {
225
234
  trackTime = !trackTime
226
235
  drawModal()
236
+ } else if (key === 'l' || key === 'L') {
237
+ logChat = !logChat
238
+ drawModal()
227
239
  } else if (key === '\r' || key === '\n') {
228
240
  cleanup()
229
- resolve({ agentIdx: selected, trackTime })
241
+ resolve({ agentIdx: selected, trackTime, logChat })
230
242
  } else if (key === 'q' || key === '\x1b' || key === '\x03') {
231
243
  cleanup()
232
244
  this.write(`${CSI}?25h`)
@@ -0,0 +1,40 @@
1
+ import https from 'https'
2
+
3
+ export const NPM_PACKAGE = '@phenx-inc/ctlsurf'
4
+
5
+ export function compareSemver(a: string, b: string): number {
6
+ const pa = a.split('.').map(n => parseInt(n, 10) || 0)
7
+ const pb = b.split('.').map(n => parseInt(n, 10) || 0)
8
+ for (let i = 0; i < 3; i++) {
9
+ const ai = pa[i] || 0
10
+ const bi = pb[i] || 0
11
+ if (ai !== bi) return ai - bi
12
+ }
13
+ return 0
14
+ }
15
+
16
+ export function fetchLatestNpmVersion(timeoutMs = 8000): Promise<string | null> {
17
+ return new Promise((resolve) => {
18
+ const url = `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE)}/latest`
19
+ const req = https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => {
20
+ if (res.statusCode !== 200) {
21
+ res.resume()
22
+ resolve(null)
23
+ return
24
+ }
25
+ let body = ''
26
+ res.setEncoding('utf8')
27
+ res.on('data', (chunk) => { body += chunk })
28
+ res.on('end', () => {
29
+ try {
30
+ const json = JSON.parse(body)
31
+ resolve(typeof json?.version === 'string' ? json.version : null)
32
+ } catch {
33
+ resolve(null)
34
+ }
35
+ })
36
+ })
37
+ req.on('error', () => resolve(null))
38
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null) })
39
+ })
40
+ }
@@ -82,6 +82,12 @@ const api = {
82
82
  setTracking: (enabled: boolean): Promise<{ active: boolean }> =>
83
83
  ipcRenderer.invoke('tracking:set', enabled),
84
84
 
85
+ // Chat logging (global)
86
+ getLogChat: (): Promise<{ enabled: boolean }> =>
87
+ ipcRenderer.invoke('logchat:get'),
88
+ setLogChat: (enabled: boolean): Promise<{ enabled: boolean }> =>
89
+ ipcRenderer.invoke('logchat:set', enabled),
90
+
85
91
  // Filesystem
86
92
  readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
87
93
  ipcRenderer.invoke('fs:readDir', dirPath),
@@ -46,6 +46,8 @@ declare global {
46
46
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
47
47
  getTracking: () => Promise<{ active: boolean }>
48
48
  setTracking: (enabled: boolean) => Promise<{ active: boolean }>
49
+ getLogChat: () => Promise<{ enabled: boolean }>
50
+ setLogChat: (enabled: boolean) => Promise<{ enabled: boolean }>
49
51
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
50
52
  getWebviewInfo: () => Promise<{
51
53
  frontendUrl: string; pageUrl?: string; authenticated: boolean;
@@ -1,3 +1,5 @@
1
+ import { useEffect, useState } from 'react'
2
+
1
3
  interface AgentConfig {
2
4
  id: string
3
5
  name: string
@@ -14,9 +16,32 @@ interface AgentPickerProps {
14
16
  }
15
17
 
16
18
  export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerProps) {
17
- const home = typeof window !== 'undefined' ? '' : ''
18
- // Shorten home dir for display
19
19
  const displayPath = cwd.replace(/^\/Users\/[^/]+/, '~')
20
+ const [logChat, setLogChat] = useState(false)
21
+ const [loaded, setLoaded] = useState(false)
22
+
23
+ useEffect(() => {
24
+ let cancelled = false
25
+ window.worker.getLogChat().then(r => {
26
+ if (cancelled) return
27
+ setLogChat(!!r?.enabled)
28
+ setLoaded(true)
29
+ }).catch(() => setLoaded(true))
30
+ return () => { cancelled = true }
31
+ }, [])
32
+
33
+ const toggleLogChat = async () => {
34
+ const next = !logChat
35
+ setLogChat(next)
36
+ try { await window.worker.setLogChat(next) } catch { /* ignore */ }
37
+ }
38
+
39
+ const handleSelect = async (agent: AgentConfig) => {
40
+ if (loaded) {
41
+ try { await window.worker.setLogChat(logChat) } catch { /* ignore */ }
42
+ }
43
+ onSelect(agent)
44
+ }
20
45
 
21
46
  return (
22
47
  <div className="agent-picker-overlay">
@@ -35,13 +60,23 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
35
60
  <button
36
61
  key={a.id}
37
62
  className="agent-picker-item"
38
- onClick={() => onSelect(a)}
63
+ onClick={() => handleSelect(a)}
39
64
  >
40
65
  <span className="agent-picker-name">{a.name}</span>
41
66
  <span className="agent-picker-desc">{a.description}</span>
42
67
  </button>
43
68
  ))}
44
69
  </div>
70
+
71
+ <label className="agent-picker-option" onClick={(e) => e.stopPropagation()}>
72
+ <input
73
+ type="checkbox"
74
+ checked={logChat}
75
+ onChange={toggleLogChat}
76
+ />
77
+ <span>Log chat to ctlsurf</span>
78
+ </label>
79
+
45
80
  <div className="agent-picker-hint">Select an agent to start</div>
46
81
  </div>
47
82
  </div>