@phenx-inc/ctlsurf 0.2.0 → 0.3.1

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/out/headless/index.mjs +320 -44
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +275 -15
  4. package/out/preload/index.js +3 -0
  5. package/out/renderer/assets/{cssMode-D3kH1Kju.js → cssMode-BW-SuYuP.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-BCHZUSLb.js → freemarker2-2YWYzawi.js} +1 -1
  7. package/out/renderer/assets/{handlebars-DKx-Fw-H.js → handlebars-EwtUQRsf.js} +1 -1
  8. package/out/renderer/assets/{html-BSCM04uL.js → html-BNZkIDb9.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-BucU1MUc.js → htmlMode-C2dZKrOy.js} +3 -3
  10. package/out/renderer/assets/{index-BsdOeO0U.js → index-Bm_rbVP-.js} +114 -34
  11. package/out/renderer/assets/{index-BzF7I1my.css → index-CrTu3Z4M.css} +21 -0
  12. package/out/renderer/assets/{javascript-bPY5C4uq.js → javascript-busdVZMv.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BmJotb6E.js → jsonMode-BaVI6jAw.js} +3 -3
  14. package/out/renderer/assets/{liquid-Cja_Pzh3.js → liquid-DG08un1Q.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-hoVZfVKv.js → lspLanguageFeatures-peGVtLxi.js} +1 -1
  16. package/out/renderer/assets/{mdx-C0s81MOq.js → mdx-DogBhUxZ.js} +1 -1
  17. package/out/renderer/assets/{python-CulkBOJr.js → python-Bf-INYXh.js} +1 -1
  18. package/out/renderer/assets/{razor-czmzhwVZ.js → razor-DLrZ2hsF.js} +1 -1
  19. package/out/renderer/assets/{tsMode-B90EqYGx.js → tsMode-B4oEmliC.js} +1 -1
  20. package/out/renderer/assets/{typescript-Ckc6emP2.js → typescript-CjkgfhVK.js} +1 -1
  21. package/out/renderer/assets/{xml-CKh-JyGN.js → xml-0FAXmuVg.js} +1 -1
  22. package/out/renderer/assets/{yaml-B49zLim4.js → yaml-DWxnPuy8.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/ctlsurfApi.ts +26 -0
  26. package/src/main/headless.ts +33 -28
  27. package/src/main/index.ts +8 -0
  28. package/src/main/orchestrator.ts +63 -2
  29. package/src/main/timeTracker.ts +223 -0
  30. package/src/main/tui.ts +25 -5
  31. package/src/preload/index.ts +7 -1
  32. package/src/renderer/App.tsx +36 -0
  33. package/src/renderer/components/SettingsDialog.tsx +38 -1
  34. package/src/renderer/components/TerminalPanel.tsx +25 -13
  35. package/src/renderer/styles.css +21 -0
@@ -7,6 +7,7 @@ import { AgentConfig, isCodingAgent } from './agents'
7
7
  import { CtlsurfApi } from './ctlsurfApi'
8
8
  import { ConversationBridge } from './bridge'
9
9
  import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
10
+ import { TimeTracker } from './timeTracker'
10
11
 
11
12
  function log(...args: unknown[]): void {
12
13
  try { console.log(...args) } catch { /* EPIPE safe */ }
@@ -19,8 +20,12 @@ export interface Profile {
19
20
  apiKey: string
20
21
  baseUrl: string
21
22
  dataspacePageId: string
23
+ trackTime?: boolean
24
+ idleTimeoutMin?: number
22
25
  }
23
26
 
27
+ const DEFAULT_IDLE_TIMEOUT_MIN = 15
28
+
24
29
  export interface SettingsData {
25
30
  activeProfile: string
26
31
  profiles: Record<string, Profile>
@@ -54,6 +59,8 @@ const DEFAULT_PROFILES: Record<string, Profile> = {
54
59
  apiKey: '',
55
60
  baseUrl: 'https://app.ctlsurf.com',
56
61
  dataspacePageId: '',
62
+ trackTime: true,
63
+ idleTimeoutMin: 15,
57
64
  },
58
65
  }
59
66
 
@@ -67,6 +74,7 @@ export class Orchestrator {
67
74
  readonly ctlsurfApi = new CtlsurfApi()
68
75
  readonly bridge = new ConversationBridge()
69
76
  readonly workerWs: WorkerWsClient
77
+ readonly timeTracker = new TimeTracker(this.ctlsurfApi)
70
78
 
71
79
  // State
72
80
  private tabs = new Map<string, TabState>()
@@ -222,6 +230,8 @@ export class Orchestrator {
222
230
  baseUrl: p.baseUrl,
223
231
  hasApiKey: !!p.apiKey,
224
232
  dataspacePageId: p.dataspacePageId || null,
233
+ trackTime: p.trackTime !== false,
234
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
225
235
  })),
226
236
  }
