@phenx-inc/ctlsurf 0.1.21 → 0.3.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 (35) hide show
  1. package/out/headless/index.mjs +409 -99
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +419 -77
  4. package/out/preload/index.js +12 -8
  5. package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-DiOmyihM.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BAfv60yb.js} +1 -1
  7. package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-Ult17NzQ.js} +1 -1
  8. package/out/renderer/assets/{html-Ds5-qvDh.js → html-DCxh4J-1.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-CQ5Xenrg.js} +3 -3
  10. package/out/renderer/assets/{index-DwSsD_Xm.js → index-BnCJ1IaZ.js} +308 -101
  11. package/out/renderer/assets/{index-DK9wLFFm.css → index-CrTu3Z4M.css} +132 -0
  12. package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-U5dsRcHx.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-DshPNyVy.js} +3 -3
  14. package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-jHHLYTlB.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-CUafmPGy.js} +1 -1
  16. package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-Ct-tiY6g.js} +1 -1
  17. package/out/renderer/assets/{python-BQDHXVwp.js → python-wD3UwKPV.js} +1 -1
  18. package/out/renderer/assets/{razor-BfXW9cDc.js → razor-11ECS4oH.js} +1 -1
  19. package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-D-7JexQ_.js} +1 -1
  20. package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Cvna1mak.js} +1 -1
  21. package/out/renderer/assets/{xml-B6EKhHiy.js → xml-JsEaImjA.js} +1 -1
  22. package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B8pCNDb_.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 +40 -34
  27. package/src/main/index.ts +95 -13
  28. package/src/main/orchestrator.ts +160 -55
  29. package/src/main/timeTracker.ts +223 -0
  30. package/src/main/tui.ts +25 -5
  31. package/src/preload/index.ts +23 -15
  32. package/src/renderer/App.tsx +197 -43
  33. package/src/renderer/components/SettingsDialog.tsx +38 -1
  34. package/src/renderer/components/TerminalPanel.tsx +109 -59
  35. package/src/renderer/styles.css +132 -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>
@@ -30,14 +35,22 @@ export interface SettingsData {
30
35
  }
31
36
 
32
37
  export interface OrchestratorEvents {
33
- onPtyData: (data: string) => void
34
- onPtyExit: (code: number) => void
38
+ onPtyData: (tabId: string, data: string) => void
39
+ onPtyExit: (tabId: string, code: number) => void
35
40
  onWorkerStatus: (status: string) => void
36
41
  onWorkerMessage: (message: IncomingMessage) => void
37
42
  onWorkerRegistered: (data: { worker_id: string; folder_id: string | null; status: string }) => void
38
43
  onCwdChanged: () => void
39
44
  }
40
45
 
46
+ interface TabState {
47
+ ptyManager: PtyManager
48
+ agent: AgentConfig
49
+ cwd: string
50
+ termStreamBuffer: string
51
+ termStreamTimer: ReturnType<typeof setTimeout> | null
52
+ }
53
+
41
54
  // ─── Orchestrator ─────────────────────────────────
42
55
 
43
56
  const DEFAULT_PROFILES: Record<string, Profile> = {
@@ -46,6 +59,8 @@ const DEFAULT_PROFILES: Record<string, Profile> = {
46
59
  apiKey: '',
47
60
  baseUrl: 'https://app.ctlsurf.com',
48
61
  dataspacePageId: '',
62
+ trackTime: true,
63
+ idleTimeoutMin: 15,
49
64
  },
50
65
  }
51
66
 
@@ -59,9 +74,11 @@ export class Orchestrator {
59
74
  readonly ctlsurfApi = new CtlsurfApi()
60
75
  readonly bridge = new ConversationBridge()
61
76
  readonly workerWs: WorkerWsClient
77
+ readonly timeTracker = new TimeTracker(this.ctlsurfApi)
62
78
 
63
79
  // State
64
- private ptyManager: PtyManager | null = null
80
+ private tabs = new Map<string, TabState>()
81
+ private activeTabId: string | null = null
65
82
  private currentAgent: AgentConfig | null = null
66
83
  private currentCwd: string | null = null
67
84
  private settings: SettingsData = {
@@ -69,10 +86,6 @@ export class Orchestrator {
69
86
  profiles: { ...DEFAULT_PROFILES },
70
87
  }
71
88
 
72
- // Terminal stream batching
73
- private termStreamBuffer = ''
74
- private termStreamTimer: ReturnType<typeof setTimeout> | null = null
75
-
76
89
  constructor(settingsDir: string, events: OrchestratorEvents) {
77
90
  this.settingsDir = settingsDir
78
91
  this.events = events
@@ -88,8 +101,9 @@ export class Orchestrator {
88
101
  this.workerWs.sendAck(message.id)
89
102
 
90
103
  if (message.type === 'prompt' || message.type === 'task_dispatch') {
91
- if (this.ptyManager) {
92
- this.ptyManager.write(message.content + '\r')
104
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
105
+ if (activeTab) {
106
+ activeTab.ptyManager.write(message.content + '\r')
93
107
  this.bridge.feedInput(message.content)
94
108
  }
95
109
  }
@@ -102,7 +116,8 @@ export class Orchestrator {
102
116
  }
103
117
  },
104
118
  onTerminalInput: (data: string) => {
105
- this.ptyManager?.write(data)
119
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
120
+ activeTab?.ptyManager.write(data)
106
121
  },
107
122
  })
108
123
 
@@ -215,6 +230,8 @@ export class Orchestrator {
215
230
  baseUrl: p.baseUrl,
216
231
  hasApiKey: !!p.apiKey,
217
232
  dataspacePageId: p.dataspacePageId || null,
233
+ trackTime: p.trackTime !== false,
234
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
218
235
  })),
