@phenx-inc/ctlsurf 0.1.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 (133) hide show
  1. package/bin/ctlsurf-worker.js +173 -0
  2. package/electron-vite.config.ts +34 -0
  3. package/out/headless/index.mjs +1364 -0
  4. package/out/headless/index.mjs.map +7 -0
  5. package/out/main/index.js +1131 -0
  6. package/out/preload/index.js +67 -0
  7. package/out/renderer/assets/abap-D5KwWAsZ.js +1399 -0
  8. package/out/renderer/assets/apex-DVGUZ64i.js +331 -0
  9. package/out/renderer/assets/azcli-BEAhqcuE.js +69 -0
  10. package/out/renderer/assets/bat-Bqkp9Cfu.js +101 -0
  11. package/out/renderer/assets/bicep-DIlfshcM.js +110 -0
  12. package/out/renderer/assets/cameligo-CLaaYNMV.js +175 -0
  13. package/out/renderer/assets/clojure-fcgFaMHx.js +762 -0
  14. package/out/renderer/assets/codicon-ngg6Pgfi.ttf +0 -0
  15. package/out/renderer/assets/coffee-CzJ5oEdj.js +233 -0
  16. package/out/renderer/assets/cpp-CcN6f0ik.js +390 -0
  17. package/out/renderer/assets/csharp-BJeIuvde.js +327 -0
  18. package/out/renderer/assets/csp-D_3BK2Wp.js +54 -0
  19. package/out/renderer/assets/css-i3rI3_64.js +186 -0
  20. package/out/renderer/assets/css.worker-umuuUiIb.js +53567 -0
  21. package/out/renderer/assets/cssMode-DL0XItGB.js +208 -0
  22. package/out/renderer/assets/cypher-D0--_GAN.js +264 -0
  23. package/out/renderer/assets/dart-vLMHv35g.js +282 -0
  24. package/out/renderer/assets/dockerfile--oxj0cAH.js +131 -0
  25. package/out/renderer/assets/ecl-CeuUgzaZ.js +457 -0
  26. package/out/renderer/assets/editor.worker-CNgWLVu7.js +13695 -0
  27. package/out/renderer/assets/elixir-eLfY1jWH.js +570 -0
  28. package/out/renderer/assets/flow9-ZSTChSMd.js +143 -0
  29. package/out/renderer/assets/freemarker2-CrOEuDcF.js +995 -0
  30. package/out/renderer/assets/fsharp-D2uoxuLH.js +218 -0
  31. package/out/renderer/assets/go-brnMpFrj.js +219 -0
  32. package/out/renderer/assets/graphql-BeiGgjIU.js +152 -0
  33. package/out/renderer/assets/handlebars-D4QYaBof.js +414 -0
  34. package/out/renderer/assets/hcl-CrX1Es2W.js +184 -0
  35. package/out/renderer/assets/html-B2Dqk2ai.js +303 -0
  36. package/out/renderer/assets/html.worker-BT47iy49.js +29777 -0
  37. package/out/renderer/assets/htmlMode-CdZ0Prhd.js +224 -0
  38. package/out/renderer/assets/index-CJ6RsQWP.css +8108 -0
  39. package/out/renderer/assets/index-pZmE1QXB.js +211777 -0
  40. package/out/renderer/assets/ini-BcQysCTb.js +72 -0
  41. package/out/renderer/assets/java-Dt3iMn2o.js +233 -0
  42. package/out/renderer/assets/javascript-CK8zNQXj.js +72 -0
  43. package/out/renderer/assets/json.worker-D4JVmXIe.js +21424 -0
  44. package/out/renderer/assets/jsonMode-Cewaellc.js +931 -0
  45. package/out/renderer/assets/julia-Cm3ItYL_.js +512 -0
  46. package/out/renderer/assets/kotlin-Ddo1SjA5.js +253 -0
  47. package/out/renderer/assets/less-B7Qaxw-O.js +162 -0
  48. package/out/renderer/assets/lexon-C1U0m2n9.js +158 -0
  49. package/out/renderer/assets/liquid-Bd3GPNs2.js +235 -0
  50. package/out/renderer/assets/lspLanguageFeatures-DSDH7BnA.js +1841 -0
  51. package/out/renderer/assets/lua-hNsuGJkO.js +163 -0
  52. package/out/renderer/assets/m3-6ko6q9-_.js +211 -0
  53. package/out/renderer/assets/markdown-B0YTnTxW.js +230 -0
  54. package/out/renderer/assets/mdx-CCPVCrXC.js +159 -0
  55. package/out/renderer/assets/mips-CJm71dS3.js +199 -0
  56. package/out/renderer/assets/msdax-BBeIktCY.js +376 -0
  57. package/out/renderer/assets/mysql-BWiizXSn.js +879 -0
  58. package/out/renderer/assets/objective-c-B1L1C5EC.js +184 -0
  59. package/out/renderer/assets/pascal-DMQyD4Xk.js +252 -0
  60. package/out/renderer/assets/pascaligo-VA_LQ1oU.js +165 -0
  61. package/out/renderer/assets/perl-DC0Z0tlO.js +627 -0
  62. package/out/renderer/assets/pgsql-DaSGFTLp.js +852 -0
  63. package/out/renderer/assets/php-Bkx1qpkQ.js +501 -0
  64. package/out/renderer/assets/pla-DEV89yYj.js +138 -0
  65. package/out/renderer/assets/postiats-CVVurEnu.js +908 -0
  66. package/out/renderer/assets/powerquery-BQ_t1ZiQ.js +891 -0
  67. package/out/renderer/assets/powershell-BXiKvz7Z.js +240 -0
  68. package/out/renderer/assets/protobuf-CndvAUGu.js +421 -0
  69. package/out/renderer/assets/pug-BxCXwerb.js +403 -0
  70. package/out/renderer/assets/python-34jOtlcC.js +295 -0
  71. package/out/renderer/assets/qsharp-BWK6YLKm.js +302 -0
  72. package/out/renderer/assets/r-CtqYUQ6l.js +244 -0
  73. package/out/renderer/assets/razor-DXRw694z.js +545 -0
  74. package/out/renderer/assets/redis-O7gSt3oh.js +303 -0
  75. package/out/renderer/assets/redshift-CvYMMYZY.js +810 -0
  76. package/out/renderer/assets/restructuredtext-B-KQCVu_.js +175 -0
  77. package/out/renderer/assets/ruby-DCd4DmAr.js +512 -0
  78. package/out/renderer/assets/rust-B1c0VCeq.js +344 -0
  79. package/out/renderer/assets/sb-Chfc_wZF.js +116 -0
  80. package/out/renderer/assets/scala-DbVzH-3O.js +371 -0
  81. package/out/renderer/assets/scheme-D7PxodDG.js +109 -0
  82. package/out/renderer/assets/scss-B42qMyAu.js +261 -0
  83. package/out/renderer/assets/shell-vZEubQ82.js +222 -0
  84. package/out/renderer/assets/solidity-yHOxYChb.js +1368 -0
  85. package/out/renderer/assets/sophia-D7pU0Y1d.js +200 -0
  86. package/out/renderer/assets/sparql-DxuVdnRl.js +202 -0
  87. package/out/renderer/assets/sql-BAGepFCR.js +854 -0
  88. package/out/renderer/assets/st-C-b0Dh53.js +417 -0
  89. package/out/renderer/assets/swift-BmOZGynf.js +313 -0
  90. package/out/renderer/assets/systemverilog-BOC0OOdC.js +577 -0
  91. package/out/renderer/assets/tcl-Bb4GCwBr.js +233 -0
  92. package/out/renderer/assets/ts.worker-C7hW3aY-.js +225330 -0
  93. package/out/renderer/assets/tsMode-CmND5_wB.js +1265 -0
  94. package/out/renderer/assets/twig-DvgEGWAV.js +393 -0
  95. package/out/renderer/assets/typescript-BNNI0Euv.js +337 -0
  96. package/out/renderer/assets/typespec-R77Ln7Jb.js +128 -0
  97. package/out/renderer/assets/vb-Bm6ESA0Q.js +373 -0
  98. package/out/renderer/assets/wgsl-_KPae5vw.js +454 -0
  99. package/out/renderer/assets/xml-CgdndrNB.js +89 -0
  100. package/out/renderer/assets/yaml-DNWPIf1s.js +200 -0
  101. package/out/renderer/index.html +13 -0
  102. package/package.json +67 -0
  103. package/resources/icon.icns +0 -0
  104. package/resources/icon.ico +0 -0
  105. package/resources/icon.png +0 -0
  106. package/src/main/agents.ts +46 -0
  107. package/src/main/bridge.ts +180 -0
  108. package/src/main/ctlsurfApi.ts +142 -0
  109. package/src/main/detectMode.ts +17 -0
  110. package/src/main/headless.ts +182 -0
  111. package/src/main/index.ts +300 -0
  112. package/src/main/orchestrator.ts +404 -0
  113. package/src/main/pty.ts +65 -0
  114. package/src/main/settingsDir.ts +17 -0
  115. package/src/main/tui.ts +366 -0
  116. package/src/main/workerWs.ts +312 -0
  117. package/src/preload/index.ts +114 -0
  118. package/src/renderer/App.tsx +275 -0
  119. package/src/renderer/components/CtlsurfPanel.tsx +49 -0
  120. package/src/renderer/components/EditorPanel.tsx +232 -0
  121. package/src/renderer/components/MultiSplitPane.tsx +251 -0
  122. package/src/renderer/components/PaneLayout.tsx +419 -0
  123. package/src/renderer/components/SettingsDialog.tsx +204 -0
  124. package/src/renderer/components/SplitPane.tsx +82 -0
  125. package/src/renderer/components/StatusBar.tsx +73 -0
  126. package/src/renderer/components/TerminalPanel.tsx +140 -0
  127. package/src/renderer/index.html +12 -0
  128. package/src/renderer/main.tsx +10 -0
  129. package/src/renderer/styles.css +722 -0
  130. package/tsconfig.json +8 -0
  131. package/tsconfig.main.json +15 -0
  132. package/tsconfig.preload.json +14 -0
  133. package/tsconfig.renderer.json +15 -0
