@phenx-inc/ctlsurf 0.1.21 → 0.2.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 +91 -57
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +145 -63
- package/out/preload/index.js +9 -8
- package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-D3kH1Kju.js} +3 -3
- package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BCHZUSLb.js} +1 -1
- package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-DKx-Fw-H.js} +1 -1
- package/out/renderer/assets/{html-Ds5-qvDh.js → html-BSCM04uL.js} +1 -1
- package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-BucU1MUc.js} +3 -3
- package/out/renderer/assets/{index-DwSsD_Xm.js → index-BsdOeO0U.js} +230 -101
- package/out/renderer/assets/{index-DK9wLFFm.css → index-BzF7I1my.css} +111 -0
- package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-bPY5C4uq.js} +2 -2
- package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-BmJotb6E.js} +3 -3
- package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-Cja_Pzh3.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-hoVZfVKv.js} +1 -1
- package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-C0s81MOq.js} +1 -1
- package/out/renderer/assets/{python-BQDHXVwp.js → python-CulkBOJr.js} +1 -1
- package/out/renderer/assets/{razor-BfXW9cDc.js → razor-czmzhwVZ.js} +1 -1
- package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-B90EqYGx.js} +1 -1
- package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Ckc6emP2.js} +1 -1
- package/out/renderer/assets/{xml-B6EKhHiy.js → xml-CKh-JyGN.js} +1 -1
- package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B49zLim4.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/headless.ts +8 -7
- package/src/main/index.ts +87 -13
- package/src/main/orchestrator.ts +98 -54
- package/src/preload/index.ts +16 -14
- package/src/renderer/App.tsx +161 -43
- package/src/renderer/components/TerminalPanel.tsx +101 -59
- package/src/renderer/styles.css +111 -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
|
},
|
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>
|
|
@@ -72,10 +73,20 @@ const DEFAULT_LAYOUT: LayoutNode = {
|
|
|
72
73
|
|
|
73
74
|
const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
|
|
74
75
|
|
|
76
|
+
interface TabInfo {
|
|
77
|
+
id: string
|
|
78
|
+
label: string
|
|
79
|
+
agent: AgentConfig | null
|
|
80
|
+
agentStatus: 'idle' | 'active' | 'exited'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let _tabCounter = 0
|
|
84
|
+
function nextTabId(): string {
|
|
85
|
+
return `tab-${++_tabCounter}`
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
export default function App() {
|
|
76
89
|
const [agents, setAgents] = useState<AgentConfig[]>([])
|
|
77
|
-
const [selectedAgent, setSelectedAgent] = useState<AgentConfig | null>(null)
|
|
78
|
-
const [agentStatus, setAgentStatus] = useState<'idle' | 'active' | 'exited'>('idle')
|
|
79
90
|
const [sessionStart, setSessionStart] = useState<Date | null>(null)
|
|
80
91
|
const [layout, setLayout] = useState<LayoutNode>(DEFAULT_LAYOUT)
|
|
81
92
|
const [hiddenPanes, setHiddenPanes] = useState<Set<string>>(new Set())
|
|
@@ -83,11 +94,20 @@ export default function App() {
|
|
|
83
94
|
const [wsStatus, setWsStatus] = useState('disconnected')
|
|
84
95
|
const [cwd, setCwd] = useState<string | null>(null)
|
|
85
96
|
|
|
86
|
-
//
|
|
87
|
-
const
|
|
97
|
+
// Multi-tab state
|
|
98
|
+
const [tabs, setTabs] = useState<TabInfo[]>(() => {
|
|
99
|
+
const id = nextTabId()
|
|
100
|
+
return [{ id, label: 'Terminal 1', agent: null, agentStatus: 'idle' }]
|
|
101
|
+
})
|
|
102
|
+
const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
|
|
88
103
|
|
|
104
|
+
// Agent picker state: which tab is being configured (null = initial picker for first tab)
|
|
105
|
+
const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
|
|
89
106
|
const [showAgentPicker, setShowAgentPicker] = useState(true)
|
|
90
107
|
|
|
108
|
+
// Track which panes are in the layout tree
|
|
109
|
+
const visiblePaneIds = findPaneIds(layout)
|
|
110
|
+
|
|
91
111
|
useEffect(() => {
|
|
92
112
|
async function init() {
|
|
93
113
|
const list = await window.worker.listAgents()
|
|
@@ -114,45 +134,91 @@ export default function App() {
|
|
|
114
134
|
return unsub
|
|
115
135
|
}, [])
|
|
116
136
|
|
|
117
|
-
const spawnGenRef = useRef(0)
|
|
118
137
|
const cwdRef = useRef<string | null>(null)
|
|
119
138
|
|
|
120
|
-
const handleSpawn = useCallback(async (agent: AgentConfig) => {
|
|
121
|
-
|
|
122
|
-
setAgentStatus('active')
|
|
139
|
+
const handleSpawn = useCallback(async (tabId: string, agent: AgentConfig) => {
|
|
140
|
+
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, agentStatus: 'active' } : t))
|
|
123
141
|
setSessionStart(new Date())
|
|
124
142
|
const spawnCwd = cwdRef.current || await window.worker.getCwd().catch(() => window.worker.getHomePath())
|
|
125
143
|
setCwd(spawnCwd)
|
|
126
144
|
cwdRef.current = spawnCwd
|
|
127
|
-
await window.worker.spawnAgent(agent, spawnCwd)
|
|
145
|
+
await window.worker.spawnAgent(tabId, agent, spawnCwd)
|
|
128
146
|
}, [])
|
|
129
147
|
|
|
130
148
|
const handleAgentChange = useCallback(async (agentId: string) => {
|
|
131
149
|
const agent = agents.find(a => a.id === agentId)
|
|
132
150
|
if (agent) {
|
|
133
|
-
await window.worker.killPty()
|
|
134
|
-
|
|
151
|
+
await window.worker.killPty(activeTabId)
|
|
152
|
+
setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, agent, agentStatus: 'idle' } : t))
|
|
135
153
|
}
|
|
136
|
-
}, [agents])
|
|
154
|
+
}, [agents, activeTabId])
|
|
137
155
|
|
|
138
|
-
const handleExit = useCallback(() => {
|
|
139
|
-
const gen = spawnGenRef.current
|
|
156
|
+
const handleExit = useCallback((tabId: string) => {
|
|
140
157
|
setTimeout(() => {
|
|
141
|
-
|
|
158
|
+
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, agentStatus: 'exited' } : t))
|
|
142
159
|
}, 200)
|
|
143
160
|
}, [])
|
|
144
161
|
|
|
145
162
|
const handleChangeCwd = useCallback(async () => {
|
|
146
163
|
const newCwd = await window.worker.browseCwd()
|
|
147
|
-
|
|
164
|
+
const activeTab = tabs.find(t => t.id === activeTabId)
|
|
165
|
+
if (!newCwd || !activeTab?.agent) return
|
|
148
166
|
cwdRef.current = newCwd
|
|
149
167
|
setCwd(newCwd)
|
|
150
|
-
await window.worker.killPty()
|
|
151
|
-
|
|
152
|
-
setAgentStatus('active')
|
|
168
|
+
await window.worker.killPty(activeTabId)
|
|
169
|
+
setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, agentStatus: 'active' } : t))
|
|
153
170
|
setSessionStart(new Date())
|
|
154
|
-
await window.worker.spawnAgent(
|
|
155
|
-
}, [
|
|
171
|
+
await window.worker.spawnAgent(activeTabId, activeTab.agent, newCwd)
|
|
172
|
+
}, [tabs, activeTabId])
|
|
173
|
+
|
|
174
|
+
// ─── Tab management ─────────────────────────────
|
|
175
|
+
|
|
176
|
+
const addTab = useCallback(() => {
|
|
177
|
+
// Create the tab in idle state and show the agent picker for it
|
|
178
|
+
const id = nextTabId()
|
|
179
|
+
const num = _tabCounter
|
|
180
|
+
setTabs(prev => [...prev, { id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle' }])
|
|
181
|
+
setActiveTabId(id)
|
|
182
|
+
setPickerTargetTabId(id)
|
|
183
|
+
setShowAgentPicker(true)
|
|
184
|
+
}, [])
|
|
185
|
+
|
|
186
|
+
const handlePickerSelect = useCallback((agent: AgentConfig) => {
|
|
187
|
+
const tabId = pickerTargetTabId
|
|
188
|
+
if (tabId) {
|
|
189
|
+
setTabs(prev => prev.map(t =>
|
|
190
|
+
t.id === tabId ? { ...t, agent, label: `${agent.name} ${tabs.filter(x => x.agent?.id === agent.id).length + 1}` } : t
|
|
191
|
+
))
|
|
192
|
+
}
|
|
193
|
+
setShowAgentPicker(false)
|
|
194
|
+
setPickerTargetTabId(null)
|
|
195
|
+
}, [pickerTargetTabId, tabs])
|
|
196
|
+
|
|
197
|
+
const closeTab = useCallback(async (tabId: string) => {
|
|
198
|
+
await window.worker.killPty(tabId)
|
|
199
|
+
destroyTerminal(tabId)
|
|
200
|
+
setTabs(prev => {
|
|
201
|
+
const next = prev.filter(t => t.id !== tabId)
|
|
202
|
+
// If we closed the active tab, switch to the last remaining
|
|
203
|
+
if (tabId === activeTabId && next.length > 0) {
|
|
204
|
+
setActiveTabId(next[next.length - 1].id)
|
|
205
|
+
}
|
|
206
|
+
// If no tabs left, create a fresh one with the picker
|
|
207
|
+
if (next.length === 0) {
|
|
208
|
+
const id = nextTabId()
|
|
209
|
+
const num = _tabCounter
|
|
210
|
+
setActiveTabId(id)
|
|
211
|
+
setPickerTargetTabId(id)
|
|
212
|
+
setShowAgentPicker(true)
|
|
213
|
+
return [{ id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle' }]
|
|
214
|
+
}
|
|
215
|
+
return next
|
|
216
|
+
})
|
|
217
|
+
}, [activeTabId])
|
|
218
|
+
|
|
219
|
+
const switchTab = useCallback((tabId: string) => {
|
|
220
|
+
setActiveTabId(tabId)
|
|
221
|
+
}, [])
|
|
156
222
|
|
|
157
223
|
// Toggle pane: hide removes from layout tree, show re-inserts
|
|
158
224
|
const togglePane = useCallback((id: string) => {
|
|
@@ -223,7 +289,59 @@ export default function App() {
|
|
|
223
289
|
{
|
|
224
290
|
id: 'terminal',
|
|
225
291
|
label: 'Terminal',
|
|
226
|
-
content:
|
|
292
|
+
content: (
|
|
293
|
+
<div className="terminal-tabs-wrapper">
|
|
294
|
+
{/* Tab bar */}
|
|
295
|
+
<div className="terminal-tab-bar">
|
|
296
|
+
{tabs.map(tab => (
|
|
297
|
+
<div
|
|
298
|
+
key={tab.id}
|
|
299
|
+
className={`terminal-tab ${tab.id === activeTabId ? 'active' : ''}`}
|
|
300
|
+
onClick={() => switchTab(tab.id)}
|
|
301
|
+
>
|
|
302
|
+
<span className="terminal-tab-label">
|
|
303
|
+
{tab.agent ? tab.agent.name : tab.label}
|
|
304
|
+
{tab.agentStatus === 'active' && <span className="terminal-tab-dot active" />}
|
|
305
|
+
{tab.agentStatus === 'exited' && <span className="terminal-tab-dot exited" />}
|
|
306
|
+
</span>
|
|
307
|
+
{tabs.length > 1 && (
|
|
308
|
+
<button
|
|
309
|
+
className="terminal-tab-close"
|
|
310
|
+
onClick={(e) => { e.stopPropagation(); closeTab(tab.id) }}
|
|
311
|
+
title="Close tab"
|
|
312
|
+
>
|
|
313
|
+
×
|
|
314
|
+
</button>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
))}
|
|
318
|
+
<button className="terminal-tab-add" onClick={addTab} title="New terminal tab">
|
|
319
|
+
+
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
{/* Terminal panels — all mounted, only active visible */}
|
|
323
|
+
<div className="terminal-tabs-content">
|
|
324
|
+
{tabs.map(tab => (
|
|
325
|
+
<div
|
|
326
|
+
key={tab.id}
|
|
327
|
+
className="terminal-tab-panel"
|
|
328
|
+
style={{
|
|
329
|
+
display: tab.id === activeTabId ? 'flex' : 'none',
|
|
330
|
+
flex: 1,
|
|
331
|
+
}}
|
|
332
|
+
>
|
|
333
|
+
<TerminalPanel
|
|
334
|
+
tabId={tab.id}
|
|
335
|
+
agent={tab.agent}
|
|
336
|
+
onSpawn={handleSpawn}
|
|
337
|
+
onExit={handleExit}
|
|
338
|
+
isActive={tab.id === activeTabId}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
))}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
),
|
|
227
345
|
},
|
|
228
346
|
{ id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel /> },
|
|
229
347
|
]
|
|
@@ -237,16 +355,19 @@ export default function App() {
|
|
|
237
355
|
Settings
|
|
238
356
|
</button>
|
|
239
357
|
<span className="titlebar-separator" />
|
|
240
|
-
{agents.map(a =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
358
|
+
{agents.map(a => {
|
|
359
|
+
const activeTab = tabs.find(t => t.id === activeTabId)
|
|
360
|
+
return (
|
|
361
|
+
<button
|
|
362
|
+
key={a.id}
|
|
363
|
+
className={`titlebar-agent-btn ${activeTab?.agent?.id === a.id ? 'active' : ''}`}
|
|
364
|
+
onClick={() => handleAgentChange(a.id)}
|
|
365
|
+
title={a.description}
|
|
366
|
+
>
|
|
367
|
+
{a.name}
|
|
368
|
+
</button>
|
|
369
|
+
)
|
|
370
|
+
})}
|
|
250
371
|
<span className="titlebar-separator" />
|
|
251
372
|
{ALL_PANE_IDS.map(id => (
|
|
252
373
|
<button
|
|
@@ -283,10 +404,7 @@ export default function App() {
|
|
|
283
404
|
<AgentPicker
|
|
284
405
|
agents={agents}
|
|
285
406
|
cwd={cwd || ''}
|
|
286
|
-
onSelect={
|
|
287
|
-
setSelectedAgent(agent)
|
|
288
|
-
setShowAgentPicker(false)
|
|
289
|
-
}}
|
|
407
|
+
onSelect={handlePickerSelect}
|
|
290
408
|
onChangeCwd={async () => {
|
|
291
409
|
const newCwd = await window.worker.browseCwd()
|
|
292
410
|
if (newCwd) {
|
|
@@ -13,37 +13,42 @@ interface AgentConfig {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
interface TerminalPanelProps {
|
|
16
|
+
tabId: string
|
|
16
17
|
agent: AgentConfig | null
|
|
17
|
-
onSpawn: (agent: AgentConfig) => Promise<void>
|
|
18
|
-
onExit: () => void
|
|
18
|
+
onSpawn: (tabId: string, agent: AgentConfig) => Promise<void>
|
|
19
|
+
onExit: (tabId: string) => void
|
|
20
|
+
isActive: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
// ───
|
|
22
|
-
//
|
|
23
|
+
// ─── Per-tab terminal instances ──────────────────
|
|
24
|
+
// Keyed by tabId so each tab has its own xterm + pty listeners.
|
|
25
|
+
// Survives React re-renders but cleaned up when tab is closed.
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
const _terminals = new Map<string, {
|
|
28
|
+
terminal: Terminal
|
|
29
|
+
fitAddon: FitAddon
|
|
30
|
+
unsubData: (() => void) | null
|
|
31
|
+
unsubExit: (() => void) | null
|
|
32
|
+
spawnedAgentId: string | null
|
|
33
|
+
pinnedToBottom: boolean
|
|
34
|
+
}>()
|
|
32
35
|
|
|
33
36
|
function isAtBottom(terminal: Terminal): boolean {
|
|
34
37
|
const buf = terminal.buffer.active
|
|
35
38
|
return buf.viewportY >= buf.baseY
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
function scrollIfPinned(terminal: Terminal) {
|
|
39
|
-
|
|
41
|
+
function scrollIfPinned(tabId: string, terminal: Terminal) {
|
|
42
|
+
const state = _terminals.get(tabId)
|
|
43
|
+
if (state?.pinnedToBottom) {
|
|
40
44
|
terminal.scrollToBottom()
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
function getOrCreateTerminal(onExit: () => void): { terminal: Terminal; fitAddon: FitAddon } {
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): { terminal: Terminal; fitAddon: FitAddon } {
|
|
49
|
+
const existing = _terminals.get(tabId)
|
|
50
|
+
if (existing) {
|
|
51
|
+
return { terminal: existing.terminal, fitAddon: existing.fitAddon }
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
const terminal = new Terminal({
|
|
@@ -81,45 +86,66 @@ function getOrCreateTerminal(onExit: () => void): { terminal: Terminal; fitAddon
|
|
|
81
86
|
terminal.loadAddon(fitAddon)
|
|
82
87
|
terminal.loadAddon(new WebLinksAddon())
|
|
83
88
|
|
|
84
|
-
|
|
89
|
+
const state = {
|
|
90
|
+
terminal,
|
|
91
|
+
fitAddon,
|
|
92
|
+
unsubData: null as (() => void) | null,
|
|
93
|
+
unsubExit: null as (() => void) | null,
|
|
94
|
+
spawnedAgentId: null as string | null,
|
|
95
|
+
pinnedToBottom: true,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Track scroll position
|
|
85
99
|
terminal.onScroll(() => {
|
|
86
|
-
|
|
100
|
+
state.pinnedToBottom = isAtBottom(terminal)
|
|
87
101
|
})
|
|
88
102
|
|
|
89
|
-
// Send keystrokes to pty
|
|
103
|
+
// Send keystrokes to pty
|
|
90
104
|
terminal.onData((data) => {
|
|
91
|
-
|
|
105
|
+
state.pinnedToBottom = true
|
|
92
106
|
terminal.scrollToBottom()
|
|
93
|
-
window.worker.writePty(data)
|
|
107
|
+
window.worker.writePty(tabId, data)
|
|
94
108
|
})
|
|
95
109
|
|
|
96
|
-
// Receive pty output
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
// Receive pty output for this tab
|
|
111
|
+
state.unsubData = window.worker.onPtyData((eventTabId, data) => {
|
|
112
|
+
if (eventTabId === tabId) {
|
|
113
|
+
terminal.write(data)
|
|
114
|
+
scrollIfPinned(tabId, terminal)
|
|
115
|
+
}
|
|
100
116
|
})
|
|
101
117
|
|
|
102
|
-
// Handle pty exit
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
// Handle pty exit for this tab
|
|
119
|
+
state.unsubExit = window.worker.onPtyExit((eventTabId, code) => {
|
|
120
|
+
if (eventTabId === tabId) {
|
|
121
|
+
terminal.writeln(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m`)
|
|
122
|
+
onExit(tabId)
|
|
123
|
+
}
|
|
106
124
|
})
|
|
107
125
|
|
|
108
|
-
|
|
109
|
-
_fitAddon = fitAddon
|
|
126
|
+
_terminals.set(tabId, state)
|
|
110
127
|
return { terminal, fitAddon }
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
export function destroyTerminal(tabId: string): void {
|
|
131
|
+
const state = _terminals.get(tabId)
|
|
132
|
+
if (!state) return
|
|
133
|
+
state.unsubData?.()
|
|
134
|
+
state.unsubExit?.()
|
|
135
|
+
state.terminal.dispose()
|
|
136
|
+
_terminals.delete(tabId)
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
// ─── Component ───────────────────────────────────────
|
|
114
140
|
|
|
115
|
-
export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
|
|
141
|
+
export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: TerminalPanelProps) {
|
|
116
142
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
117
143
|
|
|
118
|
-
// Attach terminal to DOM container
|
|
144
|
+
// Attach terminal to DOM container
|
|
119
145
|
useEffect(() => {
|
|
120
146
|
if (!containerRef.current) return
|
|
121
147
|
|
|
122
|
-
const { terminal, fitAddon } = getOrCreateTerminal(onExit)
|
|
148
|
+
const { terminal, fitAddon } = getOrCreateTerminal(tabId, onExit)
|
|
123
149
|
|
|
124
150
|
// If terminal is already open in another container, move it
|
|
125
151
|
const existingParent = terminal.element?.parentElement
|
|
@@ -130,18 +156,19 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
|
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
fitAddon.fit()
|
|
133
|
-
scrollIfPinned(terminal)
|
|
159
|
+
scrollIfPinned(tabId, terminal)
|
|
134
160
|
|
|
135
161
|
// Resize handling
|
|
136
162
|
let resizeTimeout: ReturnType<typeof setTimeout>
|
|
137
163
|
const handleResize = () => {
|
|
138
164
|
clearTimeout(resizeTimeout)
|
|
139
165
|
resizeTimeout = setTimeout(() => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
const state = _terminals.get(tabId)
|
|
167
|
+
if (state) {
|
|
168
|
+
state.fitAddon.fit()
|
|
169
|
+
scrollIfPinned(tabId, state.terminal)
|
|
170
|
+
const { cols, rows } = state.terminal
|
|
171
|
+
window.worker.resizePty(tabId, cols, rows)
|
|
145
172
|
}
|
|
146
173
|
}, 150)
|
|
147
174
|
}
|
|
@@ -154,29 +181,44 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
|
|
|
154
181
|
clearTimeout(resizeTimeout)
|
|
155
182
|
window.removeEventListener('resize', handleResize)
|
|
156
183
|
observer.disconnect()
|
|
157
|
-
// Do NOT dispose terminal or unsub PTY listeners
|
|
184
|
+
// Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
|
|
158
185
|
}
|
|
159
|
-
}, [onExit])
|
|
186
|
+
}, [tabId, onExit])
|
|
160
187
|
|
|
161
|
-
// Spawn agent when it changes
|
|
188
|
+
// Spawn agent when it changes for this tab
|
|
162
189
|
useEffect(() => {
|
|
163
|
-
if (!agent
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
190
|
+
if (!agent) return
|
|
191
|
+
const state = _terminals.get(tabId)
|
|
192
|
+
if (!state) return
|
|
193
|
+
if (state.spawnedAgentId === agent.id) return
|
|
194
|
+
|
|
195
|
+
state.spawnedAgentId = agent.id
|
|
196
|
+
state.terminal.clear()
|
|
197
|
+
|
|
198
|
+
onSpawn(tabId, agent).then(() => {
|
|
199
|
+
const s = _terminals.get(tabId)
|
|
200
|
+
if (s) {
|
|
201
|
+
s.fitAddon.fit()
|
|
202
|
+
s.pinnedToBottom = true
|
|
203
|
+
s.terminal.scrollToBottom()
|
|
204
|
+
const { cols, rows } = s.terminal
|
|
205
|
+
window.worker.resizePty(tabId, cols, rows)
|
|
176
206
|
}
|
|
177
|
-
|
|
207
|
+
state.terminal.focus()
|
|
178
208
|
})
|
|
179
|
-
}, [agent, onSpawn])
|
|
209
|
+
}, [tabId, agent, onSpawn])
|
|
210
|
+
|
|
211
|
+
// Focus terminal when tab becomes active
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (isActive) {
|
|
214
|
+
const state = _terminals.get(tabId)
|
|
215
|
+
if (state) {
|
|
216
|
+
state.fitAddon.fit()
|
|
217
|
+
state.terminal.focus()
|
|
218
|
+
}
|
|
219
|
+
window.worker.setActiveTab(tabId)
|
|
220
|
+
}
|
|
221
|
+
}, [isActive, tabId])
|
|
180
222
|
|
|
181
223
|
return (
|
|
182
224
|
<div className="terminal-container" ref={containerRef} />
|
package/src/renderer/styles.css
CHANGED
|
@@ -118,6 +118,117 @@ html, body, #root {
|
|
|
118
118
|
overflow: hidden;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/* Terminal tabs wrapper */
|
|
122
|
+
.terminal-tabs-wrapper {
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
width: 100%;
|
|
126
|
+
height: 100%;
|
|
127
|
+
overflow: hidden;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.terminal-tab-bar {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 1px;
|
|
134
|
+
padding: 2px 4px;
|
|
135
|
+
background: #16161e;
|
|
136
|
+
border-bottom: 1px solid #292e42;
|
|
137
|
+
flex-shrink: 0;
|
|
138
|
+
overflow-x: auto;
|
|
139
|
+
min-height: 28px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.terminal-tab {
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 4px;
|
|
146
|
+
padding: 3px 8px;
|
|
147
|
+
font-size: 11px;
|
|
148
|
+
color: #565f89;
|
|
149
|
+
background: transparent;
|
|
150
|
+
border-radius: 4px;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
user-select: none;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.terminal-tab:hover {
|
|
157
|
+
background: #1f2335;
|
|
158
|
+
color: #a9b1d6;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.terminal-tab.active {
|
|
162
|
+
background: #1a1b26;
|
|
163
|
+
color: #c0caf5;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.terminal-tab-label {
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
gap: 4px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.terminal-tab-dot {
|
|
173
|
+
display: inline-block;
|
|
174
|
+
width: 6px;
|
|
175
|
+
height: 6px;
|
|
176
|
+
border-radius: 50%;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.terminal-tab-dot.active {
|
|
180
|
+
background: #9ece6a;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.terminal-tab-dot.exited {
|
|
184
|
+
background: #565f89;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.terminal-tab-close {
|
|
188
|
+
background: none;
|
|
189
|
+
border: none;
|
|
190
|
+
color: inherit;
|
|
191
|
+
font-size: 14px;
|
|
192
|
+
line-height: 1;
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
padding: 0 2px;
|
|
195
|
+
border-radius: 3px;
|
|
196
|
+
opacity: 0.5;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.terminal-tab-close:hover {
|
|
200
|
+
opacity: 1;
|
|
201
|
+
background: rgba(247, 118, 142, 0.2);
|
|
202
|
+
color: #f7768e;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.terminal-tab-add {
|
|
206
|
+
background: none;
|
|
207
|
+
border: none;
|
|
208
|
+
color: #565f89;
|
|
209
|
+
font-size: 16px;
|
|
210
|
+
cursor: pointer;
|
|
211
|
+
padding: 2px 6px;
|
|
212
|
+
border-radius: 4px;
|
|
213
|
+
line-height: 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.terminal-tab-add:hover {
|
|
217
|
+
background: #1f2335;
|
|
218
|
+
color: #a9b1d6;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.terminal-tabs-content {
|
|
222
|
+
flex: 1;
|
|
223
|
+
display: flex;
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.terminal-tab-panel {
|
|
228
|
+
flex-direction: column;
|
|
229
|
+
overflow: hidden;
|
|
230
|
+
}
|
|
231
|
+
|
|
121
232
|
/* Terminal container */
|
|
122
233
|
.terminal-container {
|
|
123
234
|
width: 100%;
|