@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.
Files changed (31) hide show
  1. package/out/headless/index.mjs +91 -57
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +145 -63
  4. package/out/preload/index.js +9 -8
  5. package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-D3kH1Kju.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BCHZUSLb.js} +1 -1
  7. package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-DKx-Fw-H.js} +1 -1
  8. package/out/renderer/assets/{html-Ds5-qvDh.js → html-BSCM04uL.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-BucU1MUc.js} +3 -3
  10. package/out/renderer/assets/{index-DwSsD_Xm.js → index-BsdOeO0U.js} +230 -101
  11. package/out/renderer/assets/{index-DK9wLFFm.css → index-BzF7I1my.css} +111 -0
  12. package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-bPY5C4uq.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-BmJotb6E.js} +3 -3
  14. package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-Cja_Pzh3.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-hoVZfVKv.js} +1 -1
  16. package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-C0s81MOq.js} +1 -1
  17. package/out/renderer/assets/{python-BQDHXVwp.js → python-CulkBOJr.js} +1 -1
  18. package/out/renderer/assets/{razor-BfXW9cDc.js → razor-czmzhwVZ.js} +1 -1
  19. package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-B90EqYGx.js} +1 -1
  20. package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Ckc6emP2.js} +1 -1
  21. package/out/renderer/assets/{xml-B6EKhHiy.js → xml-CKh-JyGN.js} +1 -1
  22. package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B49zLim4.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/headless.ts +8 -7
  26. package/src/main/index.ts +87 -13
  27. package/src/main/orchestrator.ts +98 -54
  28. package/src/preload/index.ts +16 -14
  29. package/src/renderer/App.tsx +161 -43
  30. package/src/renderer/components/TerminalPanel.tsx +101 -59
  31. package/src/renderer/styles.css +111 -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
  },
@@ -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>
@@ -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
- // Track which panes are in the layout tree
87
- const visiblePaneIds = findPaneIds(layout)
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
- spawnGenRef.current += 1
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
- setSelectedAgent(agent)
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
- if (spawnGenRef.current === gen) setAgentStatus('exited')
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
- if (!newCwd || !selectedAgent) return
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
- spawnGenRef.current += 1
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(selectedAgent, newCwd)
155
- }, [selectedAgent])
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: <TerminalPanel agent={selectedAgent} onSpawn={handleSpawn} onExit={handleExit} />,
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
- <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
- ))}
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={(agent) => {
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
- // ─── Module-level singleton ──────────────────────────
22
- // Survives React mount/unmount cycles so PTY connection is never lost.
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
- let _terminal: Terminal | null = null
25
- let _fitAddon: FitAddon | null = null
26
- let _unsubData: (() => void) | null = null
27
- let _unsubExit: (() => void) | null = null
28
- let _currentAgentId: string | null = null
29
-
30
- // Scroll-pinning: true means user is at/near bottom, auto-scroll is allowed
31
- let _pinnedToBottom = true
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
- if (_pinnedToBottom) {
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
- if (_terminal && _fitAddon) {
46
- return { terminal: _terminal, fitAddon: _fitAddon }
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
- // Track scroll position — detect when user scrolls away from bottom
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
- _pinnedToBottom = isAtBottom(terminal)
100
+ state.pinnedToBottom = isAtBottom(terminal)
87
101
  })
88
102
 
89
- // Send keystrokes to pty — re-pin to bottom on user input
103
+ // Send keystrokes to pty
90
104
  terminal.onData((data) => {
91
- _pinnedToBottom = true
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 auto-scroll only if pinned
97
- _unsubData = window.worker.onPtyData((data) => {
98
- terminal.write(data)
99
- scrollIfPinned(terminal)
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
- _unsubExit = window.worker.onPtyExit((code) => {
104
- terminal.writeln(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m`)
105
- onExit()
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
- _terminal = terminal
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 (or reattach on remount)
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
- if (_fitAddon && _terminal) {
141
- _fitAddon.fit()
142
- scrollIfPinned(_terminal)
143
- const { cols, rows } = _terminal
144
- window.worker.resizePty(cols, rows)
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 || !_terminal) return
164
- if (_currentAgentId === agent.id) return
165
-
166
- _currentAgentId = agent.id
167
- _terminal.clear()
168
-
169
- onSpawn(agent).then(() => {
170
- if (_fitAddon && _terminal) {
171
- _fitAddon.fit()
172
- _pinnedToBottom = true
173
- _terminal.scrollToBottom()
174
- const { cols, rows } = _terminal
175
- window.worker.resizePty(cols, rows)
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
- _terminal?.focus()
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} />
@@ -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%;