219
236
  }
220
237
  }
@@ -228,16 +245,27 @@ export class Orchestrator {
228
245
  baseUrl: p.baseUrl,
229
246
  hasApiKey: !!p.apiKey,
230
247
  dataspacePageId: p.dataspacePageId || '',
248
+ trackTime: p.trackTime !== false,
249
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN,
231
250
  }
232
251
  }
233
252
 
234
- 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
+ }) {
235
261
  const existing = this.settings.profiles[profileId]
236
262
  this.settings.profiles[profileId] = {
237
263
  name: data.name,
238
264
  apiKey: data.apiKey !== undefined ? data.apiKey : (existing?.apiKey || ''),
239
265
  baseUrl: data.baseUrl || 'https://app.ctlsurf.com',
240
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),
241
269
  }
242
270
  this.saveSettings()
243
271
 
@@ -280,41 +308,65 @@ export class Orchestrator {
280
308
  return { ok: true }
281
309
  }
282
310
 
283
- // ─── PTY & Agent ────────────────────────────────
311
+ // ─── PTY & Agent (multi-tab) ─────────────────────
284
312
 
285
- async spawnAgent(agent: AgentConfig, cwd: string): Promise<void> {
286
- if (this.ptyManager) {
287
- this.bridge.endSession()
288
- this.ptyManager.kill()
313
+ async spawnAgent(tabId: string, agent: AgentConfig, cwd: string, opts?: { trackTime?: boolean }): Promise<void> {
314
+ // Kill existing PTY on this tab if any
315
+ const existing = this.tabs.get(tabId)
316
+ if (existing) {
317
+ if (existing.termStreamTimer) clearTimeout(existing.termStreamTimer)
318
+ existing.ptyManager.kill()
319
+ this.tabs.delete(tabId)
289
320
  }
290
321
 
291
322
  this.currentAgent = agent
292
323
  const prevCwd = this.currentCwd
293
324
  this.currentCwd = cwd
325
+ this.activeTabId = tabId
294
326
  if (prevCwd !== cwd) {
295
327
  this.events.onCwdChanged()
296
328
  }
297
329
 
298
- this.ptyManager = new PtyManager(agent, cwd)
330
+ const ptyManager = new PtyManager(agent, cwd)
331
+ const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
332
+ this.tabs.set(tabId, tab)
299
333
 
300
- this.ptyManager.onData((data: string) => {
301
- this.events.onPtyData(data)
302
- this.bridge.feedOutput(data)
303
- this.streamTerminalData(data)
334
+ ptyManager.onData((data: string) => {
335
+ this.events.onPtyData(tabId, data)
336
+ this.timeTracker.recordActivity(tabId)
337
+ if (tabId === this.activeTabId) {
338
+ this.bridge.feedOutput(data)
339
+ this.streamTerminalData(tabId, data)
340
+ }
304
341
  })
305
342
 
306
- const thisPtyManager = this.ptyManager
307
-
308
- this.ptyManager.onExit(async (exitCode: number) => {
309
- this.events.onPtyExit(exitCode)
310
- this.bridge.endSession()
311
- if (thisPtyManager === this.ptyManager && this.currentAgent && isCodingAgent(this.currentAgent)) {
312
- this.workerWs.disconnect()
343
+ ptyManager.onExit(async (exitCode: number) => {
344
+ this.events.onPtyExit(tabId, exitCode)
345
+ await this.timeTracker.endSession(tabId)
346
+ if (tabId === this.activeTabId) {
347
+ this.bridge.endSession()
348
+ if (this.currentAgent && isCodingAgent(this.currentAgent)) {
349
+ this.workerWs.disconnect()
350
+ }
313
351
  }
352
+ // Clean up tab state
353
+ const t = this.tabs.get(tabId)
354
+ if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
314
355
  })
315
356
 
316
357
  this.bridge.startSession()
317
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
+
318
370
  if (isCodingAgent(agent)) {
319
371
  this.connectWorkerWs(agent, cwd)
320
372
  } else {
@@ -323,23 +375,74 @@ export class Orchestrator {
323
375
  }
324
376
  }
325
377
 
326
- writePty(data: string): void {
327
- this.ptyManager?.write(data)
328
- this.bridge.feedInput(data)
378
+ writePty(tabId: string, data: string): void {
379
+ this.tabs.get(tabId)?.ptyManager.write(data)
380
+ if (tabId === this.activeTabId) {
381
+ this.bridge.feedInput(data)
382
+ }
329
383
  }
330
384
 
331
- resizePty(cols: number, rows: number): void {
332
- this.ptyManager?.resize(cols, rows)
333
- this.bridge.resize(cols, rows)
334
- this.workerWs.sendTerminalResize(cols, rows)
385
+ resizePty(tabId: string, cols: number, rows: number): void {
386
+ this.tabs.get(tabId)?.ptyManager.resize(cols, rows)
387
+ if (tabId === this.activeTabId) {
388
+ this.bridge.resize(cols, rows)
389
+ this.workerWs.sendTerminalResize(cols, rows)
390
+ }
335
391
  }
336
392
 
337
- async killAgent(): Promise<void> {
338
- this.bridge.endSession()
339
- this.ptyManager?.kill()
340
- this.ptyManager = null
341
- if (this.currentAgent && isCodingAgent(this.currentAgent)) {
342
- this.workerWs.disconnect()
393
+ async killTab(tabId: string): Promise<void> {
394
+ const tab = this.tabs.get(tabId)
395
+ if (!tab) return
396
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
397
+ await this.timeTracker.endSession(tabId)
398
+ tab.ptyManager.kill()
399
+ this.tabs.delete(tabId)
400
+ if (tabId === this.activeTabId) {
401
+ this.bridge.endSession()
402
+ if (isCodingAgent(tab.agent)) {
403
+ this.workerWs.disconnect()
404
+ }
405
+ // Switch active to another tab if available
406
+ const remaining = [...this.tabs.keys()]
407
+ this.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1] : null
408
+ }
409
+ }
410
+
411
+ setActiveTab(tabId: string): void {
412
+ this.activeTabId = tabId
413
+ const tab = this.tabs.get(tabId)
414
+ if (tab) {
415
+ this.currentAgent = tab.agent
416
+ this.currentCwd = tab.cwd
417
+ }
418
+ }
419
+
420
+ getTabIds(): string[] {
421
+ return [...this.tabs.keys()]
422
+ }
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)
343
446
  }
344
447
  }
345
448
 
@@ -375,15 +478,17 @@ export class Orchestrator {
375
478
  }
376
479
  }
377
480
 
378
- private streamTerminalData(data: string): void {
379
- this.termStreamBuffer += data
380
- if (!this.termStreamTimer) {
381
- this.termStreamTimer = setTimeout(() => {
382
- if (this.termStreamBuffer) {
383
- this.workerWs.sendTerminalData(this.termStreamBuffer)
384
- this.termStreamBuffer = ''
481
+ private streamTerminalData(tabId: string, data: string): void {
482
+ const tab = this.tabs.get(tabId)
483
+ if (!tab) return
484
+ tab.termStreamBuffer += data
485
+ if (!tab.termStreamTimer) {
486
+ tab.termStreamTimer = setTimeout(() => {
487
+ if (tab.termStreamBuffer) {
488
+ this.workerWs.sendTerminalData(tab.termStreamBuffer)
489
+ tab.termStreamBuffer = ''
385
490
  }
386
- this.termStreamTimer = null
491
+ tab.termStreamTimer = null
387
492
  }, TERM_STREAM_INTERVAL_MS)
388
493
  }
389
494
  }
@@ -392,12 +497,12 @@ export class Orchestrator {
392
497
 
393
498
  async shutdown(): Promise<void> {
394
499
  this.bridge.endSession()
395
- this.ptyManager?.kill()
396
- this.ptyManager = null
397
- this.workerWs.disconnect()
398
- if (this.termStreamTimer) {
399
- clearTimeout(this.termStreamTimer)
400
- this.termStreamTimer = null
500
+ await this.timeTracker.endAll()
501
+ for (const [, tab] of this.tabs) {
502
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
503
+ tab.ptyManager.kill()
401
504
  }
505
+ this.tabs.clear()
506
+ this.workerWs.disconnect()
402
507
  }
403
508
  }
@@ -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`)