@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.
- package/out/headless/index.mjs +409 -99
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +419 -77
- package/out/preload/index.js +12 -8
- package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-DiOmyihM.js} +3 -3
- package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BAfv60yb.js} +1 -1
- package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-Ult17NzQ.js} +1 -1
- package/out/renderer/assets/{html-Ds5-qvDh.js → html-DCxh4J-1.js} +1 -1
- package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-CQ5Xenrg.js} +3 -3
- package/out/renderer/assets/{index-DwSsD_Xm.js → index-BnCJ1IaZ.js} +308 -101
- package/out/renderer/assets/{index-DK9wLFFm.css → index-CrTu3Z4M.css} +132 -0
- package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-U5dsRcHx.js} +2 -2
- package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-DshPNyVy.js} +3 -3
- package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-jHHLYTlB.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-CUafmPGy.js} +1 -1
- package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-Ct-tiY6g.js} +1 -1
- package/out/renderer/assets/{python-BQDHXVwp.js → python-wD3UwKPV.js} +1 -1
- package/out/renderer/assets/{razor-BfXW9cDc.js → razor-11ECS4oH.js} +1 -1
- package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-D-7JexQ_.js} +1 -1
- package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Cvna1mak.js} +1 -1
- package/out/renderer/assets/{xml-B6EKhHiy.js → xml-JsEaImjA.js} +1 -1
- package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B8pCNDb_.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 +40 -34
- package/src/main/index.ts +95 -13
- package/src/main/orchestrator.ts +160 -55
- package/src/main/timeTracker.ts +223 -0
- package/src/main/tui.ts +25 -5
- package/src/preload/index.ts +23 -15
- package/src/renderer/App.tsx +197 -43
- package/src/renderer/components/SettingsDialog.tsx +38 -1
- package/src/renderer/components/TerminalPanel.tsx +109 -59
- package/src/renderer/styles.css +132 -0
package/src/preload/index.ts
CHANGED
|
@@ -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),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
87
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
155
|
-
}, [
|
|
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:
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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={
|
|
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}>
|