@phenx-inc/ctlsurf 0.1.16 → 0.1.18

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 (23) hide show
  1. package/out/main/index.js +278 -48
  2. package/out/renderer/assets/{cssMode-CihsrbZY.js → cssMode-Cxe23-tB.js} +3 -3
  3. package/out/renderer/assets/{freemarker2-DFrJ_l05.js → freemarker2-Be0nj7Oa.js} +1 -1
  4. package/out/renderer/assets/{handlebars-BZi7_LdH.js → handlebars-C0It7_Nu.js} +1 -1
  5. package/out/renderer/assets/{html-CdaPU_YJ.js → html-BW6LB-7J.js} +1 -1
  6. package/out/renderer/assets/{htmlMode-DZnUcBOX.js → htmlMode-D_V-1VlE.js} +3 -3
  7. package/out/renderer/assets/{index-CKhIh5ZQ.js → index-D568SpEt.js} +117 -145
  8. package/out/renderer/assets/{javascript-DvV4owMk.js → javascript-D_LoeNc7.js} +2 -2
  9. package/out/renderer/assets/{jsonMode-DX3yX_PF.js → jsonMode-K3WSinSE.js} +3 -3
  10. package/out/renderer/assets/{liquid-c7QWTywx.js → liquid-BqfOd6m8.js} +1 -1
  11. package/out/renderer/assets/{lspLanguageFeatures-Dp-OZOZS.js → lspLanguageFeatures-Bjf28WU6.js} +1 -1
  12. package/out/renderer/assets/{mdx-Bi7NE1tt.js → mdx-BoESjI38.js} +1 -1
  13. package/out/renderer/assets/{python-BeMAHtzr.js → python-DlafOOgB.js} +1 -1
  14. package/out/renderer/assets/{razor-Cfc9e2QR.js → razor-CB6E9DBD.js} +1 -1
  15. package/out/renderer/assets/{tsMode-CKrhuOxD.js → tsMode-DYu1z_nn.js} +1 -1
  16. package/out/renderer/assets/{typescript-BEFZ19OD.js → typescript-CDjkh0d5.js} +1 -1
  17. package/out/renderer/assets/{xml-CVdJPpsd.js → xml-C-XlilKZ.js} +1 -1
  18. package/out/renderer/assets/{yaml-B_Px-4th.js → yaml-BTrtxLEo.js} +1 -1
  19. package/out/renderer/index.html +1 -1
  20. package/package.json +5 -1
  21. package/src/renderer/App.tsx +8 -25
  22. package/src/renderer/components/PaneLayout.tsx +6 -44
  23. package/src/renderer/components/TerminalPanel.tsx +100 -82
