@phenx-inc/ctlsurf 0.1.21 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/out/headless/index.mjs +409 -99
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +419 -77
  4. package/out/preload/index.js +12 -8
  5. package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-DiOmyihM.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BAfv60yb.js} +1 -1
  7. package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-Ult17NzQ.js} +1 -1
  8. package/out/renderer/assets/{html-Ds5-qvDh.js → html-DCxh4J-1.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-CQ5Xenrg.js} +3 -3
  10. package/out/renderer/assets/{index-DwSsD_Xm.js → index-BnCJ1IaZ.js} +308 -101
  11. package/out/renderer/assets/{index-DK9wLFFm.css → index-CrTu3Z4M.css} +132 -0
  12. package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-U5dsRcHx.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-DshPNyVy.js} +3 -3
  14. package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-jHHLYTlB.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-CUafmPGy.js} +1 -1
  16. package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-Ct-tiY6g.js} +1 -1
  17. package/out/renderer/assets/{python-BQDHXVwp.js → python-wD3UwKPV.js} +1 -1
  18. package/out/renderer/assets/{razor-BfXW9cDc.js → razor-11ECS4oH.js} +1 -1
  19. package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-D-7JexQ_.js} +1 -1
  20. package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Cvna1mak.js} +1 -1
  21. package/out/renderer/assets/{xml-B6EKhHiy.js → xml-JsEaImjA.js} +1 -1
  22. package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B8pCNDb_.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/ctlsurfApi.ts +26 -0
  26. package/src/main/headless.ts +40 -34
  27. package/src/main/index.ts +95 -13
  28. package/src/main/orchestrator.ts +160 -55
  29. package/src/main/timeTracker.ts +223 -0
  30. package/src/main/tui.ts +25 -5
  31. package/src/preload/index.ts +23 -15
  32. package/src/renderer/App.tsx +197 -43
  33. package/src/renderer/components/SettingsDialog.tsx +38 -1
  34. package/src/renderer/components/TerminalPanel.tsx +109 -59
  35. package/src/renderer/styles.css +132 -0
@@ -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,52 @@ 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 — delay fit to let the container get its layout dimensions
212
+ useEffect(() => {
213
+ if (isActive) {
214
+ window.worker.setActiveTab(tabId)
215
+ // Wait for the browser to layout the now-visible container before fitting
216
+ requestAnimationFrame(() => {
217
+ setTimeout(() => {
218
+ const state = _terminals.get(tabId)
219
+ if (state) {
220
+ state.fitAddon.fit()
221
+ state.terminal.focus()
222
+ scrollIfPinned(tabId, state.terminal)
223
+ const { cols, rows } = state.terminal
224
+ window.worker.resizePty(tabId, cols, rows)
225
+ }
226
+ }, 50)
227
+ })
228
+ }
229
+ }, [isActive, tabId])
180
230
 
181
231
  return (
182
232
  <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%;
@@ -407,6 +518,27 @@ html, body, #root {
407
518
  .status-dot.idle { background: #565f89; }
408
519
  .status-dot.pending { background: #e0af68; }
409
520
 
521
+ .tracking-dot {
522
+ width: 6px;
523
+ height: 6px;
524
+ border-radius: 50%;
525
+ display: inline-block;
526
+ vertical-align: middle;
527
+ }
528
+ .tracking-dot.on { background: #9ece6a; box-shadow: 0 0 4px #9ece6a; }
529
+ .tracking-dot.off { background: #565f89; }
530
+
531
+ .titlebar-icon-btn {
532
+ display: inline-flex;
533
+ align-items: center;
534
+ gap: 5px;
535
+ padding: 0 8px;
536
+ }
537
+ .tracking-icon {
538
+ font-size: 14px;
539
+ line-height: 1;
540
+ }
541
+
410
542
  /* Editor panel */
411
543
  .editor-panel {
412
544
  display: flex;