227
237
  }
@@ -235,16 +245,27 @@ export class Orchestrator {
235
245
  baseUrl: p.baseUrl,
236
246
  hasApiKey: !!p.apiKey,
237
247
  dataspacePageId: p.dataspacePageId || '',
248
+ trackTime: p.trackTime !== false,
249
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
238
250
  }
239
251
  }
240
252
 
241
- saveProfile(profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) {
253
+ saveProfile(profileId: string, data: {
254
+ name: string
255
+ apiKey?: string
256
+ baseUrl: string
257
+ dataspacePageId: string
258
+ trackTime?: boolean
259
+ idleTimeoutMin?: number
260
+ }) {
242
261
  const existing = this.settings.profiles[profileId]
243
262
  this.settings.profiles[profileId] = {
244
263
  name: data.name,
245
264
  apiKey: data.apiKey !== undefined ? data.apiKey : (existing?.apiKey || ''),
246
265
  baseUrl: data.baseUrl || 'https://app.ctlsurf.com',
247
266
  dataspacePageId: data.dataspacePageId || '',
267
+ trackTime: data.trackTime !== undefined ? data.trackTime : (existing?.trackTime !== false),
268
+ idleTimeoutMin: data.idleTimeoutMin !== undefined ? data.idleTimeoutMin : (existing?.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN),
248
269
  }
249
270
  this.saveSettings()
250
271
 
@@ -289,7 +310,7 @@ export class Orchestrator {
289
310
 
290
311
  // ─── PTY & Agent (multi-tab) ─────────────────────
291
312
 
292
- async spawnAgent(tabId: string, agent: AgentConfig, cwd: string): Promise<void> {
313
+ async spawnAgent(tabId: string, agent: AgentConfig, cwd: string, opts?: { trackTime?: boolean }): Promise<void> {
293
314
  // Kill existing PTY on this tab if any
294
315
  const existing = this.tabs.get(tabId)
295
316
  if (existing) {
@@ -312,6 +333,7 @@ export class Orchestrator {
312
333
 
313
334
  ptyManager.onData((data: string) => {
314
335
  this.events.onPtyData(tabId, data)
336
+ this.timeTracker.recordActivity(tabId)
315
337
  if (tabId === this.activeTabId) {
316
338
  this.bridge.feedOutput(data)
317
339
  this.streamTerminalData(tabId, data)
@@ -320,6 +342,7 @@ export class Orchestrator {
320
342
 
321
343
  ptyManager.onExit(async (exitCode: number) => {
322
344
  this.events.onPtyExit(tabId, exitCode)
345
+ await this.timeTracker.endSession(tabId)
323
346
  if (tabId === this.activeTabId) {
324
347
  this.bridge.endSession()
325
348
  if (this.currentAgent && isCodingAgent(this.currentAgent)) {
@@ -333,6 +356,17 @@ export class Orchestrator {
333
356
 
334
357
  this.bridge.startSession()
335
358
 
359
+ const profile = this.getActiveProfile()
360
+ const shouldTrack = opts?.trackTime !== undefined ? opts.trackTime : (profile.trackTime !== false)
361
+ if (shouldTrack) {
362
+ void this.timeTracker.startSession(
363
+ tabId,
364
+ cwd,
365
+ agent.name,
366
+ profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
367
+ )
368
+ }
369
+
336
370
  if (isCodingAgent(agent)) {
337
371
  this.connectWorkerWs(agent, cwd)
338
372
  } else {
@@ -360,6 +394,7 @@ export class Orchestrator {
360
394
  const tab = this.tabs.get(tabId)
361
395
  if (!tab) return
362
396
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
397
+ await this.timeTracker.endSession(tabId)
363
398
  tab.ptyManager.kill()
364
399
  this.tabs.delete(tabId)
365
400
  if (tabId === this.activeTabId) {
@@ -386,6 +421,31 @@ export class Orchestrator {
386
421
  return [...this.tabs.keys()]
387
422
  }
388
423
 
424
+ // ─── Tracking control (active tab) ──────────────
425
+
426
+ isActiveTabTracking(): boolean {
427
+ if (!this.activeTabId) return false
428
+ return this.timeTracker.isTracking(this.activeTabId)
429
+ }
430
+
431
+ async setActiveTabTracking(enabled: boolean): Promise<void> {
432
+ if (!this.activeTabId) return
433
+ const tab = this.tabs.get(this.activeTabId)
434
+ if (!tab) return
435
+ if (enabled) {
436
+ if (this.timeTracker.isTracking(this.activeTabId)) return
437
+ const profile = this.getActiveProfile()
438
+ await this.timeTracker.startSession(
439
+ this.activeTabId,
440
+ tab.cwd,
441
+ tab.agent.name,
442
+ profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
443
+ )
444
+ } else {
445
+ await this.timeTracker.endSession(this.activeTabId)
446
+ }
447
+ }
448
+
389
449
  // ─── Worker WebSocket ───────────────────────────
390
450
 
391
451
  connectWorkerWs(agent: AgentConfig, cwd: string): void {
@@ -437,6 +497,7 @@ export class Orchestrator {
437
497
 
438
498
  async shutdown(): Promise<void> {
439
499
  this.bridge.endSession()
500
+ await this.timeTracker.endAll()
440
501
  for (const [, tab] of this.tabs) {
441
502
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
442
503
  tab.ptyManager.kill()
@@ -0,0 +1,223 @@
1
+ import os from 'os'
2
+ import { randomUUID } from 'crypto'
3
+ import type { CtlsurfApi } from './ctlsurfApi'
4
+
5
+ const DATASTORE_TITLE = 'Time Tracking'
6
+ const AGENT_DATASTORE_PAGE_TITLE = 'Agent Datastore'
7
+ const FIRST_CHECKPOINT_DELAY_MS = 30 * 1000
8
+ const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1000
9
+
10
+ const COLUMNS: Array<{ name: string; type: string }> = [
11
+ { name: 'Started', type: 'date' },
12
+ { name: 'Active Time', type: 'number' },
13
+ { name: 'Agent', type: 'text' },
14
+ { name: 'Worker', type: 'text' },
15
+ { name: 'Session', type: 'text' },
16
+ { name: 'Notes', type: 'text' },
17
+ ]
18
+
19
+ interface SessionState {
20
+ blockId: string
21
+ rowId: string
22
+ cwd: string
23
+ startedAt: number
24
+ lastActivity: number
25
+ activeMs: number
26
+ idleTimeoutMs: number
27
+ firstCheckpointTimer: ReturnType<typeof setTimeout> | null
28
+ checkpointTimer: ReturnType<typeof setInterval> | null
29
+ ended: boolean
30
+ }
31
+
32
+ function log(...args: unknown[]): void {
33
+ try { console.log('[time-tracker]', ...args) } catch { /* EPIPE safe */ }
34
+ }
35
+
36
+ function findPageByTitle(pages: any[], title: string): any | null {
37
+ for (const p of pages) {
38
+ if (p?.title === title) return p
39
+ if (p?.children?.length) {
40
+ const c = findPageByTitle(p.children, title)
41
+ if (c) return c
42
+ }
43
+ }
44
+ return null
45
+ }
46
+
47
+ export class TimeTracker {
48
+ private api: CtlsurfApi
49
+ private sessions = new Map<string, SessionState>()
50
+ private blockCache = new Map<string, string>()
51
+
52
+ constructor(api: CtlsurfApi) {
53
+ this.api = api
54
+ }
55
+
56
+ async startSession(tabId: string, cwd: string, agentName: string, idleTimeoutMin: number): Promise<void> {
57
+ if (this.sessions.has(tabId)) {
58
+ await this.endSession(tabId)
59
+ }
60
+ try {
61
+ const blockId = await this.ensureDatastore(cwd)
62
+ if (!blockId) {
63
+ log(`No "${AGENT_DATASTORE_PAGE_TITLE}" page found for ${cwd} — tracking disabled for this session`)
64
+ return
65
+ }
66
+ const startedAt = Date.now()
67
+ const startedIso = new Date(startedAt).toISOString()
68
+ const sessionUuid = randomUUID()
69
+ const row = await this.api.addRow(blockId, {
70
+ Started: startedIso,
71
+ 'Active Time': 0,
72
+ Agent: agentName,
73
+ Worker: os.hostname(),
74
+ Session: sessionUuid,
75
+ Notes: '',
76
+ })
77
+ const rowId = row?.id
78
+ if (!rowId) {
79
+ log('addRow returned no id; aborting tracking', row)
80
+ return
81
+ }
82
+ const state: SessionState = {
83
+ blockId,
84
+ rowId,
85
+ cwd,
86
+ startedAt,
87
+ lastActivity: startedAt,
88
+ activeMs: 0,
89
+ idleTimeoutMs: Math.max(1, idleTimeoutMin) * 60 * 1000,
90
+ firstCheckpointTimer: null,
91
+ checkpointTimer: null,
92
+ ended: false,
93
+ }
94
+ state.firstCheckpointTimer = setTimeout(() => {
95
+ void this.checkpoint(tabId)
96
+ const live = this.sessions.get(tabId)
97
+ if (live && !live.ended) {
98
+ live.checkpointTimer = setInterval(() => {
99
+ void this.checkpoint(tabId)
100
+ }, CHECKPOINT_INTERVAL_MS)
101
+ }
102
+ }, FIRST_CHECKPOINT_DELAY_MS)
103
+ this.sessions.set(tabId, state)
104
+ log(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}`)
105
+ } catch (err: any) {
106
+ log(`startSession failed: ${err?.message || err}`)
107
+ }
108
+ }
109
+
110
+ isTracking(tabId: string): boolean {
111
+ const s = this.sessions.get(tabId)
112
+ return !!s && !s.ended
113
+ }
114
+
115
+ recordActivity(tabId: string): void {
116
+ const s = this.sessions.get(tabId)
117
+ if (!s || s.ended) return
118
+ const now = Date.now()
119
+ const delta = now - s.lastActivity
120
+ if (delta < s.idleTimeoutMs) {
121
+ s.activeMs += delta
122
+ }
123
+ s.lastActivity = now
124
+ }
125
+
126
+ async endSession(tabId: string): Promise<void> {
127
+ const s = this.sessions.get(tabId)
128
+ if (!s || s.ended) return
129
+ s.ended = true
130
+ if (s.firstCheckpointTimer) clearTimeout(s.firstCheckpointTimer)
131
+ if (s.checkpointTimer) clearInterval(s.checkpointTimer)
132
+ try {
133
+ await this.writeRow(s, Date.now())
134
+ } catch (err: any) {
135
+ log(`endSession write failed: ${err?.message || err}`)
136
+ }
137
+ this.sessions.delete(tabId)
138
+ }
139
+
140
+ async endAll(): Promise<void> {
141
+ const ids = [...this.sessions.keys()]
142
+ await Promise.all(ids.map(id => this.endSession(id)))
143
+ }
144
+
145
+ private async checkpoint(tabId: string): Promise<void> {
146
+ const s = this.sessions.get(tabId)
147
+ if (!s || s.ended) return
148
+ try {
149
+ await this.writeRow(s, Date.now())
150
+ } catch (err: any) {
151
+ log(`checkpoint failed: ${err?.message || err}`)
152
+ }
153
+ }
154
+
155
+ private async writeRow(s: SessionState, _endTimeMs: number): Promise<void> {
156
+ const activeMin = Math.round(s.activeMs / 60000)
157
+ await this.api.updateRow(s.blockId, s.rowId, {
158
+ 'Active Time': activeMin,
159
+ })
160
+ }
161
+
162
+ private async ensureDatastore(cwd: string): Promise<string | null> {
163
+ const cached = this.blockCache.get(cwd)
164
+ if (cached) return cached
165
+
166
+ let folder: any = null
167
+ try {
168
+ folder = await this.api.findFolderByPath(cwd)
169
+ } catch {
170
+ return null
171
+ }
172
+ if (!folder?.id) return null
173
+
174
+ const folderDetail = await this.api.getFolder(folder.id)
175
+ const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
176
+ if (!agentPage?.id) return null
177
+
178
+ const summaries = await this.api.getPageBlockSummaries(agentPage.id)
179
+ const existing = (summaries || []).find((b: any) => b?.type === 'datastore' && b?.title === DATASTORE_TITLE)
180
+ if (existing?.id) {
181
+ await this.ensureColumns(existing.id)
182
+ this.blockCache.set(cwd, existing.id)
183
+ return existing.id
184
+ }
185
+
186
+ const columns = COLUMNS.map((c, i) => ({ id: `col_${i}`, name: c.name, type: c.type }))
187
+ const created = await this.api.createBlock(agentPage.id, {
188
+ type: 'datastore',
189
+ title: DATASTORE_TITLE,
190
+ props: { columns },
191
+ })
192
+ if (created?.id) {
193
+ log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
194
+ this.blockCache.set(cwd, created.id)
195
+ return created.id
196
+ }
197
+ return null
198
+ }
199
+
200
+ private async ensureColumns(blockId: string): Promise<void> {
201
+ try {
202
+ const schema = await this.api.getDatastoreSchema(blockId)
203
+ const existingCols = schema.columns || []
204
+ const existingNames = new Set(existingCols.map(c => c.name))
205
+ const missing = COLUMNS.filter(c => !existingNames.has(c.name))
206
+ if (missing.length === 0) return
207
+
208
+ const usedIds = new Set(existingCols.map(c => c.id))
209
+ let nextIdx = existingCols.length
210
+ const appended = missing.map(c => {
211
+ let id = `col_${nextIdx++}`
212
+ while (usedIds.has(id)) id = `col_${nextIdx++}`
213
+ usedIds.add(id)
214
+ return { id, name: c.name, type: c.type }
215
+ })
216
+ const merged = [...existingCols, ...appended]
217
+ await this.api.updateDatastoreSchema(blockId, merged)
218
+ log(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map(c => c.name).join(', ')}`)
219
+ } catch (err: any) {
220
+ log(`ensureColumns failed: ${err?.message || err}`)
221
+ }
222
+ }
223
+ }
package/src/main/tui.ts CHANGED
@@ -132,11 +132,16 @@ export class Tui {
132
132
  * Show an interactive agent picker modal.
133
133
  * Uses alternate screen just for the picker, then exits back to normal.
134
134
  */
135
- showAgentPicker(agents: { name: string; description: string }[]): Promise<number> {
135
+ showAgentPicker(
136
+ agents: { name: string; description: string }[],
137
+ options: { initialTrackTime: boolean },
138
+ ): Promise<{ agentIdx: number; trackTime: boolean }> {
136
139
  return new Promise((resolve) => {
137
140
  let selected = 0
141
+ let trackTime = options.initialTrackTime
138
142
  const modalWidth = 44
139
- const modalHeight = agents.length + 4
143
+ // +4 for borders/title/sep, +2 for track-time separator + row
144
+ const modalHeight = agents.length + 4 + 2
140
145
  const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2))
141
146
  const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2))
142
147
 
@@ -180,10 +185,22 @@ export class Tui {
180
185
  this.write(`${CSI}${row};${startCol}H${bg}${FG_DIM}\u2502${RESET}${bg}${content}${pad}${RESET}${BG_MODAL}${FG_DIM}\u2502${RESET}`)
181
186
  }
182
187
 
183
- const botRow = startRow + 3 + agents.length
188
+ const innerSep = '\u251c' + '\u2500'.repeat(modalWidth - 2) + '┤'
189
+ const sepRow = startRow + 3 + agents.length
190
+ this.write(`${CSI}${sepRow};${startCol}H${BG_MODAL}${FG_DIM}${innerSep}${RESET}`)
191
+
192
+ const trackRow = sepRow + 1
193
+ const checkbox = trackTime ? `${FG_GREEN}[✓]${RESET}${BG_MODAL}` : `${FG_DIM}[ ]${RESET}${BG_MODAL}`
194
+ const trackLabelFg = trackTime ? FG_WHITE : FG_DIM
195
+ const trackContent = ` ${checkbox} ${trackLabelFg}Track time${RESET}${BG_MODAL}`
196
+ const trackContentLen = 2 + 3 + 1 + 'Track time'.length
197
+ const trackPad = ' '.repeat(Math.max(0, modalWidth - 2 - trackContentLen))
198
+ this.write(`${CSI}${trackRow};${startCol}H${BG_MODAL}${FG_DIM}│${RESET}${BG_MODAL}${trackContent}${trackPad}${FG_DIM}│${RESET}`)
199
+
200
+ const botRow = trackRow + 1
184
201
  this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
185
202
 
186
- const hint = '\u2191\u2193 navigate \u00B7 Enter select \u00B7 q quit'
203
+ const hint = '\u2191\u2193 navigate \u00B7 Enter select \u00B7 t track \u00B7 q quit'
187
204
  const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
188
205
  this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
189
206
  }
@@ -204,9 +221,12 @@ export class Tui {
204
221
  } else if (key === '\x1b[B' || key === 'j') {
205
222
  selected = (selected + 1) % agents.length
206
223
  drawModal()
224
+ } else if (key === 't' || key === 'T' || key === ' ') {
225
+ trackTime = !trackTime
226
+ drawModal()
207
227
  } else if (key === '\r' || key === '\n') {
208
228
  cleanup()
209
- resolve(selected)
229
+ resolve({ agentIdx: selected, trackTime })
210
230
  } else if (key === 'q' || key === '\x1b' || key === '\x03') {
211
231
  cleanup()
212
232
  this.write(`${CSI}?25h`)
@@ -60,13 +60,19 @@ const api = {
60
60
  ipcRenderer.invoke('profiles:list'),
61
61
  getProfile: (profileId: string) =>
62
62
  ipcRenderer.invoke('profiles:get', profileId),
63
- saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) =>
63
+ saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string; trackTime?: boolean; idleTimeoutMin?: number }) =>
64
64
  ipcRenderer.invoke('profiles:save', profileId, data),
65
65
  switchProfile: (profileId: string) =>
66
66
  ipcRenderer.invoke('profiles:switch', profileId),
67
67
  deleteProfile: (profileId: string) =>
68
68
  ipcRenderer.invoke('profiles:delete', profileId),
69
69
 
70
+ // Tracking (active tab)
71
+ getTracking: (): Promise<{ active: boolean }> =>
72
+ ipcRenderer.invoke('tracking:get'),
73
+ setTracking: (enabled: boolean): Promise<{ active: boolean }> =>
74
+ ipcRenderer.invoke('tracking:set', enabled),
75
+
70
76
  // Filesystem
71
77
  readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
72
78
  ipcRenderer.invoke('fs:readDir', dirPath),
@@ -44,6 +44,8 @@ declare global {
44
44
  saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) => Promise<{ ok: boolean }>
45
45
  switchProfile: (profileId: string) => Promise<{ ok: boolean }>
46
46
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
47
+ getTracking: () => Promise<{ active: boolean }>
48
+ setTracking: (enabled: boolean) => Promise<{ active: boolean }>
47
49
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
48
50
  getWebviewInfo: () => Promise<{
49
51
  frontendUrl: string; pageUrl?: string; authenticated: boolean;
@@ -100,6 +102,7 @@ export default function App() {
100
102
  return [{ id, label: 'Terminal 1', agent: null, agentStatus: 'idle' }]
101
103
  })
102
104
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
105
+ const [trackingActive, setTrackingActive] = useState(false)
103
106
 
104
107
  // Agent picker state: which tab is being configured (null = initial picker for first tab)
105
108
  const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
@@ -134,6 +137,30 @@ export default function App() {
134
137
  return unsub
135
138
  }, [])
136
139
 
140
+ // Reflect tracking state for the active tab; refresh on tab switch and poll
141
+ // periodically so the UI catches changes (e.g. a session ending naturally).
142
+ useEffect(() => {
143
+ let cancelled = false
144
+ const refresh = async () => {
145
+ try {
146
+ const r = await window.worker.getTracking()
147
+ if (!cancelled) setTrackingActive(!!r?.active)
148
+ } catch { /* ignore */ }
149
+ }
150
+ refresh()
151
+ const id = setInterval(refresh, 4000)
152
+ return () => { cancelled = true; clearInterval(id) }
153
+ }, [activeTabId])
154
+
155
+ const handleToggleTracking = useCallback(async () => {
156
+ try {
157
+ const r = await window.worker.setTracking(!trackingActive)
158
+ setTrackingActive(!!r?.active)
159
+ } catch (err) {
160
+ console.error('[tracking] toggle failed', err)
161
+ }
162
+ }, [trackingActive])
163
+
137
164
  const cwdRef = useRef<string | null>(null)
138
165
 
139
166
  const handleSpawn = useCallback(async (tabId: string, agent: AgentConfig) => {
@@ -354,6 +381,15 @@ export default function App() {
354
381
  <button className="titlebar-btn" onClick={() => setShowSettings(true)} title="Settings">
355
382
  Settings
356
383
  </button>
384
+ <button
385
+ className={`titlebar-btn titlebar-icon-btn ${trackingActive ? 'active' : ''}`}
386
+ onClick={handleToggleTracking}
387
+ title={trackingActive ? 'Time tracking on — click to stop' : 'Time tracking off — click to start'}
388
+ aria-label="Time tracking"
389
+ >
390
+ <span className="tracking-icon">⏱</span>
391
+ <span className={`tracking-dot ${trackingActive ? 'on' : 'off'}`} />
392
+ </button>
357
393
  <span className="titlebar-separator" />
358
394
  {agents.map(a => {
359
395
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -6,6 +6,8 @@ interface ProfileSummary {
6
6
  baseUrl: string
7
7
  hasApiKey: boolean
8
8
  dataspacePageId: string | null
9
+ trackTime?: boolean
10
+ idleTimeoutMin?: number
9
11
  }
10
12
 
11
13
  interface ProfileListResponse {
@@ -28,6 +30,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
28
30
  const [apiKey, setApiKey] = useState('')
29
31
  const [baseUrl, setBaseUrl] = useState('')
30
32
  const [dataspacePageId, setDataspacePageId] = useState('')
33
+ const [trackTime, setTrackTime] = useState(false)
34
+ const [idleTimeoutMin, setIdleTimeoutMin] = useState(15)
31
35
  const [saved, setSaved] = useState(false)
32
36
 
33
37
  const loadProfiles = async () => {
@@ -50,6 +54,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
50
54
  setApiKey('')
51
55
  setBaseUrl(profile.baseUrl)
52
56
  setDataspacePageId(profile.dataspacePageId || '')
57
+ setTrackTime(!!profile.trackTime)
58
+ setIdleTimeoutMin(profile.idleTimeoutMin ?? 15)
53
59
  setSaved(false)
54
60
  }
55
61
 
@@ -60,16 +66,20 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
60
66
  setApiKey('')
61
67
  setBaseUrl('http://localhost:8000')
62
68
  setDataspacePageId('')
69
+ setTrackTime(true)
70
+ setIdleTimeoutMin(15)
63
71
  setSaved(false)
64
72
  }
65
73
 
66
74
  const handleSave = async () => {
67
75
  if (!editingId || !name.trim()) return
68
76
 
69
- const data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string } = {
77
+ const data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string; trackTime?: boolean; idleTimeoutMin?: number } = {
70
78
  name: name.trim(),
71
79
  baseUrl: baseUrl.trim() || 'https://app.ctlsurf.com',
72
80
  dataspacePageId: dataspacePageId.trim(),
81
+ trackTime,
82
+ idleTimeoutMin: Math.max(1, Math.floor(idleTimeoutMin)) || 15,
73
83
  }
74
84
  // Only send apiKey if user typed something new
75
85
  if (apiKey.trim()) {
@@ -154,6 +164,33 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
154
164
  />
155
165
  </label>
156
166
 
167
+ <label className="settings-checkbox">
168
+ <input
169
+ type="checkbox"
170
+ checked={trackTime}
171
+ onChange={e => setTrackTime(e.target.checked)}
172
+ />
173
+ <span>Track time per project</span>
174
+ <span className="settings-hint">
175
+ Logs each session to a "Time Tracking" datastore on the project's Agent Datastore page.
176
+ </span>
177
+ </label>
178
+
179
+ {trackTime && (
180
+ <label>
181
+ Idle timeout (min)
182
+ <input
183
+ type="number"
184
+ min={1}
185
+ value={idleTimeoutMin}
186
+ onChange={e => setIdleTimeoutMin(parseInt(e.target.value, 10) || 15)}
187
+ />
188
+ <span className="settings-hint">
189
+ Gaps longer than this without terminal activity are counted as idle, not work.
190
+ </span>
191
+ </label>
192
+ )}
193
+
157
194
  <div className="settings-actions">
158
195
  <button className="btn-secondary" onClick={() => setEditingId(null)}>Back</button>
159
196
  <button className="btn-primary" onClick={handleSave}>
@@ -158,18 +158,22 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
158
158
  fitAddon.fit()
159
159
  scrollIfPinned(tabId, terminal)
160
160
 
161
- // Resize handling
161
+ // Resize handling — skip when the container is hidden (display:none → 0x0),
162
+ // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
163
+ // until the user switches back.
162
164
  let resizeTimeout: ReturnType<typeof setTimeout>
163
165
  const handleResize = () => {
164
166
  clearTimeout(resizeTimeout)
165
167
  resizeTimeout = setTimeout(() => {
168
+ const el = containerRef.current
169
+ if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return
166
170
  const state = _terminals.get(tabId)
167
- if (state) {
168
- state.fitAddon.fit()
169
- scrollIfPinned(tabId, state.terminal)
170
- const { cols, rows } = state.terminal
171
- window.worker.resizePty(tabId, cols, rows)
172
- }
171
+ if (!state) return
172
+ state.fitAddon.fit()
173
+ const { cols, rows } = state.terminal
174
+ if (cols < 10 || rows < 5) return
175
+ scrollIfPinned(tabId, state.terminal)
176
+ window.worker.resizePty(tabId, cols, rows)
173
177
  }, 150)
174
178
  }
175
179
 
@@ -208,15 +212,23 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
208
212
  })
209
213
  }, [tabId, agent, onSpawn])
210
214
 
211
- // Focus terminal when tab becomes active
215
+ // Focus terminal when tab becomes active — delay fit to let the container get its layout dimensions
212
216
  useEffect(() => {
213
217
  if (isActive) {
214
- const state = _terminals.get(tabId)
215
- if (state) {
216
- state.fitAddon.fit()
217
- state.terminal.focus()
218
- }
219
218
  window.worker.setActiveTab(tabId)
219
+ // Wait for the browser to layout the now-visible container before fitting
220
+ requestAnimationFrame(() => {
221
+ setTimeout(() => {
222
+ const state = _terminals.get(tabId)
223
+ if (state) {
224
+ state.fitAddon.fit()
225
+ state.terminal.focus()
226
+ scrollIfPinned(tabId, state.terminal)
227
+ const { cols, rows } = state.terminal
228
+ window.worker.resizePty(tabId, cols, rows)
229
+ }
230
+ }, 50)
231
+ })
220
232
  }
221
233
  }, [isActive, tabId])
222
234