@@ -0,0 +1,114 @@
1
+ import { contextBridge, ipcRenderer } from 'electron'
2
+
3
+ export interface AgentConfig {
4
+ id: string
5
+ name: string
6
+ command: string
7
+ args: string[]
8
+ description: string
9
+ }
10
+
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'),
21
+
22
+ // Pty events
23
+ onPtyData: (callback: (data: string) => void) => {
24
+ const listener = (_event: Electron.IpcRendererEvent, data: string) => callback(data)
25
+ ipcRenderer.on('pty:data', listener)
26
+ return () => ipcRenderer.removeListener('pty:data', listener)
27
+ },
28
+ onPtyExit: (callback: (code: number) => void) => {
29
+ const listener = (_event: Electron.IpcRendererEvent, code: number) => callback(code)
30
+ ipcRenderer.on('pty:exit', listener)
31
+ return () => ipcRenderer.removeListener('pty:exit', listener)
32
+ },
33
+
34
+ // Agent management
35
+ listAgents: (): Promise<AgentConfig[]> =>
36
+ ipcRenderer.invoke('agents:list'),
37
+ getDefaultAgent: (): Promise<AgentConfig> =>
38
+ ipcRenderer.invoke('agents:default'),
39
+
40
+ // App info
41
+ getHomePath: (): Promise<string> =>
42
+ ipcRenderer.invoke('app:homePath'),
43
+ getCwd: (): Promise<string> =>
44
+ ipcRenderer.invoke('app:cwd'),
45
+ browseCwd: (): Promise<string | null> =>
46
+ ipcRenderer.invoke('app:browseCwd'),
47
+
48
+ // Settings (legacy)
49
+ getSetting: (key: string): Promise<string | null> =>
50
+ ipcRenderer.invoke('settings:get', key),
51
+ setSetting: (key: string, value: string): Promise<{ ok: boolean }> =>
52
+ ipcRenderer.invoke('settings:set', key, value),
53
+ getAllSettings: (): Promise<Record<string, string | null>> =>
54
+ ipcRenderer.invoke('settings:getAll'),
55
+
56
+ // Profiles
57
+ listProfiles: () =>
58
+ ipcRenderer.invoke('profiles:list'),
59
+ getProfile: (profileId: string) =>
60
+ ipcRenderer.invoke('profiles:get', profileId),
61
+ saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) =>
62
+ ipcRenderer.invoke('profiles:save', profileId, data),
63
+ switchProfile: (profileId: string) =>
64
+ ipcRenderer.invoke('profiles:switch', profileId),
65
+ deleteProfile: (profileId: string) =>
66
+ ipcRenderer.invoke('profiles:delete', profileId),
67
+
68
+ // Filesystem
69
+ readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
70
+ ipcRenderer.invoke('fs:readDir', dirPath),
71
+ readFile: (filePath: string): Promise<{ ok: boolean; content?: string; error?: string }> =>
72
+ ipcRenderer.invoke('fs:readFile', filePath),
73
+ writeFile: (filePath: string, content: string): Promise<{ ok: boolean; error?: string }> =>
74
+ ipcRenderer.invoke('fs:writeFile', filePath, content),
75
+
76
+ // Worker connection
77
+ getWorkerStatus: (): Promise<string> =>
78
+ ipcRenderer.invoke('worker:getStatus'),
79
+ getWorkerId: (): Promise<string | null> =>
80
+ ipcRenderer.invoke('worker:getWorkerId'),
81
+ createProject: (): Promise<{ ok: boolean; folder_id?: string; error?: string }> =>
82
+ ipcRenderer.invoke('worker:createProject'),
83
+ getWebviewInfo: (): Promise<{
84
+ frontendUrl: string;
85
+ pageUrl?: string;
86
+ authenticated: boolean;
87
+ }> => ipcRenderer.invoke('worker:getWebviewInfo'),
88
+
89
+ onWorkerStatus: (callback: (status: string) => void) => {
90
+ const listener = (_event: Electron.IpcRendererEvent, status: string) => callback(status)
91
+ ipcRenderer.on('worker:status', listener)
92
+ return () => ipcRenderer.removeListener('worker:status', listener)
93
+ },
94
+ onWorkerMessage: (callback: (message: unknown) => void) => {
95
+ const listener = (_event: Electron.IpcRendererEvent, message: unknown) => callback(message)
96
+ ipcRenderer.on('worker:message', listener)
97
+ return () => ipcRenderer.removeListener('worker:message', listener)
98
+ },
99
+ onCwdChanged: (callback: () => void) => {
100
+ const listener = () => callback()
101
+ ipcRenderer.on('app:cwdChanged', listener)
102
+ return () => ipcRenderer.removeListener('app:cwdChanged', listener)
103
+ },
104
+
105
+ onWorkerRegistered: (callback: (data: unknown) => void) => {
106
+ const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data)
107
+ ipcRenderer.on('worker:registered', listener)
108
+ return () => ipcRenderer.removeListener('worker:registered', listener)
109
+ },
110
+ }
111
+
112
+ contextBridge.exposeInMainWorld('worker', api)
113
+
114
+ export type WorkerAPI = typeof api
@@ -0,0 +1,275 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
2
+ import { TerminalPanel } from './components/TerminalPanel'
3
+ import { CtlsurfPanel } from './components/CtlsurfPanel'
4
+ import { EditorPanel } from './components/EditorPanel'
5
+ import {
6
+ PaneLayout,
7
+ type LayoutNode,
8
+ type PaneContent,
9
+ removePane,
10
+ findPaneIds,
11
+ } from './components/PaneLayout'
12
+ import { StatusBar } from './components/StatusBar'
13
+ import { SettingsDialog } from './components/SettingsDialog'
14
+
15
+ interface AgentConfig {
16
+ id: string
17
+ name: string
18
+ command: string
19
+ args: string[]
20
+ description: string
21
+ }
22
+
23
+ declare global {
24
+ interface Window {
25
+ worker: {
26
+ spawnAgent: (agent: AgentConfig, cwd: string) => Promise<{ ok: boolean }>
27
+ writePty: (data: string) => Promise<void>
28
+ resizePty: (cols: number, rows: number) => Promise<void>
29
+ killPty: () => Promise<void>
30
+ onPtyData: (callback: (data: string) => void) => () => void
31
+ onPtyExit: (callback: (code: number) => void) => () => void
32
+ listAgents: () => Promise<AgentConfig[]>
33
+ getDefaultAgent: () => Promise<AgentConfig>
34
+ getHomePath: () => Promise<string>
35
+ getCwd: () => Promise<string>
36
+ browseCwd: () => Promise<string | null>
37
+ getSetting: (key: string) => Promise<string | null>
38
+ setSetting: (key: string, value: string) => Promise<{ ok: boolean }>
39
+ getAllSettings: () => Promise<Record<string, string | null>>
40
+ listProfiles: () => Promise<unknown>
41
+ getProfile: (profileId: string) => Promise<unknown>
42
+ saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) => Promise<{ ok: boolean }>
43
+ switchProfile: (profileId: string) => Promise<{ ok: boolean }>
44
+ deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
45
+ createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
46
+ getWebviewInfo: () => Promise<{
47
+ frontendUrl: string; pageUrl?: string; authenticated: boolean;
48
+ }>
49
+ getWorkerStatus: () => Promise<string>
50
+ getWorkerId: () => Promise<string | null>
51
+ onWorkerStatus: (callback: (status: string) => void) => () => void
52
+ onWorkerMessage: (callback: (message: unknown) => void) => () => void
53
+ onCwdChanged: (callback: () => void) => () => void
54
+ onWorkerRegistered: (callback: (data: unknown) => void) => () => void
55
+ readDir: (dirPath: string) => Promise<Array<{ name: string; path: string; isDirectory: boolean }>>
56
+ readFile: (filePath: string) => Promise<{ ok: boolean; content?: string; error?: string }>
57
+ writeFile: (filePath: string, content: string) => Promise<{ ok: boolean; error?: string }>
58
+ }
59
+ }
60
+ }
61
+
62
+ const DEFAULT_LAYOUT: LayoutNode = {
63
+ type: 'split',
64
+ direction: 'horizontal',
65
+ children: [
66
+ { type: 'leaf', paneId: 'editor' },
67
+ { type: 'leaf', paneId: 'terminal' },
68
+ { type: 'leaf', paneId: 'ctlsurf' },
69
+ ],
70
+ sizes: [33.33, 33.33, 33.34],
71
+ }
72
+
73
+ const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
74
+
75
+ export default function App() {
76
+ const [agents, setAgents] = useState<AgentConfig[]>([])
77
+ const [selectedAgent, setSelectedAgent] = useState<AgentConfig | null>(null)
78
+ const [agentStatus, setAgentStatus] = useState<'idle' | 'active' | 'exited'>('idle')
79
+ const [sessionStart, setSessionStart] = useState<Date | null>(null)
80
+ const [layout, setLayout] = useState<LayoutNode>(DEFAULT_LAYOUT)
81
+ const [hiddenPanes, setHiddenPanes] = useState<Set<string>>(new Set())
82
+ const [showSettings, setShowSettings] = useState(false)
83
+ const [wsStatus, setWsStatus] = useState('disconnected')
84
+ const [cwd, setCwd] = useState<string | null>(null)
85
+
86
+ // Track which panes are in the layout tree
87
+ const visiblePaneIds = findPaneIds(layout)
88
+
89
+ useEffect(() => {
90
+ async function init() {
91
+ const list = await window.worker.listAgents()
92
+ const defaultAgent = await window.worker.getDefaultAgent()
93
+ setAgents(list)
94
+ setSelectedAgent(defaultAgent)
95
+ const status = await window.worker.getWorkerStatus()
96
+ setWsStatus(status)
97
+ }
98
+ init()
99
+ }, [])
100
+
101
+ useEffect(() => {
102
+ const unsub = window.worker.onWorkerStatus((status) => setWsStatus(status))
103
+ return unsub
104
+ }, [])
105
+
106
+ useEffect(() => {
107
+ const unsub = window.worker.onWorkerMessage((message) => {
108
+ console.log('[worker] Received message:', message)
109
+ })
110
+ return unsub
111
+ }, [])
112
+
113
+ const spawnGenRef = useRef(0)
114
+ const cwdRef = useRef<string | null>(null)
115
+
116
+ const handleSpawn = useCallback(async (agent: AgentConfig) => {
117
+ spawnGenRef.current += 1
118
+ setAgentStatus('active')
119
+ setSessionStart(new Date())
120
+ const spawnCwd = cwdRef.current || await window.worker.getCwd().catch(() => window.worker.getHomePath())
121
+ setCwd(spawnCwd)
122
+ cwdRef.current = spawnCwd
123
+ await window.worker.spawnAgent(agent, spawnCwd)
124
+ }, [])
125
+
126
+ const handleAgentChange = useCallback(async (agentId: string) => {
127
+ const agent = agents.find(a => a.id === agentId)
128
+ if (agent) {
129
+ await window.worker.killPty()
130
+ setSelectedAgent(agent)
131
+ }
132
+ }, [agents])
133
+
134
+ const handleExit = useCallback(() => {
135
+ const gen = spawnGenRef.current
136
+ setTimeout(() => {
137
+ if (spawnGenRef.current === gen) setAgentStatus('exited')
138
+ }, 200)
139
+ }, [])
140
+
141
+ const handleChangeCwd = useCallback(async () => {
142
+ const newCwd = await window.worker.browseCwd()
143
+ if (!newCwd || !selectedAgent) return
144
+ cwdRef.current = newCwd
145
+ setCwd(newCwd)
146
+ await window.worker.killPty()
147
+ spawnGenRef.current += 1
148
+ setAgentStatus('active')
149
+ setSessionStart(new Date())
150
+ await window.worker.spawnAgent(selectedAgent, newCwd)
151
+ }, [selectedAgent])
152
+
153
+ // Toggle pane: hide removes from layout tree, show re-inserts
154
+ const togglePane = useCallback((id: string) => {
155
+ if (visiblePaneIds.includes(id)) {
156
+ // Hide: remove from tree
157
+ const newLayout = removePane(layout, id)
158
+ if (newLayout) {
159
+ setLayout(newLayout)
160
+ }
161
+ setHiddenPanes(prev => new Set([...prev, id]))
162
+ } else {
163
+ // Show: insert as a new right-side split at the root
164
+ setHiddenPanes(prev => {
165
+ const next = new Set(prev)
166
+ next.delete(id)
167
+ return next
168
+ })
169
+
170
+ setLayout(prev => {
171
+ // If only one leaf, create a horizontal split
172
+ if (prev.type === 'leaf') {
173
+ return {
174
+ type: 'split',
175
+ direction: 'horizontal',
176
+ children: [prev, { type: 'leaf', paneId: id }],
177
+ sizes: [50, 50],
178
+ }
179
+ }
180
+ // If it's a horizontal split at root, append
181
+ if (prev.type === 'split' && prev.direction === 'horizontal') {
182
+ const count = prev.children.length + 1
183
+ const equalSize = 100 / count
184
+ return {
185
+ ...prev,
186
+ children: [...prev.children, { type: 'leaf', paneId: id }],
187
+ sizes: Array(count).fill(equalSize),
188
+ }
189
+ }
190
+ // Otherwise wrap in a horizontal split
191
+ return {
192
+ type: 'split',
193
+ direction: 'horizontal',
194
+ children: [prev, { type: 'leaf', paneId: id }],
195
+ sizes: [66, 34],
196
+ }
197
+ })
198
+ }
199
+ }, [layout, visiblePaneIds])
200
+
201
+ // Keyboard shortcuts
202
+ useEffect(() => {
203
+ const handleKeyDown = (e: KeyboardEvent) => {
204
+ if (e.ctrlKey && e.shiftKey) {
205
+ switch (e.key) {
206
+ case 'E': e.preventDefault(); togglePane('editor'); break
207
+ case 'T': e.preventDefault(); togglePane('terminal'); break
208
+ case 'P': e.preventDefault(); togglePane('ctlsurf'); break
209
+ }
210
+ }
211
+ }
212
+ window.addEventListener('keydown', handleKeyDown)
213
+ return () => window.removeEventListener('keydown', handleKeyDown)
214
+ }, [togglePane])
215
+
216
+ // Build pane contents (always rendered, layout controls visibility)
217
+ const panes: PaneContent[] = [
218
+ { id: 'editor', label: 'Editor', content: <EditorPanel cwd={cwd} /> },
219
+ {
220
+ id: 'terminal',
221
+ label: 'Terminal',
222
+ content: <TerminalPanel agent={selectedAgent} onSpawn={handleSpawn} onExit={handleExit} />,
223
+ },
224
+ { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel /> },
225
+ ]
226
+
227
+ return (
228
+ <div className="app">
229
+ <div className="titlebar">
230
+ <span className="titlebar-title">ctlsurf-worker</span>
231
+ <div className="titlebar-controls">
232
+ <button className="titlebar-btn" onClick={() => setShowSettings(true)} title="Settings">
233
+ Settings
234
+ </button>
235
+ {ALL_PANE_IDS.map(id => (
236
+ <button
237
+ key={id}
238
+ className={`titlebar-btn ${visiblePaneIds.includes(id) ? 'active' : ''}`}
239
+ onClick={() => togglePane(id)}
240
+ title={`Toggle ${id}`}
241
+ >
242
+ {id === 'editor' ? 'Editor' : id === 'terminal' ? 'Terminal' : 'ctlsurf'}
243
+ </button>
244
+ ))}
245
+ <select
246
+ value={selectedAgent?.id || ''}
247
+ onChange={(e) => handleAgentChange(e.target.value)}
248
+ >
249
+ {agents.map(a => (
250
+ <option key={a.id} value={a.id}>{a.name}</option>
251
+ ))}
252
+ </select>
253
+ </div>
254
+ </div>
255
+
256
+ <div className="main-content">
257
+ {visiblePaneIds.length === 0 ? (
258
+ <div className="multi-split-empty">
259
+ <span>All panes hidden. Use the titlebar buttons to show a pane.</span>
260
+ </div>
261
+ ) : (
262
+ <PaneLayout
263
+ layout={layout}
264
+ panes={panes.filter(p => visiblePaneIds.includes(p.id))}
265
+ onLayoutChange={setLayout}
266
+ onToggle={togglePane}
267
+ />
268
+ )}
269
+ </div>
270
+
271
+ <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} />
272
+ <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
273
+ </div>
274
+ )
275
+ }
@@ -0,0 +1,49 @@
1
+ import { useRef, useEffect, useState } from 'react'
2
+
3
+ export function CtlsurfPanel() {
4
+ const webviewRef = useRef<HTMLWebViewElement>(null)
5
+ const [url, setUrl] = useState<string | null>(null)
6
+ const [key, setKey] = useState(0) // force remount on cwd change
7
+
8
+ const loadUrl = async () => {
9
+ try {
10
+ const info = await window.worker.getWebviewInfo()
11
+ setUrl(info.pageUrl || info.frontendUrl)
12
+ } catch (err) {
13
+ console.error('[ctlsurf] Failed to get webview info:', err)
14
+ setUrl('https://app.ctlsurf.com')
15
+ }
16
+ }
17
+
18
+ useEffect(() => { loadUrl() }, [])
19
+
20
+ // Reload webview when cwd changes
21
+ useEffect(() => {
22
+ const unsub = window.worker.onCwdChanged(() => {
23
+ setUrl(null)
24
+ setKey(k => k + 1)
25
+ loadUrl()
26
+ })
27
+ return unsub
28
+ }, [])
29
+
30
+ if (!url) {
31
+ return (
32
+ <div className="ctlsurf-panel" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
33
+ <span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
34
+ </div>
35
+ )
36
+ }
37
+
38
+ return (
39
+ <div className="ctlsurf-panel">
40
+ <webview
41
+ key={key}
42
+ ref={webviewRef as any}
43
+ src={url}
44
+ style={{ width: '100%', height: '100%' }}
45
+ allowpopups={'true' as any}
46
+ />
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,232 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
2
+ import Editor, { loader } from '@monaco-editor/react'
3
+ import * as monaco from 'monaco-editor'
4
+ import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
5
+ import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
6
+ import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
7
+ import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
8
+ import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
9
+
10
+ // Configure Monaco workers for Vite
11
+ self.MonacoEnvironment = {
12
+ getWorker(_, label) {
13
+ if (label === 'json') return new jsonWorker()
14
+ if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker()
15
+ if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker()
16
+ if (label === 'typescript' || label === 'javascript') return new tsWorker()
17
+ return new editorWorker()
18
+ },
19
+ }
20
+
21
+ // Use local monaco instead of CDN
22
+ loader.config({ monaco })
23
+
24
+ interface FileEntry {
25
+ name: string
26
+ path: string
27
+ isDirectory: boolean
28
+ }
29
+
30
+ interface TreeNodeProps {
31
+ entry: FileEntry
32
+ depth: number
33
+ selectedPath: string | null
34
+ onSelect: (path: string) => void
35
+ }
36
+
37
+ function TreeNode({ entry, depth, selectedPath, onSelect }: TreeNodeProps) {
38
+ const [expanded, setExpanded] = useState(false)
39
+ const [children, setChildren] = useState<FileEntry[]>([])
40
+ const [loaded, setLoaded] = useState(false)
41
+
42
+ const toggle = async () => {
43
+ if (!entry.isDirectory) {
44
+ onSelect(entry.path)
45
+ return
46
+ }
47
+ if (!loaded) {
48
+ const entries = await window.worker.readDir(entry.path)
49
+ setChildren(entries)
50
+ setLoaded(true)
51
+ }
52
+ setExpanded(!expanded)
53
+ }
54
+
55
+ const isSelected = entry.path === selectedPath
56
+
57
+ return (
58
+ <div>
59
+ <div
60
+ className={`tree-node ${isSelected ? 'selected' : ''}`}
61
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
62
+ onClick={toggle}
63
+ >
64
+ {entry.isDirectory ? (
65
+ <span className="tree-icon">{expanded ? '\u25BE' : '\u25B8'}</span>
66
+ ) : (
67
+ <span className="tree-icon tree-file-icon">&nbsp;</span>
68
+ )}
69
+ <span className="tree-label">{entry.name}</span>
70
+ </div>
71
+ {expanded && children.map(child => (
72
+ <TreeNode
73
+ key={child.path}
74
+ entry={child}
75
+ depth={depth + 1}
76
+ selectedPath={selectedPath}
77
+ onSelect={onSelect}
78
+ />
79
+ ))}
80
+ </div>
81
+ )
82
+ }
83
+
84
+ interface EditorPanelProps {
85
+ cwd: string | null
86
+ }
87
+
88
+ export function EditorPanel({ cwd }: EditorPanelProps) {
89
+ const [rootEntries, setRootEntries] = useState<FileEntry[]>([])
90
+ const [openFile, setOpenFile] = useState<string | null>(null)
91
+ const [fileContent, setFileContent] = useState<string>('')
92
+ const [modified, setModified] = useState(false)
93
+ const [treePanelWidth, setTreePanelWidth] = useState(220)
94
+ const dragging = useRef(false)
95
+ const containerRef = useRef<HTMLDivElement>(null)
96
+
97
+ // Load root directory
98
+ useEffect(() => {
99
+ if (!cwd) return
100
+ window.worker.readDir(cwd).then(setRootEntries)
101
+ }, [cwd])
102
+
103
+ const handleFileSelect = useCallback(async (filePath: string) => {
104
+ const result = await window.worker.readFile(filePath)
105
+ if (result.ok && result.content !== undefined) {
106
+ setOpenFile(filePath)
107
+ setFileContent(result.content)
108
+ setModified(false)
109
+ }
110
+ }, [])
111
+
112
+ const handleSave = useCallback(async () => {
113
+ if (!openFile) return
114
+ const result = await window.worker.writeFile(openFile, fileContent)
115
+ if (result.ok) setModified(false)
116
+ }, [openFile, fileContent])
117
+
118
+ // Ctrl+S to save
119
+ useEffect(() => {
120
+ const handler = (e: KeyboardEvent) => {
121
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
122
+ // Only handle if focus is within our panel
123
+ if (containerRef.current?.contains(document.activeElement)) {
124
+ e.preventDefault()
125
+ handleSave()
126
+ }
127
+ }
128
+ }
129
+ window.addEventListener('keydown', handler)
130
+ return () => window.removeEventListener('keydown', handler)
131
+ }, [handleSave])
132
+
133
+ // Tree panel resize
134
+ const handleDividerDown = useCallback((e: React.MouseEvent) => {
135
+ e.preventDefault()
136
+ dragging.current = true
137
+ document.body.style.cursor = 'col-resize'
138
+ document.body.style.userSelect = 'none'
139
+ }, [])
140
+
141
+ useEffect(() => {
142
+ const onMove = (e: MouseEvent) => {
143
+ if (!dragging.current || !containerRef.current) return
144
+ const rect = containerRef.current.getBoundingClientRect()
145
+ const newWidth = Math.max(120, Math.min(400, e.clientX - rect.left))
146
+ setTreePanelWidth(newWidth)
147
+ }
148
+ const onUp = () => {
149
+ if (dragging.current) {
150
+ dragging.current = false
151
+ document.body.style.cursor = ''
152
+ document.body.style.userSelect = ''
153
+ }
154
+ }
155
+ window.addEventListener('mousemove', onMove)
156
+ window.addEventListener('mouseup', onUp)
157
+ return () => {
158
+ window.removeEventListener('mousemove', onMove)
159
+ window.removeEventListener('mouseup', onUp)
160
+ }
161
+ }, [])
162
+
163
+ const fileName = openFile?.split('/').pop() || ''
164
+
165
+ // Infer language from file extension
166
+ const getLanguage = (path: string): string => {
167
+ const ext = path.split('.').pop()?.toLowerCase() || ''
168
+ const map: Record<string, string> = {
169
+ ts: 'typescript', tsx: 'typescriptreact', js: 'javascript', jsx: 'javascriptreact',
170
+ json: 'json', html: 'html', css: 'css', scss: 'scss', less: 'less',
171
+ md: 'markdown', py: 'python', rs: 'rust', go: 'go', java: 'java',
172
+ yaml: 'yaml', yml: 'yaml', toml: 'toml', sh: 'shell', bash: 'shell',
173
+ sql: 'sql', xml: 'xml', svg: 'xml', c: 'c', cpp: 'cpp', h: 'c',
174
+ }
175
+ return map[ext] || 'plaintext'
176
+ }
177
+
178
+ return (
179
+ <div className="editor-panel" ref={containerRef}>
180
+ <div className="editor-tree" style={{ width: `${treePanelWidth}px` }}>
181
+ <div className="editor-tree-header">FILES</div>
182
+ <div className="editor-tree-content">
183
+ {rootEntries.map(entry => (
184
+ <TreeNode
185
+ key={entry.path}
186
+ entry={entry}
187
+ depth={0}
188
+ selectedPath={openFile}
189
+ onSelect={handleFileSelect}
190
+ />
191
+ ))}
192
+ </div>
193
+ </div>
194
+ <div className="editor-tree-divider" onMouseDown={handleDividerDown} />
195
+ <div className="editor-main">
196
+ {openFile ? (
197
+ <>
198
+ <div className="editor-tab-bar">
199
+ <span className="editor-tab active">
200
+ {fileName}{modified ? ' *' : ''}
201
+ </span>
202
+ {modified && (
203
+ <button className="editor-save-btn" onClick={handleSave}>Save</button>
204
+ )}
205
+ </div>
206
+ <div className="editor-monaco-container">
207
+ <Editor
208
+ language={getLanguage(openFile)}
209
+ value={fileContent}
210
+ onChange={(val) => { setFileContent(val || ''); setModified(true) }}
211
+ theme="vs-dark"
212
+ options={{
213
+ minimap: { enabled: false },
214
+ fontSize: 13,
215
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
216
+ scrollBeyondLastLine: false,
217
+ automaticLayout: true,
218
+ tabSize: 2,
219
+ wordWrap: 'on',
220
+ }}
221
+ />
222
+ </div>
223
+ </>
224
+ ) : (
225
+ <div className="editor-placeholder">
226
+ <span>Select a file to edit</span>
227
+ </div>
228
+ )}
229
+ </div>
230
+ </div>
231
+ )
232
+ }