@@ -72,15 +72,6 @@ const DEFAULT_LAYOUT: LayoutNode = {
72
72
 
73
73
  const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
74
74
 
75
- const offscreenStyle: React.CSSProperties = {
76
- position: 'absolute',
77
- width: '1px',
78
- height: '1px',
79
- overflow: 'hidden',
80
- opacity: 0,
81
- pointerEvents: 'none',
82
- }
83
-
84
75
  export default function App() {
85
76
  const [agents, setAgents] = useState<AgentConfig[]>([])
86
77
  const [selectedAgent, setSelectedAgent] = useState<AgentConfig | null>(null)
@@ -226,26 +217,19 @@ export default function App() {
226
217
  return () => window.removeEventListener('keydown', handleKeyDown)
227
218
  }, [togglePane])
228
219
 
229
- // Pane definitions content is rendered via portals to preserve React tree position
220
+ // Build pane contents (always rendered, layout controls visibility)
230
221
  const panes: PaneContent[] = [
231
- { id: 'editor', label: 'Editor', content: null },
232
- { id: 'terminal', label: 'Terminal', content: null },
233
- { id: 'ctlsurf', label: 'ctlsurf', content: null },
222
+ { id: 'editor', label: 'Editor', content: <EditorPanel cwd={cwd} /> },
223
+ {
224
+ id: 'terminal',
225
+ label: 'Terminal',
226
+ content: <TerminalPanel agent={selectedAgent} onSpawn={handleSpawn} onExit={handleExit} />,
227
+ },
228
+ { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel /> },
234
229
  ]
235
230
 
236
231
  return (
237
232
  <div className="app">
238
- {/* Persistent component instances — never unmounted, positioned by portal targets */}
239
- <div id="pane-host-editor" style={visiblePaneIds.includes('editor') ? undefined : offscreenStyle}>
240
- <EditorPanel cwd={cwd} />
241
- </div>
242
- <div id="pane-host-terminal" style={visiblePaneIds.includes('terminal') ? undefined : offscreenStyle}>
243
- <TerminalPanel agent={selectedAgent} onSpawn={handleSpawn} onExit={handleExit} />
244
- </div>
245
- <div id="pane-host-ctlsurf" style={visiblePaneIds.includes('ctlsurf') ? undefined : offscreenStyle}>
246
- <CtlsurfPanel />
247
- </div>
248
-
249
233
  <div className="titlebar">
250
234
  <span className="titlebar-title">ctlsurf-worker</span>
251
235
  <div className="titlebar-controls">
@@ -288,7 +272,6 @@ export default function App() {
288
272
  panes={panes.filter(p => visiblePaneIds.includes(p.id))}
289
273
  onLayoutChange={setLayout}
290
274
  onToggle={togglePane}
291
- paneHostPrefix="pane-host-"
292
275
  />
293
276
  )}
294
277
  </div>
@@ -116,10 +116,9 @@ interface PaneLayoutProps {
116
116
  panes: PaneContent[]
117
117
  onLayoutChange: (layout: LayoutNode) => void
118
118
  onToggle: (id: string) => void
119
- paneHostPrefix?: string
120
119
  }
121
120
 
122
- export function PaneLayout({ layout, panes, onLayoutChange, onToggle, paneHostPrefix }: PaneLayoutProps) {
121
+ export function PaneLayout({ layout, panes, onLayoutChange, onToggle }: PaneLayoutProps) {
123
122
  const [dragPaneId, setDragPaneId] = useState<string | null>(null)
124
123
 
125
124
  const handleDrop = useCallback((targetPaneId: string, zone: DropZone) => {
@@ -151,7 +150,6 @@ export function PaneLayout({ layout, panes, onLayoutChange, onToggle, paneHostPr
151
150
  onLayoutChange={onLayoutChange}
152
151
  parentDirection={null}
153
152
  path={[]}
154
- paneHostPrefix={paneHostPrefix}
155
153
  />
156
154
  </div>
157
155
  </DragContext.Provider>
@@ -168,10 +166,9 @@ interface LayoutRendererProps {
168
166
  onLayoutChange: (layout: LayoutNode) => void
169
167
  parentDirection: SplitDirection | null
170
168
  path: number[]
171
- paneHostPrefix?: string
172
169
  }
173
170
 
174
- function LayoutRenderer({ node, paneMap, onDrop, onToggle, onLayoutChange, parentDirection, path, paneHostPrefix }: LayoutRendererProps) {
171
+ function LayoutRenderer({ node, paneMap, onDrop, onToggle, onLayoutChange, parentDirection, path }: LayoutRendererProps) {
175
172
  if (node.type === 'leaf') {
176
173
  const pane = paneMap.get(node.paneId)
177
174
  if (!pane) return null
@@ -180,7 +177,6 @@ function LayoutRenderer({ node, paneMap, onDrop, onToggle, onLayoutChange, paren
180
177
  pane={pane}
181
178
  onDrop={onDrop}
182
179
  onToggle={onToggle}
183
- paneHostPrefix={paneHostPrefix}
184
180
  />
185
181
  )
186
182
  }
@@ -193,7 +189,6 @@ function LayoutRenderer({ node, paneMap, onDrop, onToggle, onLayoutChange, paren
193
189
  onToggle={onToggle}
194
190
  onLayoutChange={onLayoutChange}
195
191
  path={path}
196
- paneHostPrefix={paneHostPrefix}
197
192
  />
198
193
  )
199
194
  }
@@ -207,10 +202,9 @@ interface SplitContainerProps {
207
202
  onToggle: (id: string) => void
208
203
  onLayoutChange: (layout: LayoutNode) => void
209
204
  path: number[]
210
- paneHostPrefix?: string
211
205
  }
212
206
 
213
- function SplitContainer({ node, paneMap, onDrop, onToggle, onLayoutChange, path, paneHostPrefix }: SplitContainerProps) {
207
+ function SplitContainer({ node, paneMap, onDrop, onToggle, onLayoutChange, path }: SplitContainerProps) {
214
208
  const containerRef = useRef<HTMLDivElement>(null)
215
209
  const draggingDivider = useRef<number | null>(null)
216
210
  const sizesRef = useRef(node.sizes)
@@ -312,7 +306,6 @@ function SplitContainer({ node, paneMap, onDrop, onToggle, onLayoutChange, path,
312
306
  }}
313
307
  parentDirection={node.direction}
314
308
  path={[...path, i]}
315
- paneHostPrefix={paneHostPrefix}
316
309
  />
317
310
  </div>
318
311
  {i < node.children.length - 1 && (
@@ -339,43 +332,12 @@ interface LeafPaneProps {
339
332
  pane: PaneContent
340
333
  onDrop: (targetPaneId: string, zone: DropZone) => void
341
334
  onToggle: (id: string) => void
342
- paneHostPrefix?: string
343
335
  }
344
336
 
345
- function LeafPane({ pane, onDrop, onToggle, paneHostPrefix }: LeafPaneProps) {
337
+ function LeafPane({ pane, onDrop, onToggle }: LeafPaneProps) {
346
338
  const { dragPaneId, setDragPaneId } = useContext(DragContext)
347
339
  const [hoverZone, setHoverZone] = useState<DropZone | null>(null)
348
340
  const paneRef = useRef<HTMLDivElement>(null)
349
- const contentRef = useRef<HTMLDivElement>(null)
350
-
351
- // Reparent the persistent host element into this leaf's content area
352
- useEffect(() => {
353
- if (!paneHostPrefix || !contentRef.current) return
354
- const hostEl = document.getElementById(`${paneHostPrefix}${pane.id}`)
355
- if (!hostEl) return
356
-
357
- // Move host element into this container
358
- hostEl.style.position = ''
359
- hostEl.style.width = '100%'
360
- hostEl.style.height = '100%'
361
- hostEl.style.overflow = ''
362
- hostEl.style.opacity = '1'
363
- hostEl.style.pointerEvents = ''
364
- contentRef.current.appendChild(hostEl)
365
-
366
- return () => {
367
- // When this leaf unmounts, move host back to offscreen
368
- if (hostEl.parentElement === contentRef.current) {
369
- document.body.appendChild(hostEl)
370
- hostEl.style.position = 'absolute'
371
- hostEl.style.width = '1px'
372
- hostEl.style.height = '1px'
373
- hostEl.style.overflow = 'hidden'
374
- hostEl.style.opacity = '0'
375
- hostEl.style.pointerEvents = 'none'
376
- }
377
- }
378
- }, [pane.id, paneHostPrefix])
379
341
 
380
342
  const isDragging = dragPaneId === pane.id
381
343
  const isDropTarget = dragPaneId !== null && dragPaneId !== pane.id
@@ -423,8 +385,8 @@ function LeafPane({ pane, onDrop, onToggle, paneHostPrefix }: LeafPaneProps) {
423
385
  </div>
424
386
 
425
387
  {/* Content */}
426
- <div className="leaf-pane-content" ref={contentRef}>
427
- {!paneHostPrefix && pane.content}
388
+ <div className="leaf-pane-content">
389
+ {pane.content}
428
390
  </div>
429
391
 
430
392
  {/* Drop zone overlay (only visible when another pane is being dragged) */}
@@ -18,82 +18,107 @@ interface TerminalPanelProps {
18
18
  onExit: () => void
19
19
  }
20
20
 
21
+ // ─── Module-level singleton ──────────────────────────
22
+ // Survives React mount/unmount cycles so PTY connection is never lost.
23
+
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
+ function getOrCreateTerminal(onExit: () => void): { terminal: Terminal; fitAddon: FitAddon } {
31
+ if (_terminal && _fitAddon) {
32
+ return { terminal: _terminal, fitAddon: _fitAddon }
33
+ }
34
+
35
+ const terminal = new Terminal({
36
+ cursorBlink: true,
37
+ fontSize: 14,
38
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
39
+ theme: {
40
+ background: '#1a1b26',
41
+ foreground: '#a9b1d6',
42
+ cursor: '#c0caf5',
43
+ selectionBackground: '#33467C',
44
+ black: '#15161e',
45
+ red: '#f7768e',
46
+ green: '#9ece6a',
47
+ yellow: '#e0af68',
48
+ blue: '#7aa2f7',
49
+ magenta: '#bb9af7',
50
+ cyan: '#7dcfff',
51
+ white: '#a9b1d6',
52
+ brightBlack: '#414868',
53
+ brightRed: '#f7768e',
54
+ brightGreen: '#9ece6a',
55
+ brightYellow: '#e0af68',
56
+ brightBlue: '#7aa2f7',
57
+ brightMagenta: '#bb9af7',
58
+ brightCyan: '#7dcfff',
59
+ brightWhite: '#c0caf5'
60
+ },
61
+ scrollback: 10000,
62
+ scrollOnUserInput: true,
63
+ allowTransparency: false
64
+ })
65
+
66
+ const fitAddon = new FitAddon()
67
+ terminal.loadAddon(fitAddon)
68
+ terminal.loadAddon(new WebLinksAddon())
69
+
70
+ // Send keystrokes to pty
71
+ terminal.onData((data) => {
72
+ window.worker.writePty(data)
73
+ })
74
+
75
+ // Receive pty output — subscribed once, never unsubscribed
76
+ _unsubData = window.worker.onPtyData((data) => {
77
+ terminal.write(data)
78
+ })
79
+
80
+ // Handle pty exit
81
+ _unsubExit = window.worker.onPtyExit((code) => {
82
+ terminal.writeln(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m`)
83
+ onExit()
84
+ })
85
+
86
+ _terminal = terminal
87
+ _fitAddon = fitAddon
88
+ return { terminal, fitAddon }
89
+ }
90
+
91
+ // ─── Component ───────────────────────────────────────
92
+
21
93
  export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
22
94
  const containerRef = useRef<HTMLDivElement>(null)
23
- const terminalRef = useRef<Terminal | null>(null)
24
- const fitAddonRef = useRef<FitAddon | null>(null)
25
- const currentAgentRef = useRef<string | null>(null)
26
95
 
27
- // Initialize terminal once
96
+ // Attach terminal to DOM container (or reattach on remount)
28
97
  useEffect(() => {
29
- if (!containerRef.current || terminalRef.current) return
30
-
31
- const terminal = new Terminal({
32
- cursorBlink: true,
33
- fontSize: 14,
34
- fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
35
- theme: {
36
- background: '#1a1b26',
37
- foreground: '#a9b1d6',
38
- cursor: '#c0caf5',
39
- selectionBackground: '#33467C',
40
- black: '#15161e',
41
- red: '#f7768e',
42
- green: '#9ece6a',
43
- yellow: '#e0af68',
44
- blue: '#7aa2f7',
45
- magenta: '#bb9af7',
46
- cyan: '#7dcfff',
47
- white: '#a9b1d6',
48
- brightBlack: '#414868',
49
- brightRed: '#f7768e',
50
- brightGreen: '#9ece6a',
51
- brightYellow: '#e0af68',
52
- brightBlue: '#7aa2f7',
53
- brightMagenta: '#bb9af7',
54
- brightCyan: '#7dcfff',
55
- brightWhite: '#c0caf5'
56
- },
57
- scrollback: 10000,
58
- scrollOnUserInput: true,
59
- allowTransparency: false
60
- })
61
-
62
- const fitAddon = new FitAddon()
63
- terminal.loadAddon(fitAddon)
64
- terminal.loadAddon(new WebLinksAddon())
65
-
66
- terminal.open(containerRef.current)
67
- fitAddon.fit()
98
+ if (!containerRef.current) return
68
99
 
69
- terminalRef.current = terminal
70
- fitAddonRef.current = fitAddon
100
+ const { terminal, fitAddon } = getOrCreateTerminal(onExit)
71
101
 
72
- // Send keystrokes to pty
73
- terminal.onData((data) => {
74
- window.worker.writePty(data)
75
- })
76
-
77
- // Receive pty output
78
- const unsubData = window.worker.onPtyData((data) => {
79
- terminal.write(data)
80
- })
102
+ // If terminal is already open in another container, move it
103
+ const existingParent = terminal.element?.parentElement
104
+ if (existingParent && existingParent !== containerRef.current) {
105
+ containerRef.current.appendChild(terminal.element!)
106
+ } else if (!terminal.element) {
107
+ terminal.open(containerRef.current)
108
+ }
81
109
 
82
- // Handle pty exit
83
- const unsubExit = window.worker.onPtyExit((code) => {
84
- terminal.writeln(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m`)
85
- onExit()
86
- })
110
+ fitAddon.fit()
111
+ terminal.scrollToBottom()
87
112
 
88
- // Resize handling — scroll to bottom after fit to prevent jumping to top
113
+ // Resize handling
89
114
  let resizeTimeout: ReturnType<typeof setTimeout>
90
115
  const handleResize = () => {
91
116
  clearTimeout(resizeTimeout)
92
117
  resizeTimeout = setTimeout(() => {
93
- if (fitAddonRef.current && terminalRef.current) {
94
- fitAddonRef.current.fit()
95
- terminalRef.current.scrollToBottom()
96
- const { cols, rows } = terminalRef.current
118
+ if (_fitAddon && _terminal) {
119
+ _fitAddon.fit()
120
+ _terminal.scrollToBottom()
121
+ const { cols, rows } = _terminal
97
122
  window.worker.resizePty(cols, rows)
98
123
  }
99
124
  }, 150)
@@ -107,33 +132,26 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
107
132
  clearTimeout(resizeTimeout)
108
133
  window.removeEventListener('resize', handleResize)
109
134
  observer.disconnect()
110
- unsubData()
111
- unsubExit()
112
- terminal.dispose()
113
- terminalRef.current = null
114
- fitAddonRef.current = null
135
+ // Do NOT dispose terminal or unsub PTY listeners
115
136
  }
116
- }, []) // eslint-disable-line react-hooks/exhaustive-deps
137
+ }, [onExit])
117
138
 
118
139
  // Spawn agent when it changes
119
140
  useEffect(() => {
120
- if (!agent || !terminalRef.current) return
121
- if (currentAgentRef.current === agent.id) return
122
-
123
- currentAgentRef.current = agent.id
141
+ if (!agent || !_terminal) return
142
+ if (_currentAgentId === agent.id) return
124
143
 
125
- // Clear terminal for new agent
126
- terminalRef.current.clear()
144
+ _currentAgentId = agent.id
145
+ _terminal.clear()
127
146
 
128
147
  onSpawn(agent).then(() => {
129
- // Send initial resize after spawn
130
- if (fitAddonRef.current && terminalRef.current) {
131
- fitAddonRef.current.fit()
132
- terminalRef.current.scrollToBottom()
133
- const { cols, rows } = terminalRef.current
148
+ if (_fitAddon && _terminal) {
149
+ _fitAddon.fit()
150
+ _terminal.scrollToBottom()
151
+ const { cols, rows } = _terminal
134
152
  window.worker.resizePty(cols, rows)
135
153
  }
136
- terminalRef.current?.focus()
154
+ _terminal?.focus()
137
155
  })
138
156
  }, [agent, onSpawn])
139
157