@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
@@ -9,24 +9,26 @@ export interface AgentConfig {
9
9
  }
10
10
 
11
11
  const api = {
12
- // Pty operations
13
- spawnAgent: (agent: AgentConfig, cwd: string) =>
14
- ipcRenderer.invoke('pty:spawn', agent, cwd),
15
- writePty: (data: string) =>
16
- ipcRenderer.invoke('pty:write', data),
17
- resizePty: (cols: number, rows: number) =>
18
- ipcRenderer.invoke('pty:resize', cols, rows),
19
- killPty: () =>
20
- ipcRenderer.invoke('pty:kill'),
12
+ // Pty operations (multi-tab: all take tabId)
13
+ spawnAgent: (tabId: string, agent: AgentConfig, cwd: string) =>
14
+ ipcRenderer.invoke('pty:spawn', tabId, agent, cwd),
15
+ writePty: (tabId: string, data: string) =>
16
+ ipcRenderer.invoke('pty:write', tabId, data),
17
+ resizePty: (tabId: string, cols: number, rows: number) =>
18
+ ipcRenderer.invoke('pty:resize', tabId, cols, rows),
19
+ killPty: (tabId: string) =>
20
+ ipcRenderer.invoke('pty:kill', tabId),
21
+ setActiveTab: (tabId: string) =>
22
+ ipcRenderer.invoke('pty:setActiveTab', tabId),
21
23
 
22
- // Pty events
23
- onPtyData: (callback: (data: string) => void) => {
24
- const listener = (_event: Electron.IpcRendererEvent, data: string) => callback(data)
24
+ // Pty events (multi-tab: callbacks receive tabId)
25
+ onPtyData: (callback: (tabId: string, data: string) => void) => {
26
+ const listener = (_event: Electron.IpcRendererEvent, tabId: string, data: string) => callback(tabId, data)
25
27
  ipcRenderer.on('pty:data', listener)
26
28
  return () => ipcRenderer.removeListener('pty:data', listener)
27
29
  },
28
- onPtyExit: (callback: (code: number) => void) => {
29
- const listener = (_event: Electron.IpcRendererEvent, code: number) => callback(code)
30
+ onPtyExit: (callback: (tabId: string, code: number) => void) => {
31
+ const listener = (_event: Electron.IpcRendererEvent, tabId: string, code: number) => callback(tabId, code)
30
32
  ipcRenderer.on('pty:exit', listener)
31
33
  return () => ipcRenderer.removeListener('pty:exit', listener)
32
34
  },
@@ -58,13 +60,19 @@ const api = {
58
60
  ipcRenderer.invoke('profiles:list'),
59
61
  getProfile: (profileId: string) =>
60
62
  ipcRenderer.invoke('profiles:get', profileId),
61
- 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 }) =>
62
64
  ipcRenderer.invoke('profiles:save', profileId, data),
63
65
  switchProfile: (profileId: string) =>
64
66
  ipcRenderer.invoke('profiles:switch', profileId),
65
67
  deleteProfile: (profileId: string) =>
66
68
  ipcRenderer.invoke('profiles:delete', profileId),
67
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
+
68
76
  // Filesystem
69
77
  readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
70
78
  ipcRenderer.invoke('fs:readDir', dirPath),
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useCallback, useRef } from 'react'
2
- import { TerminalPanel } from './components/TerminalPanel'
2
+ import { TerminalPanel, destroyTerminal } from './components/TerminalPanel'
3
3
  import { CtlsurfPanel } from './components/CtlsurfPanel'
4
4
  import { EditorPanel } from './components/EditorPanel'
5
5
  import { AgentPicker } from './components/AgentPicker'
@@ -24,12 +24,13 @@ interface AgentConfig {
24
24
  declare global {
25
25
  interface Window {
26
26
  worker: {
27
- spawnAgent: (agent: AgentConfig, cwd: string) => Promise<{ ok: boolean }>
28
- writePty: (data: string) => Promise<void>
29
- resizePty: (cols: number, rows: number) => Promise<void>
30
- killPty: () => Promise<void>
31
- onPtyData: (callback: (data: string) => void) => () => void
32
- onPtyExit: (callback: (code: number) => void) => () => void
27
+ spawnAgent: (tabId: string, agent: AgentConfig, cwd: string) => Promise<{ ok: boolean }>
28
+ writePty: (tabId: string, data: string) => Promise<void>
29
+ resizePty: (tabId: string, cols: number, rows: number) => Promise<void>
30
+ killPty: (tabId: string) => Promise<void>
31
+ setActiveTab: (tabId: string) => Promise<void>
32
+ onPtyData: (callback: (tabId: string, data: string) => void) => () => void
33
+ onPtyExit: (callback: (tabId: string, code: number) => void) => () => void
33
34
  listAgents: () => Promise<AgentConfig[]>
34
35
  getDefaultAgent: () => Promise<AgentConfig>
35
36
  getHomePath: () => Promise<string>
@@ -43,6 +44,8 @@ declare global {
43
44
  saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) => Promise<{ ok: boolean }>
44
45
  switchProfile: (profileId: string) => Promise<{ ok: boolean }>
45
46
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
47
+ getTracking: () => Promise<{ active: boolean }>
48
+ setTracking: (enabled: boolean) => Promise<{ active: boolean }>
46
49
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
47
50
  getWebviewInfo: () => Promise<{
48
51
  frontendUrl: string; pageUrl?: string; authenticated: boolean;
@@ -72,10 +75,20 @@ const DEFAULT_LAYOUT: LayoutNode = {
72
75
 
73
76
  const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
74
77
 
78
+ interface TabInfo {
79
+ id: string
80
+ label: string
81
+ agent: AgentConfig | null
82
+ agentStatus: 'idle' | 'active' | 'exited'
83
+ }
84
+
85
+ let _tabCounter = 0
86
+ function nextTabId(): string {
87
+ return `tab-${++_tabCounter}`
88
+ }
89
+
75
90
  export default function App() {
76
91
  const [agents, setAgents] = useState<AgentConfig[]>([])
77
- const [selectedAgent, setSelectedAgent] = useState<AgentConfig | null>(null)
78
- const [agentStatus, setAgentStatus] = useState<'idle' | 'active' | 'exited'>('idle')
79
92
  const [sessionStart, setSessionStart] = useState<Date | null>(null)
80
93
  const [layout, setLayout] = useState<LayoutNode>(DEFAULT_LAYOUT)
81
94
  const [hiddenPanes, setHiddenPanes] = useState<Set<string>>(new Set())
@@ -83,11 +96,21 @@ export default function App() {
83
96
  const [wsStatus, setWsStatus] = useState('disconnected')
84
97
  const [cwd, setCwd] = useState<string | null>(null)
85
98
 
86
- // Track which panes are in the layout tree
87
- const visiblePaneIds = findPaneIds(layout)
99
+ // Multi-tab state
100
+ const [tabs, setTabs] = useState<TabInfo[]>(() => {
101
+ const id = nextTabId()
102
+ return [{ id, label: 'Terminal 1', agent: null, agentStatus: 'idle' }]
103
+ })
104
+ const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
105
+ const [trackingActive, setTrackingActive] = useState(false)
88
106
 
107
+ // Agent picker state: which tab is being configured (null = initial picker for first tab)
108
+ const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
89
109
  const [showAgentPicker, setShowAgentPicker] = useState(true)
90
110
 
111
+ // Track which panes are in the layout tree
112
+ const visiblePaneIds = findPaneIds(layout)
113
+
91
114
  useEffect(() => {
92
115
  async function init() {
93
116
  const list = await window.worker.listAgents()
@@ -114,45 +137,115 @@ export default function App() {
114
137
  return unsub
115
138
  }, [])
116
139
 
117
- const spawnGenRef = useRef(0)
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
+
118
164
  const cwdRef = useRef<string | null>(null)
119
165
 
120
- const handleSpawn = useCallback(async (agent: AgentConfig) => {
121
- spawnGenRef.current += 1
122
- setAgentStatus('active')
166
+ const handleSpawn = useCallback(async (tabId: string, agent: AgentConfig) => {
167
+ setTabs(prev => prev.map(t => t.id === tabId ? { ...t, agentStatus: 'active' } : t))
123
168
  setSessionStart(new Date())
124
169
  const spawnCwd = cwdRef.current || await window.worker.getCwd().catch(() => window.worker.getHomePath())
125
170
  setCwd(spawnCwd)
126
171
  cwdRef.current = spawnCwd
127
- await window.worker.spawnAgent(agent, spawnCwd)
172
+ await window.worker.spawnAgent(tabId, agent, spawnCwd)
128
173
  }, [])
129
174
 
130
175
  const handleAgentChange = useCallback(async (agentId: string) => {
131
176
  const agent = agents.find(a => a.id === agentId)
132
177
  if (agent) {
133
- await window.worker.killPty()
134
- setSelectedAgent(agent)
178
+ await window.worker.killPty(activeTabId)
179
+ setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, agent, agentStatus: 'idle' } : t))
135
180
  }
136
- }, [agents])
181
+ }, [agents, activeTabId])
137
182
 
138
- const handleExit = useCallback(() => {
139
- const gen = spawnGenRef.current
183
+ const handleExit = useCallback((tabId: string) => {
140
184
  setTimeout(() => {
141
- if (spawnGenRef.current === gen) setAgentStatus('exited')
185
+ setTabs(prev => prev.map(t => t.id === tabId ? { ...t, agentStatus: 'exited' } : t))
142
186
  }, 200)
143
187
  }, [])
144
188
 
145
189
  const handleChangeCwd = useCallback(async () => {
146
190
  const newCwd = await window.worker.browseCwd()
147
- if (!newCwd || !selectedAgent) return
191
+ const activeTab = tabs.find(t => t.id === activeTabId)
192
+ if (!newCwd || !activeTab?.agent) return
148
193
  cwdRef.current = newCwd
149
194
  setCwd(newCwd)
150
- await window.worker.killPty()
151
- spawnGenRef.current += 1
152
- setAgentStatus('active')
195
+ await window.worker.killPty(activeTabId)
196
+ setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, agentStatus: 'active' } : t))
153
197
  setSessionStart(new Date())
154
- await window.worker.spawnAgent(selectedAgent, newCwd)
155
- }, [selectedAgent])
198
+ await window.worker.spawnAgent(activeTabId, activeTab.agent, newCwd)
199
+ }, [tabs, activeTabId])
200
+
201
+ // ─── Tab management ─────────────────────────────
202
+
203
+ const addTab = useCallback(() => {
204
+ // Create the tab in idle state and show the agent picker for it
205
+ const id = nextTabId()
206
+ const num = _tabCounter
207
+ setTabs(prev => [...prev, { id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle' }])
208
+ setActiveTabId(id)
209
+ setPickerTargetTabId(id)
210
+ setShowAgentPicker(true)
211
+ }, [])
212
+
213
+ const handlePickerSelect = useCallback((agent: AgentConfig) => {
214
+ const tabId = pickerTargetTabId
215
+ if (tabId) {
216
+ setTabs(prev => prev.map(t =>
217
+ t.id === tabId ? { ...t, agent, label: `${agent.name} ${tabs.filter(x => x.agent?.id === agent.id).length + 1}` } : t
218
+ ))
219
+ }
220
+ setShowAgentPicker(false)
221
+ setPickerTargetTabId(null)
222
+ }, [pickerTargetTabId, tabs])
223
+
224
+ const closeTab = useCallback(async (tabId: string) => {
225
+ await window.worker.killPty(tabId)
226
+ destroyTerminal(tabId)
227
+ setTabs(prev => {
228
+ const next = prev.filter(t => t.id !== tabId)
229
+ // If we closed the active tab, switch to the last remaining
230
+ if (tabId === activeTabId && next.length > 0) {
231
+ setActiveTabId(next[next.length - 1].id)
232
+ }
233
+ // If no tabs left, create a fresh one with the picker
234
+ if (next.length === 0) {
235
+ const id = nextTabId()
236
+ const num = _tabCounter
237
+ setActiveTabId(id)
238
+ setPickerTargetTabId(id)
239
+ setShowAgentPicker(true)
240
+ return [{ id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle' }]
241
+ }
242
+ return next
243
+ })
244
+ }, [activeTabId])
245
+
246
+ const switchTab = useCallback((tabId: string) => {
247
+ setActiveTabId(tabId)
248
+ }, [])
156
249
 
157
250
  // Toggle pane: hide removes from layout tree, show re-inserts
158
251
  const togglePane = useCallback((id: string) => {
@@ -223,7 +316,59 @@ export default function App() {
223
316
  {
224
317
  id: 'terminal',
225
318
  label: 'Terminal',
226
- content: <TerminalPanel agent={selectedAgent} onSpawn={handleSpawn} onExit={handleExit} />,
319
+ content: (
320
+ <div className="terminal-tabs-wrapper">
321
+ {/* Tab bar */}
322
+ <div className="terminal-tab-bar">
323
+ {tabs.map(tab => (
324
+ <div
325
+ key={tab.id}
326
+ className={`terminal-tab ${tab.id === activeTabId ? 'active' : ''}`}
327
+ onClick={() => switchTab(tab.id)}
328
+ >
329
+ <span className="terminal-tab-label">
330
+ {tab.agent ? tab.agent.name : tab.label}
331
+ {tab.agentStatus === 'active' && <span className="terminal-tab-dot active" />}
332
+ {tab.agentStatus === 'exited' && <span className="terminal-tab-dot exited" />}
333
+ </span>
334
+ {tabs.length > 1 && (
335
+ <button
336
+ className="terminal-tab-close"
337
+ onClick={(e) => { e.stopPropagation(); closeTab(tab.id) }}
338
+ title="Close tab"
339
+ >
340
+ ×
341
+ </button>
342
+ )}
343
+ </div>
344
+ ))}
345
+ <button className="terminal-tab-add" onClick={addTab} title="New terminal tab">
346
+ +
347
+ </button>
348
+ </div>
349
+ {/* Terminal panels — all mounted, only active visible */}
350
+ <div className="terminal-tabs-content">
351
+ {tabs.map(tab => (
352
+ <div
353
+ key={tab.id}
354
+ className="terminal-tab-panel"
355
+ style={{
356
+ display: tab.id === activeTabId ? 'flex' : 'none',
357
+ flex: 1,
358
+ }}
359
+ >
360
+ <TerminalPanel
361
+ tabId={tab.id}
362
+ agent={tab.agent}
363
+ onSpawn={handleSpawn}
364
+ onExit={handleExit}
365
+ isActive={tab.id === activeTabId}
366
+ />
367
+ </div>
368
+ ))}
369
+ </div>
370
+ </div>
371
+ ),
227
372
  },
228
373
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel /> },
229
374
  ]
@@ -236,17 +381,29 @@ export default function App() {
236
381
  <button className="titlebar-btn" onClick={() => setShowSettings(true)} title="Settings">
237
382
  Settings
238
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>
239
393
  <span className="titlebar-separator" />
240
- {agents.map(a => (
241
- <button
242
- key={a.id}
243
- className={`titlebar-agent-btn ${selectedAgent?.id === a.id ? 'active' : ''}`}
244
- onClick={() => handleAgentChange(a.id)}
245
- title={a.description}
246
- >
247
- {a.name}
248
- </button>
249
- ))}
394
+ {agents.map(a => {
395
+ const activeTab = tabs.find(t => t.id === activeTabId)
396
+ return (
397
+ <button
398
+ key={a.id}
399
+ className={`titlebar-agent-btn ${activeTab?.agent?.id === a.id ? 'active' : ''}`}
400
+ onClick={() => handleAgentChange(a.id)}
401
+ title={a.description}
402
+ >
403
+ {a.name}
404
+ </button>
405
+ )
406
+ })}
250
407
  <span className="titlebar-separator" />
251
408
  {ALL_PANE_IDS.map(id => (
252
409
  <button
@@ -283,10 +440,7 @@ export default function App() {
283
440
  <AgentPicker
284
441
  agents={agents}
285
442
  cwd={cwd || ''}
286
- onSelect={(agent) => {
287
- setSelectedAgent(agent)
288
- setShowAgentPicker(false)
289
- }}
443
+ onSelect={handlePickerSelect}
290
444
  onChangeCwd={async () => {
291
445
  const newCwd = await window.worker.browseCwd()
292
446
  if (newCwd) {
@@ -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}>