@phenx-inc/ctlsurf 0.1.20 → 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-Cxe23-tB.js → cssMode-D3kH1Kju.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-Be0nj7Oa.js → freemarker2-BCHZUSLb.js} +1 -1
  7. package/out/renderer/assets/{handlebars-C0It7_Nu.js → handlebars-DKx-Fw-H.js} +1 -1
  8. package/out/renderer/assets/{html-BW6LB-7J.js → html-BSCM04uL.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-D_V-1VlE.js → htmlMode-BucU1MUc.js} +3 -3
  10. package/out/renderer/assets/{index-D568SpEt.js → index-BsdOeO0U.js} +240 -94
  11. package/out/renderer/assets/{index-DK9wLFFm.css → index-BzF7I1my.css} +111 -0
  12. package/out/renderer/assets/{javascript-D_LoeNc7.js → javascript-bPY5C4uq.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-K3WSinSE.js → jsonMode-BmJotb6E.js} +3 -3
  14. package/out/renderer/assets/{liquid-BqfOd6m8.js → liquid-Cja_Pzh3.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Bjf28WU6.js → lspLanguageFeatures-hoVZfVKv.js} +1 -1
  16. package/out/renderer/assets/{mdx-BoESjI38.js → mdx-C0s81MOq.js} +1 -1
  17. package/out/renderer/assets/{python-DlafOOgB.js → python-CulkBOJr.js} +1 -1
  18. package/out/renderer/assets/{razor-CB6E9DBD.js → razor-czmzhwVZ.js} +1 -1
  19. package/out/renderer/assets/{tsMode-DYu1z_nn.js → tsMode-B90EqYGx.js} +1 -1
  20. package/out/renderer/assets/{typescript-CDjkh0d5.js → typescript-Ckc6emP2.js} +1 -1
  21. package/out/renderer/assets/{xml-C-XlilKZ.js → xml-CKh-JyGN.js} +1 -1
  22. package/out/renderer/assets/{yaml-BTrtxLEo.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 +113 -48
  31. package/src/renderer/styles.css +111 -0
@@ -7504,6 +7504,117 @@ html, body, #root {
7504
7504
  overflow: hidden;
7505
7505
  }
7506
7506
 
7507
+ /* Terminal tabs wrapper */
7508
+ .terminal-tabs-wrapper {
7509
+ display: flex;
7510
+ flex-direction: column;
7511
+ width: 100%;
7512
+ height: 100%;
7513
+ overflow: hidden;
7514
+ }
7515
+
7516
+ .terminal-tab-bar {
7517
+ display: flex;
7518
+ align-items: center;
7519
+ gap: 1px;
7520
+ padding: 2px 4px;
7521
+ background: #16161e;
7522
+ border-bottom: 1px solid #292e42;
7523
+ flex-shrink: 0;
7524
+ overflow-x: auto;
7525
+ min-height: 28px;
7526
+ }
7527
+
7528
+ .terminal-tab {
7529
+ display: flex;
7530
+ align-items: center;
7531
+ gap: 4px;
7532
+ padding: 3px 8px;
7533
+ font-size: 11px;
7534
+ color: #565f89;
7535
+ background: transparent;
7536
+ border-radius: 4px;
7537
+ cursor: pointer;
7538
+ white-space: nowrap;
7539
+ user-select: none;
7540
+ }
7541
+
7542
+ .terminal-tab:hover {
7543
+ background: #1f2335;
7544
+ color: #a9b1d6;
7545
+ }
7546
+
7547
+ .terminal-tab.active {
7548
+ background: #1a1b26;
7549
+ color: #c0caf5;
7550
+ }
7551
+
7552
+ .terminal-tab-label {
7553
+ display: flex;
7554
+ align-items: center;
7555
+ gap: 4px;
7556
+ }
7557
+
7558
+ .terminal-tab-dot {
7559
+ display: inline-block;
7560
+ width: 6px;
7561
+ height: 6px;
7562
+ border-radius: 50%;
7563
+ }
7564
+
7565
+ .terminal-tab-dot.active {
7566
+ background: #9ece6a;
7567
+ }
7568
+
7569
+ .terminal-tab-dot.exited {
7570
+ background: #565f89;
7571
+ }
7572
+
7573
+ .terminal-tab-close {
7574
+ background: none;
7575
+ border: none;
7576
+ color: inherit;
7577
+ font-size: 14px;
7578
+ line-height: 1;
7579
+ cursor: pointer;
7580
+ padding: 0 2px;
7581
+ border-radius: 3px;
7582
+ opacity: 0.5;
7583
+ }
7584
+
7585
+ .terminal-tab-close:hover {
7586
+ opacity: 1;
7587
+ background: rgba(247, 118, 142, 0.2);
7588
+ color: #f7768e;
7589
+ }
7590
+
7591
+ .terminal-tab-add {
7592
+ background: none;
7593
+ border: none;
7594
+ color: #565f89;
7595
+ font-size: 16px;
7596
+ cursor: pointer;
7597
+ padding: 2px 6px;
7598
+ border-radius: 4px;
7599
+ line-height: 1;
7600
+ }
7601
+
7602
+ .terminal-tab-add:hover {
7603
+ background: #1f2335;
7604
+ color: #a9b1d6;
7605
+ }
7606
+
7607
+ .terminal-tabs-content {
7608
+ flex: 1;
7609
+ display: flex;
7610
+ overflow: hidden;
7611
+ }
7612
+
7613
+ .terminal-tab-panel {
7614
+ flex-direction: column;
7615
+ overflow: hidden;
7616
+ }
7617
+
7507
7618
  /* Terminal container */
