@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.
- package/out/headless/index.mjs +320 -44
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +275 -15
- package/out/preload/index.js +3 -0
- package/out/renderer/assets/{cssMode-D3kH1Kju.js → cssMode-BW-SuYuP.js} +3 -3
- package/out/renderer/assets/{freemarker2-BCHZUSLb.js → freemarker2-2YWYzawi.js} +1 -1
- package/out/renderer/assets/{handlebars-DKx-Fw-H.js → handlebars-EwtUQRsf.js} +1 -1
- package/out/renderer/assets/{html-BSCM04uL.js → html-BNZkIDb9.js} +1 -1
- package/out/renderer/assets/{htmlMode-BucU1MUc.js → htmlMode-C2dZKrOy.js} +3 -3
- package/out/renderer/assets/{index-BsdOeO0U.js → index-Bm_rbVP-.js} +114 -34
- package/out/renderer/assets/{index-BzF7I1my.css → index-CrTu3Z4M.css} +21 -0
- package/out/renderer/assets/{javascript-bPY5C4uq.js → javascript-busdVZMv.js} +2 -2
- package/out/renderer/assets/{jsonMode-BmJotb6E.js → jsonMode-BaVI6jAw.js} +3 -3
- package/out/renderer/assets/{liquid-Cja_Pzh3.js → liquid-DG08un1Q.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-hoVZfVKv.js → lspLanguageFeatures-peGVtLxi.js} +1 -1
- package/out/renderer/assets/{mdx-C0s81MOq.js → mdx-DogBhUxZ.js} +1 -1
- package/out/renderer/assets/{python-CulkBOJr.js → python-Bf-INYXh.js} +1 -1
- package/out/renderer/assets/{razor-czmzhwVZ.js → razor-DLrZ2hsF.js} +1 -1
- package/out/renderer/assets/{tsMode-B90EqYGx.js → tsMode-B4oEmliC.js} +1 -1
- package/out/renderer/assets/{typescript-Ckc6emP2.js → typescript-CjkgfhVK.js} +1 -1
- package/out/renderer/assets/{xml-CKh-JyGN.js → xml-0FAXmuVg.js} +1 -1
- package/out/renderer/assets/{yaml-B49zLim4.js → yaml-DWxnPuy8.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/ctlsurfApi.ts +26 -0
- package/src/main/headless.ts +33 -28
- package/src/main/index.ts +8 -0
- package/src/main/orchestrator.ts +63 -2
- package/src/main/timeTracker.ts +223 -0
- package/src/main/tui.ts +25 -5
- package/src/preload/index.ts +7 -1
- package/src/renderer/App.tsx +36 -0
- package/src/renderer/components/SettingsDialog.tsx +38 -1
- package/src/renderer/components/TerminalPanel.tsx +25 -13
- package/src/renderer/styles.css +21 -0
package/src/main/orchestrator.ts
CHANGED
|
@@ -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: {
|
|
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(
|
|
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
|
-
|
|
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
|
|
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`)
|
package/src/preload/index.ts
CHANGED
|
@@ -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),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|