7508
7619
  .terminal-container {
7509
7620
  width: 100%;
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-CDjkh0d5.js";
2
- import "./index-D568SpEt.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-Ckc6emP2.js";
2
+ import "./index-BsdOeO0U.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-D568SpEt.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-Bjf28WU6.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Bjf28WU6.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-BsdOeO0U.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-hoVZfVKv.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-hoVZfVKv.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-D568SpEt.js";
1
+ import { l as languages } from "./index-BsdOeO0U.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-D568SpEt.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-BsdOeO0U.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-D568SpEt.js";
1
+ import { l as languages } from "./index-BsdOeO0U.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-D568SpEt.js";
1
+ import { l as languages } from "./index-BsdOeO0U.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-D568SpEt.js";
1
+ import { l as languages } from "./index-BsdOeO0U.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-D568SpEt.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-BsdOeO0U.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-D568SpEt.js";
1
+ import { l as languages } from "./index-BsdOeO0U.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-D568SpEt.js";
1
+ import { l as languages } from "./index-BsdOeO0U.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-D568SpEt.js";
1
+ import { l as languages } from "./index-BsdOeO0U.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ctlsurf-worker</title>
7
- <script type="module" crossorigin src="./assets/index-D568SpEt.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-DK9wLFFm.css">
7
+ <script type="module" crossorigin src="./assets/index-BsdOeO0U.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-BzF7I1my.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.1.20",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
@@ -99,10 +99,10 @@ async function main() {
99
99
  tui.init()
100
100
 
101
101
  const orchestrator = new Orchestrator(settingsDir, {
102
- onPtyData: (data) => {
102
+ onPtyData: (_tabId, data) => {
103
103
  tui.writePtyData(data)
104
104
  },
105
- onPtyExit: (code) => {
105
+ onPtyExit: (_tabId, code) => {
106
106
  tui.destroy()
107
107
  console.log(`Agent exited with code ${code}`)
108
108
  orchestrator.shutdown().then(() => process.exit(code))
@@ -126,14 +126,15 @@ async function main() {
126
126
  if (args.baseUrl) orchestrator.overrideBaseUrl(args.baseUrl)
127
127
 
128
128
  // Spawn agent with PTY sized to fit the TUI content area
129
+ const HEADLESS_TAB = 'headless'
129
130
  const ptySize = tui.getPtySize()
130
- await orchestrator.spawnAgent(agent, args.cwd)
131
- orchestrator.resizePty(ptySize.cols, ptySize.rows)
131
+ await orchestrator.spawnAgent(HEADLESS_TAB, agent, args.cwd)
132
+ orchestrator.resizePty(HEADLESS_TAB, ptySize.cols, ptySize.rows)
132
133
 
133
134
  // For coding agents, send an initial prompt to kick-start them
134
135
  if (isCodingAgent(agent)) {
135
136
  setTimeout(() => {
136
- orchestrator.writePty('hello\r')
137
+ orchestrator.writePty(HEADLESS_TAB, 'hello\r')
137
138
  }, 1000)
138
139
  }
139
140
 
@@ -160,7 +161,7 @@ async function main() {
160
161
  if (SCROLL_UP_RE.test(str) || SCROLL_DOWN_RE.test(str)) {
161
162
  return
162
163
  }
163
- orchestrator.writePty(str)
164
+ orchestrator.writePty(HEADLESS_TAB, str)
164
165
  })
165
166
  }
166
167
 
@@ -170,7 +171,7 @@ async function main() {
170
171
  const rows = process.stdout.rows || 24
171
172
  tui.resize(cols, rows)
172
173
  const size = tui.getPtySize()
173
- orchestrator.resizePty(size.cols, size.rows)
174
+ orchestrator.resizePty(HEADLESS_TAB, size.cols, size.rows)
174
175
  })
175
176
 
176
177
  // Graceful shutdown
package/src/main/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, ipcMain, dialog } from 'electron'
1
+ import { app, BrowserWindow, ipcMain, dialog, nativeImage } from 'electron'
2
2
  import path from 'path'
3
3
  import fs from 'fs'
4
4
 
@@ -19,17 +19,88 @@ import { Orchestrator } from './orchestrator'
19
19
 
20
20
  let mainWindow: BrowserWindow | null = null
21
21
 
22
+ // ─── Project badge on dock/taskbar icon ──────────
23
+
24
+ function getProjectAbbrev(cwdPath: string): string {
25
+ const folderName = cwdPath.split('/').filter(Boolean).pop() || ''
26
+ if (!folderName) return ''
27
+
28
+ // Split on hyphens, underscores, dots, camelCase boundaries
29
+ const words = folderName
30
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → separate words
31
+ .split(/[-_.\s]+/)
32
+ .filter(Boolean)
33
+
34
+ if (words.length >= 2) {
35
+ // Multiple words → take initials (up to 4)
36
+ return words.slice(0, 4).map(w => w[0]).join('').toUpperCase()
37
+ }
38
+
39
+ // Single word: if short (<=3 chars) use as-is, otherwise first 2 chars
40
+ const word = words[0]
41
+ if (word.length <= 3) return word.toUpperCase()
42
+ return word.slice(0, 2).toUpperCase()
43
+ }
44
+
45
+ function createOverlayIcon(text: string): Electron.NativeImage {
46
+ // Create a 32x32 overlay with text for Windows/Linux
47
+ const size = 32
48
+ const canvas = Buffer.alloc(size * size * 4, 0) // RGBA
49
+
50
+ // Fill with a semi-transparent dark background circle
51
+ for (let y = 0; y < size; y++) {
52
+ for (let x = 0; x < size; x++) {
53
+ const cx = x - size / 2
54
+ const cy = y - size / 2
55
+ const dist = Math.sqrt(cx * cx + cy * cy)
56
+ if (dist <= size / 2) {
57
+ const idx = (y * size + x) * 4
58
+ canvas[idx] = 122 // R (blue-ish)
59
+ canvas[idx + 1] = 162 // G
60
+ canvas[idx + 2] = 247 // B
61
+ canvas[idx + 3] = 220 // A
62
+ }
63
+ }
64
+ }
65
+
66
+ return nativeImage.createFromBuffer(canvas, { width: size, height: size })
67
+ }
68
+
69
+ function updateProjectBadge(cwdPath: string | null): void {
70
+ if (!cwdPath) return
71
+ const abbrev = getProjectAbbrev(cwdPath)
72
+
73
+ // macOS: dock badge shows text directly
74
+ if (process.platform === 'darwin' && app.dock) {
75
+ app.dock.setBadge(abbrev)
76
+ }
77
+
78
+ // Windows: overlay icon on taskbar
79
+ if (process.platform === 'win32' && mainWindow) {
80
+ mainWindow.setOverlayIcon(createOverlayIcon(abbrev), abbrev)
81
+ }
82
+
83
+ // All platforms: update window title
84
+ if (mainWindow) {
85
+ const folderName = cwdPath.split('/').filter(Boolean).pop() || 'ctlsurf-worker'
86
+ mainWindow.setTitle(`ctlsurf-worker — ${folderName}`)
87
+ }
88
+ }
89
+
22
90
  // ─── Orchestrator (shared logic) ──────────────────
23
91
 
24
92
  const orchestrator = new Orchestrator(
25
93
  getSettingsDir(true),
26
94
  {
27
- onPtyData: (data) => mainWindow?.webContents.send('pty:data', data),
28
- onPtyExit: (code) => mainWindow?.webContents.send('pty:exit', code),
95
+ onPtyData: (tabId, data) => mainWindow?.webContents.send('pty:data', tabId, data),
96
+ onPtyExit: (tabId, code) => mainWindow?.webContents.send('pty:exit', tabId, code),
29
97
  onWorkerStatus: (status) => mainWindow?.webContents.send('worker:status', status),
30
98
  onWorkerMessage: (message) => mainWindow?.webContents.send('worker:message', message),
31
99
  onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
32
- onCwdChanged: () => mainWindow?.webContents.send('app:cwdChanged'),
100
+ onCwdChanged: () => {
101
+ mainWindow?.webContents.send('app:cwdChanged')
102
+ updateProjectBadge(orchestrator.cwd)
103
+ },
33
104
  }
34
105
  )
35
106
 
@@ -65,21 +136,25 @@ function createWindow(): void {
65
136
 
66
137
  // ─── IPC Handlers ──────────────────────────────────
67
138
 
68
- ipcMain.handle('pty:spawn', async (_event, agent: AgentConfig, cwd: string) => {
69
- await orchestrator.spawnAgent(agent, cwd)
139
+ ipcMain.handle('pty:spawn', async (_event, tabId: string, agent: AgentConfig, cwd: string) => {
140
+ await orchestrator.spawnAgent(tabId, agent, cwd)
70
141
  return { ok: true }
71
142
  })
72
143
 
73
- ipcMain.handle('pty:write', (_event, data: string) => {
74
- orchestrator.writePty(data)
144
+ ipcMain.handle('pty:write', (_event, tabId: string, data: string) => {
145
+ orchestrator.writePty(tabId, data)
146
+ })
147
+
148
+ ipcMain.handle('pty:resize', (_event, tabId: string, cols: number, rows: number) => {
149
+ orchestrator.resizePty(tabId, cols, rows)
75
150
  })
76
151
 
77
- ipcMain.handle('pty:resize', (_event, cols: number, rows: number) => {
78
- orchestrator.resizePty(cols, rows)
152
+ ipcMain.handle('pty:kill', async (_event, tabId: string) => {
153
+ await orchestrator.killTab(tabId)
79
154
  })
80
155
 
81
- ipcMain.handle('pty:kill', async () => {
82
- await orchestrator.killAgent()
156
+ ipcMain.handle('pty:setActiveTab', (_event, tabId: string) => {
157
+ orchestrator.setActiveTab(tabId)
83
158
  })
84
159
 
85
160
  ipcMain.handle('agents:list', () => getBuiltinAgents())
@@ -279,7 +354,6 @@ app.whenReady().then(() => {
279
354
  if (process.platform === 'darwin' && app.dock) {
280
355
  const iconPath = path.join(__dirname, '../../resources/icon.png')
281
356
  try {
282
- const { nativeImage } = require('electron')
283
357
  app.dock.setIcon(nativeImage.createFromPath(iconPath))
284
358
  } catch { /* ignore */ }
285
359
  }
@@ -30,14 +30,22 @@ export interface SettingsData {
30
30
  }
31
31
 
32
32
  export interface OrchestratorEvents {
33
- onPtyData: (data: string) => void
34
- onPtyExit: (code: number) => void
33
+ onPtyData: (tabId: string, data: string) => void
34
+ onPtyExit: (tabId: string, code: number) => void
35
35
  onWorkerStatus: (status: string) => void
36
36
  onWorkerMessage: (message: IncomingMessage) => void
37
37
  onWorkerRegistered: (data: { worker_id: string; folder_id: string | null; status: string }) => void
38
38
  onCwdChanged: () => void
39
39
  }
40
40
 
41
+ interface TabState {
42
+ ptyManager: PtyManager
43
+ agent: AgentConfig
44
+ cwd: string
45
+ termStreamBuffer: string
46
+ termStreamTimer: ReturnType<typeof setTimeout> | null
47
+ }
48
+
41
49
  // ─── Orchestrator ─────────────────────────────────
42
50
 
43
51
  const DEFAULT_PROFILES: Record<string, Profile> = {
@@ -61,7 +69,8 @@ export class Orchestrator {
61
69
  readonly workerWs: WorkerWsClient
62
70
 
63
71
  // State
64
- private ptyManager: PtyManager | null = null
72
+ private tabs = new Map<string, TabState>()
73
+ private activeTabId: string | null = null
65
74
  private currentAgent: AgentConfig | null = null
66
75
  private currentCwd: string | null = null
67
76
  private settings: SettingsData = {
@@ -69,10 +78,6 @@ export class Orchestrator {
69
78
  profiles: { ...DEFAULT_PROFILES },
70
79
  }
71
80
 
72
- // Terminal stream batching
73
- private termStreamBuffer = ''
74
- private termStreamTimer: ReturnType<typeof setTimeout> | null = null
75
-
76
81
  constructor(settingsDir: string, events: OrchestratorEvents) {
77
82
  this.settingsDir = settingsDir
78
83
  this.events = events
@@ -88,8 +93,9 @@ export class Orchestrator {
88
93
  this.workerWs.sendAck(message.id)
89
94
 
90
95
  if (message.type === 'prompt' || message.type === 'task_dispatch') {
91
- if (this.ptyManager) {
92
- this.ptyManager.write(message.content + '\r')
96
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
97
+ if (activeTab) {
98
+ activeTab.ptyManager.write(message.content + '\r')
93
99
  this.bridge.feedInput(message.content)
94
100
  }
95
101
  }
@@ -102,7 +108,8 @@ export class Orchestrator {
102
108
  }
103
109
  },
104
110
  onTerminalInput: (data: string) => {
105
- this.ptyManager?.write(data)
111
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
112
+ activeTab?.ptyManager.write(data)
106
113
  },
107
114
  })
108
115
 
@@ -280,37 +287,48 @@ export class Orchestrator {
280
287
  return { ok: true }
281
288
  }
282
289
 
283
- // ─── PTY & Agent ────────────────────────────────
290
+ // ─── PTY & Agent (multi-tab) ─────────────────────
284
291
 
285
- async spawnAgent(agent: AgentConfig, cwd: string): Promise<void> {
286
- if (this.ptyManager) {
287
- this.bridge.endSession()
288
- this.ptyManager.kill()
292
+ async spawnAgent(tabId: string, agent: AgentConfig, cwd: string): Promise<void> {
293
+ // Kill existing PTY on this tab if any
294
+ const existing = this.tabs.get(tabId)
295
+ if (existing) {
296
+ if (existing.termStreamTimer) clearTimeout(existing.termStreamTimer)
297
+ existing.ptyManager.kill()
298
+ this.tabs.delete(tabId)
289
299
  }
290
300
 
291
301
  this.currentAgent = agent
292
302
  const prevCwd = this.currentCwd
293
303
  this.currentCwd = cwd
304
+ this.activeTabId = tabId
294
305
  if (prevCwd !== cwd) {
295
306
  this.events.onCwdChanged()
296
307
  }
297
308
 
298
- this.ptyManager = new PtyManager(agent, cwd)
309
+ const ptyManager = new PtyManager(agent, cwd)
310
+ const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
311
+ this.tabs.set(tabId, tab)
299
312
 
300
- this.ptyManager.onData((data: string) => {
301
- this.events.onPtyData(data)
302
- this.bridge.feedOutput(data)
303
- this.streamTerminalData(data)
313
+ ptyManager.onData((data: string) => {
314
+ this.events.onPtyData(tabId, data)
315
+ if (tabId === this.activeTabId) {
316
+ this.bridge.feedOutput(data)
317
+ this.streamTerminalData(tabId, data)
318
+ }
304
319
  })
305
320
 
306
- const thisPtyManager = this.ptyManager
307
-
308
- this.ptyManager.onExit(async (exitCode: number) => {
309
- this.events.onPtyExit(exitCode)
310
- this.bridge.endSession()
311
- if (thisPtyManager === this.ptyManager && this.currentAgent && isCodingAgent(this.currentAgent)) {
312
- this.workerWs.disconnect()
321
+ ptyManager.onExit(async (exitCode: number) => {
322
+ this.events.onPtyExit(tabId, exitCode)
323
+ if (tabId === this.activeTabId) {
324
+ this.bridge.endSession()
325
+ if (this.currentAgent && isCodingAgent(this.currentAgent)) {
326
+ this.workerWs.disconnect()
327
+ }
313
328
  }
329
+ // Clean up tab state
330
+ const t = this.tabs.get(tabId)
331
+ if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
314
332
  })
315
333
 
316
334
  this.bridge.startSession()
@@ -323,26 +341,51 @@ export class Orchestrator {
323
341
  }
324
342
  }
325
343
 
326
- writePty(data: string): void {
327
- this.ptyManager?.write(data)
328
- this.bridge.feedInput(data)
344
+ writePty(tabId: string, data: string): void {
345
+ this.tabs.get(tabId)?.ptyManager.write(data)
346
+ if (tabId === this.activeTabId) {
347
+ this.bridge.feedInput(data)
348
+ }
329
349
  }
330
350
 
331
- resizePty(cols: number, rows: number): void {
332
- this.ptyManager?.resize(cols, rows)
333
- this.bridge.resize(cols, rows)
334
- this.workerWs.sendTerminalResize(cols, rows)
351
+ resizePty(tabId: string, cols: number, rows: number): void {
352
+ this.tabs.get(tabId)?.ptyManager.resize(cols, rows)
353
+ if (tabId === this.activeTabId) {
354
+ this.bridge.resize(cols, rows)
355
+ this.workerWs.sendTerminalResize(cols, rows)
356
+ }
335
357
  }
336
358
 
337
- async killAgent(): Promise<void> {
338
- this.bridge.endSession()
339
- this.ptyManager?.kill()
340
- this.ptyManager = null
341
- if (this.currentAgent && isCodingAgent(this.currentAgent)) {
342
- this.workerWs.disconnect()
359
+ async killTab(tabId: string): Promise<void> {
360
+ const tab = this.tabs.get(tabId)
361
+ if (!tab) return
362
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
363
+ tab.ptyManager.kill()
364
+ this.tabs.delete(tabId)
365
+ if (tabId === this.activeTabId) {
366
+ this.bridge.endSession()
367
+ if (isCodingAgent(tab.agent)) {
368
+ this.workerWs.disconnect()
369
+ }
370
+ // Switch active to another tab if available
371
+ const remaining = [...this.tabs.keys()]
372
+ this.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1] : null
343
373
  }
344
374
  }
345
375
 
376
+ setActiveTab(tabId: string): void {
377
+ this.activeTabId = tabId
378
+ const tab = this.tabs.get(tabId)
379
+ if (tab) {
380
+ this.currentAgent = tab.agent
381
+ this.currentCwd = tab.cwd
382
+ }
383
+ }
384
+
385
+ getTabIds(): string[] {
386
+ return [...this.tabs.keys()]
387
+ }
388
+
346
389
  // ─── Worker WebSocket ───────────────────────────
347
390
 
348
391
  connectWorkerWs(agent: AgentConfig, cwd: string): void {
@@ -375,15 +418,17 @@ export class Orchestrator {
375
418
  }
376
419
  }
377
420
 
378
- private streamTerminalData(data: string): void {
379
- this.termStreamBuffer += data
380
- if (!this.termStreamTimer) {
381
- this.termStreamTimer = setTimeout(() => {
382
- if (this.termStreamBuffer) {
383
- this.workerWs.sendTerminalData(this.termStreamBuffer)
384
- this.termStreamBuffer = ''
421
+ private streamTerminalData(tabId: string, data: string): void {
422
+ const tab = this.tabs.get(tabId)
423
+ if (!tab) return
424
+ tab.termStreamBuffer += data
425
+ if (!tab.termStreamTimer) {
426
+ tab.termStreamTimer = setTimeout(() => {
427
+ if (tab.termStreamBuffer) {
428
+ this.workerWs.sendTerminalData(tab.termStreamBuffer)
429
+ tab.termStreamBuffer = ''
385
430
  }
386
- this.termStreamTimer = null
431
+ tab.termStreamTimer = null
387
432
  }, TERM_STREAM_INTERVAL_MS)
388
433
  }
389
434
  }
@@ -392,12 +437,11 @@ export class Orchestrator {
392
437
 
393
438
  async shutdown(): Promise<void> {
394
439
  this.bridge.endSession()
395
- this.ptyManager?.kill()
396
- this.ptyManager = null
397
- this.workerWs.disconnect()
398
- if (this.termStreamTimer) {
399
- clearTimeout(this.termStreamTimer)
400
- this.termStreamTimer = null
440
+ for (const [, tab] of this.tabs) {
441
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
442
+ tab.ptyManager.kill()
401
443
  }
444
+ this.tabs.clear()
445
+ this.workerWs.disconnect()
402
446
  }
403
447
  }