@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.
- package/bin/ctlsurf-worker.js +173 -0
- package/electron-vite.config.ts +34 -0
- package/out/headless/index.mjs +1364 -0
- package/out/headless/index.mjs.map +7 -0
- package/out/main/index.js +1131 -0
- package/out/preload/index.js +67 -0
- package/out/renderer/assets/abap-D5KwWAsZ.js +1399 -0
- package/out/renderer/assets/apex-DVGUZ64i.js +331 -0
- package/out/renderer/assets/azcli-BEAhqcuE.js +69 -0
- package/out/renderer/assets/bat-Bqkp9Cfu.js +101 -0
- package/out/renderer/assets/bicep-DIlfshcM.js +110 -0
- package/out/renderer/assets/cameligo-CLaaYNMV.js +175 -0
- package/out/renderer/assets/clojure-fcgFaMHx.js +762 -0
- package/out/renderer/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/out/renderer/assets/coffee-CzJ5oEdj.js +233 -0
- package/out/renderer/assets/cpp-CcN6f0ik.js +390 -0
- package/out/renderer/assets/csharp-BJeIuvde.js +327 -0
- package/out/renderer/assets/csp-D_3BK2Wp.js +54 -0
- package/out/renderer/assets/css-i3rI3_64.js +186 -0
- package/out/renderer/assets/css.worker-umuuUiIb.js +53567 -0
- package/out/renderer/assets/cssMode-DL0XItGB.js +208 -0
- package/out/renderer/assets/cypher-D0--_GAN.js +264 -0
- package/out/renderer/assets/dart-vLMHv35g.js +282 -0
- package/out/renderer/assets/dockerfile--oxj0cAH.js +131 -0
- package/out/renderer/assets/ecl-CeuUgzaZ.js +457 -0
- package/out/renderer/assets/editor.worker-CNgWLVu7.js +13695 -0
- package/out/renderer/assets/elixir-eLfY1jWH.js +570 -0
- package/out/renderer/assets/flow9-ZSTChSMd.js +143 -0
- package/out/renderer/assets/freemarker2-CrOEuDcF.js +995 -0
- package/out/renderer/assets/fsharp-D2uoxuLH.js +218 -0
- package/out/renderer/assets/go-brnMpFrj.js +219 -0
- package/out/renderer/assets/graphql-BeiGgjIU.js +152 -0
- package/out/renderer/assets/handlebars-D4QYaBof.js +414 -0
- package/out/renderer/assets/hcl-CrX1Es2W.js +184 -0
- package/out/renderer/assets/html-B2Dqk2ai.js +303 -0
- package/out/renderer/assets/html.worker-BT47iy49.js +29777 -0
- package/out/renderer/assets/htmlMode-CdZ0Prhd.js +224 -0
- package/out/renderer/assets/index-CJ6RsQWP.css +8108 -0
- package/out/renderer/assets/index-pZmE1QXB.js +211777 -0
- package/out/renderer/assets/ini-BcQysCTb.js +72 -0
- package/out/renderer/assets/java-Dt3iMn2o.js +233 -0
- package/out/renderer/assets/javascript-CK8zNQXj.js +72 -0
- package/out/renderer/assets/json.worker-D4JVmXIe.js +21424 -0
- package/out/renderer/assets/jsonMode-Cewaellc.js +931 -0
- package/out/renderer/assets/julia-Cm3ItYL_.js +512 -0
- package/out/renderer/assets/kotlin-Ddo1SjA5.js +253 -0
- package/out/renderer/assets/less-B7Qaxw-O.js +162 -0
- package/out/renderer/assets/lexon-C1U0m2n9.js +158 -0
- package/out/renderer/assets/liquid-Bd3GPNs2.js +235 -0
- package/out/renderer/assets/lspLanguageFeatures-DSDH7BnA.js +1841 -0
- package/out/renderer/assets/lua-hNsuGJkO.js +163 -0
- package/out/renderer/assets/m3-6ko6q9-_.js +211 -0
- package/out/renderer/assets/markdown-B0YTnTxW.js +230 -0
- package/out/renderer/assets/mdx-CCPVCrXC.js +159 -0
- package/out/renderer/assets/mips-CJm71dS3.js +199 -0
- package/out/renderer/assets/msdax-BBeIktCY.js +376 -0
- package/out/renderer/assets/mysql-BWiizXSn.js +879 -0
- package/out/renderer/assets/objective-c-B1L1C5EC.js +184 -0
- package/out/renderer/assets/pascal-DMQyD4Xk.js +252 -0
- package/out/renderer/assets/pascaligo-VA_LQ1oU.js +165 -0
- package/out/renderer/assets/perl-DC0Z0tlO.js +627 -0
- package/out/renderer/assets/pgsql-DaSGFTLp.js +852 -0
- package/out/renderer/assets/php-Bkx1qpkQ.js +501 -0
- package/out/renderer/assets/pla-DEV89yYj.js +138 -0
- package/out/renderer/assets/postiats-CVVurEnu.js +908 -0
- package/out/renderer/assets/powerquery-BQ_t1ZiQ.js +891 -0
- package/out/renderer/assets/powershell-BXiKvz7Z.js +240 -0
- package/out/renderer/assets/protobuf-CndvAUGu.js +421 -0
- package/out/renderer/assets/pug-BxCXwerb.js +403 -0
- package/out/renderer/assets/python-34jOtlcC.js +295 -0
- package/out/renderer/assets/qsharp-BWK6YLKm.js +302 -0
- package/out/renderer/assets/r-CtqYUQ6l.js +244 -0
- package/out/renderer/assets/razor-DXRw694z.js +545 -0
- package/out/renderer/assets/redis-O7gSt3oh.js +303 -0
- package/out/renderer/assets/redshift-CvYMMYZY.js +810 -0
- package/out/renderer/assets/restructuredtext-B-KQCVu_.js +175 -0
- package/out/renderer/assets/ruby-DCd4DmAr.js +512 -0
- package/out/renderer/assets/rust-B1c0VCeq.js +344 -0
- package/out/renderer/assets/sb-Chfc_wZF.js +116 -0
- package/out/renderer/assets/scala-DbVzH-3O.js +371 -0
- package/out/renderer/assets/scheme-D7PxodDG.js +109 -0
- package/out/renderer/assets/scss-B42qMyAu.js +261 -0
- package/out/renderer/assets/shell-vZEubQ82.js +222 -0
- package/out/renderer/assets/solidity-yHOxYChb.js +1368 -0
- package/out/renderer/assets/sophia-D7pU0Y1d.js +200 -0
- package/out/renderer/assets/sparql-DxuVdnRl.js +202 -0
- package/out/renderer/assets/sql-BAGepFCR.js +854 -0
- package/out/renderer/assets/st-C-b0Dh53.js +417 -0
- package/out/renderer/assets/swift-BmOZGynf.js +313 -0
- package/out/renderer/assets/systemverilog-BOC0OOdC.js +577 -0
- package/out/renderer/assets/tcl-Bb4GCwBr.js +233 -0
- package/out/renderer/assets/ts.worker-C7hW3aY-.js +225330 -0
- package/out/renderer/assets/tsMode-CmND5_wB.js +1265 -0
- package/out/renderer/assets/twig-DvgEGWAV.js +393 -0
- package/out/renderer/assets/typescript-BNNI0Euv.js +337 -0
- package/out/renderer/assets/typespec-R77Ln7Jb.js +128 -0
- package/out/renderer/assets/vb-Bm6ESA0Q.js +373 -0
- package/out/renderer/assets/wgsl-_KPae5vw.js +454 -0
- package/out/renderer/assets/xml-CgdndrNB.js +89 -0
- package/out/renderer/assets/yaml-DNWPIf1s.js +200 -0
- package/out/renderer/index.html +13 -0
- package/package.json +67 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.ico +0 -0
- package/resources/icon.png +0 -0
- package/src/main/agents.ts +46 -0
- package/src/main/bridge.ts +180 -0
- package/src/main/ctlsurfApi.ts +142 -0
- package/src/main/detectMode.ts +17 -0
- package/src/main/headless.ts +182 -0
- package/src/main/index.ts +300 -0
- package/src/main/orchestrator.ts +404 -0
- package/src/main/pty.ts +65 -0
- package/src/main/settingsDir.ts +17 -0
- package/src/main/tui.ts +366 -0
- package/src/main/workerWs.ts +312 -0
- package/src/preload/index.ts +114 -0
- package/src/renderer/App.tsx +275 -0
- package/src/renderer/components/CtlsurfPanel.tsx +49 -0
- package/src/renderer/components/EditorPanel.tsx +232 -0
- package/src/renderer/components/MultiSplitPane.tsx +251 -0
- package/src/renderer/components/PaneLayout.tsx +419 -0
- package/src/renderer/components/SettingsDialog.tsx +204 -0
- package/src/renderer/components/SplitPane.tsx +82 -0
- package/src/renderer/components/StatusBar.tsx +73 -0
- package/src/renderer/components/TerminalPanel.tsx +140 -0
- package/src/renderer/index.html +12 -0
- package/src/renderer/main.tsx +10 -0
- package/src/renderer/styles.css +722 -0
- package/tsconfig.json +8 -0
- package/tsconfig.main.json +15 -0
- package/tsconfig.preload.json +14 -0
- package/tsconfig.renderer.json +15 -0
package/src/main/tui.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI (TUI) renderer
|
|
3
|
+
*
|
|
4
|
+
* Draws a title bar and status bar around the PTY output area.
|
|
5
|
+
* Uses ANSI escape codes and scroll regions — no external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ESC = '\x1b'
|
|
9
|
+
const CSI = `${ESC}[`
|
|
10
|
+
|
|
11
|
+
// Colors (Tokyo Night palette)
|
|
12
|
+
const BG_BAR = `${CSI}48;2;22;22;30m` // #16161e
|
|
13
|
+
const FG_TITLE = `${CSI}38;2;192;202;245m` // #c0caf5
|
|
14
|
+
const FG_DIM = `${CSI}38;2;86;95;137m` // #565f89
|
|
15
|
+
const FG_ACCENT = `${CSI}38;2;122;162;247m` // #7aa2f7
|
|
16
|
+
const FG_GREEN = `${CSI}38;2;158;206;106m` // #9ece6a
|
|
17
|
+
const FG_RED = `${CSI}38;2;247;118;142m` // #f7768e
|
|
18
|
+
const FG_YELLOW = `${CSI}38;2;224;175;104m` // #e0af68
|
|
19
|
+
const FG_WHITE = `${CSI}38;2;169;177;214m` // #a9b1d6
|
|
20
|
+
const BG_MODAL = `${CSI}48;2;31;35;53m` // #1f2335
|
|
21
|
+
const BG_SELECTED = `${CSI}48;2;42;43;61m` // #2a2b3d
|
|
22
|
+
const RESET = `${CSI}0m`
|
|
23
|
+
|
|
24
|
+
export interface TuiState {
|
|
25
|
+
agentName: string
|
|
26
|
+
cwd: string
|
|
27
|
+
wsStatus: string
|
|
28
|
+
workerId: string | null
|
|
29
|
+
mode: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Tui {
|
|
33
|
+
private rows: number = 0
|
|
34
|
+
private cols: number = 0
|
|
35
|
+
private state: TuiState = {
|
|
36
|
+
agentName: '',
|
|
37
|
+
cwd: '',
|
|
38
|
+
wsStatus: 'disconnected',
|
|
39
|
+
workerId: null,
|
|
40
|
+
mode: 'terminal',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
this.rows = process.stdout.rows || 24
|
|
45
|
+
this.cols = process.stdout.columns || 80
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the TUI: alternate screen, hide cursor reporting, set scroll region
|
|
50
|
+
*/
|
|
51
|
+
init(): void {
|
|
52
|
+
// Alternate screen buffer
|
|
53
|
+
this.write(`${CSI}?1049h`)
|
|
54
|
+
// Set scroll region: lines 2 to (rows - 1), leaving line 1 for title, line rows for status
|
|
55
|
+
this.setScrollRegion()
|
|
56
|
+
// Move cursor to the PTY area
|
|
57
|
+
this.moveToPtyArea()
|
|
58
|
+
// Draw chrome
|
|
59
|
+
this.drawTitleBar()
|
|
60
|
+
this.drawStatusBar()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Restore terminal to normal state
|
|
65
|
+
*/
|
|
66
|
+
destroy(): void {
|
|
67
|
+
// Reset scroll region
|
|
68
|
+
this.write(`${CSI}r`)
|
|
69
|
+
// Leave alternate screen
|
|
70
|
+
this.write(`${CSI}?1049l`)
|
|
71
|
+
// Show cursor
|
|
72
|
+
this.write(`${CSI}?25h`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handle terminal resize
|
|
77
|
+
*/
|
|
78
|
+
resize(cols: number, rows: number): void {
|
|
79
|
+
this.cols = cols
|
|
80
|
+
this.rows = rows
|
|
81
|
+
this.setScrollRegion()
|
|
82
|
+
this.drawTitleBar()
|
|
83
|
+
this.drawStatusBar()
|
|
84
|
+
this.moveToPtyArea()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the PTY dimensions (main area minus title + status bars)
|
|
89
|
+
*/
|
|
90
|
+
getPtySize(): { cols: number; rows: number } {
|
|
91
|
+
return {
|
|
92
|
+
cols: this.cols,
|
|
93
|
+
rows: Math.max(1, this.rows - 2),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Update state and redraw bars
|
|
99
|
+
*/
|
|
100
|
+
update(partial: Partial<TuiState>): void {
|
|
101
|
+
Object.assign(this.state, partial)
|
|
102
|
+
// Save cursor, draw bars, restore cursor
|
|
103
|
+
this.write(`${CSI}s`) // save cursor
|
|
104
|
+
this.drawTitleBar()
|
|
105
|
+
this.drawStatusBar()
|
|
106
|
+
this.write(`${CSI}u`) // restore cursor
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Write PTY output to the scroll region.
|
|
111
|
+
* Cursor is assumed to be in the PTY area already.
|
|
112
|
+
*/
|
|
113
|
+
writePtyData(data: string): void {
|
|
114
|
+
// PTY data goes straight to stdout — it's already in the scroll region
|
|
115
|
+
this.write(data)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Update the terminal window/tab title via OSC escape sequence.
|
|
120
|
+
* Works in passthrough mode (no chrome) — doesn't conflict with the agent's TUI.
|
|
121
|
+
*/
|
|
122
|
+
setTerminalTitle(title: string): void {
|
|
123
|
+
this.write(`${ESC}]0;${title}\x07`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a title string from current state for the terminal tab.
|
|
128
|
+
*/
|
|
129
|
+
updateTerminalTitle(): void {
|
|
130
|
+
const { agentName, wsStatus, cwd } = this.state
|
|
131
|
+
const displayCwd = this.shortenPath(cwd)
|
|
132
|
+
const statusIcon = {
|
|
133
|
+
connected: '\u25CF',
|
|
134
|
+
connecting: '\u25CB',
|
|
135
|
+
disconnected: '\u25CB',
|
|
136
|
+
pending_approval: '\u25CB',
|
|
137
|
+
no_project: '\u25CB',
|
|
138
|
+
}[wsStatus] || '\u25CB'
|
|
139
|
+
const statusLabel = {
|
|
140
|
+
connected: 'Connected',
|
|
141
|
+
connecting: 'Connecting...',
|
|
142
|
+
disconnected: 'Disconnected',
|
|
143
|
+
pending_approval: 'Pending',
|
|
144
|
+
no_project: 'No Project',
|
|
145
|
+
}[wsStatus] || wsStatus
|
|
146
|
+
|
|
147
|
+
this.setTerminalTitle(`ctlsurf \u00B7 ${agentName} \u00B7 ${statusIcon} ${statusLabel} \u00B7 ${displayCwd}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Show an interactive agent picker modal.
|
|
152
|
+
* Returns a promise that resolves with the selected agent index.
|
|
153
|
+
*/
|
|
154
|
+
showAgentPicker(agents: { name: string; description: string }[]): Promise<number> {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
let selected = 0
|
|
157
|
+
const modalWidth = 44
|
|
158
|
+
const modalHeight = agents.length + 4 // border + title + items + border
|
|
159
|
+
const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2))
|
|
160
|
+
const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2))
|
|
161
|
+
|
|
162
|
+
// Enter alternate screen if not already
|
|
163
|
+
this.write(`${CSI}?1049h`)
|
|
164
|
+
// Hide cursor
|
|
165
|
+
this.write(`${CSI}?25l`)
|
|
166
|
+
|
|
167
|
+
const drawModal = () => {
|
|
168
|
+
const topBorder = '\u250c' + '\u2500'.repeat(modalWidth - 2) + '\u2510'
|
|
169
|
+
const botBorder = '\u2514' + '\u2500'.repeat(modalWidth - 2) + '\u2518'
|
|
170
|
+
const emptyLine = '\u2502' + ' '.repeat(modalWidth - 2) + '\u2502'
|
|
171
|
+
|
|
172
|
+
// Draw background fill
|
|
173
|
+
for (let r = 0; r < this.rows; r++) {
|
|
174
|
+
this.write(`${CSI}${r + 1};1H${BG_BAR}${CSI}2K${RESET}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Draw logo/branding centered
|
|
178
|
+
const brand = 'ctlsurf'
|
|
179
|
+
const brandCol = Math.max(1, Math.floor((this.cols - brand.length) / 2))
|
|
180
|
+
this.write(`${CSI}${startRow - 2};${brandCol}H${FG_ACCENT}${brand}${RESET}`)
|
|
181
|
+
|
|
182
|
+
// Top border
|
|
183
|
+
this.write(`${CSI}${startRow};${startCol}H${BG_MODAL}${FG_DIM}${topBorder}${RESET}`)
|
|
184
|
+
|
|
185
|
+
// Title
|
|
186
|
+
const title = ' Select Agent'
|
|
187
|
+
const titlePad = ' '.repeat(Math.max(0, modalWidth - 2 - title.length))
|
|
188
|
+
this.write(`${CSI}${startRow + 1};${startCol}H${BG_MODAL}${FG_DIM}\u2502${RESET}${BG_MODAL}${FG_TITLE}${title}${titlePad}${FG_DIM}\u2502${RESET}`)
|
|
189
|
+
|
|
190
|
+
// Separator
|
|
191
|
+
const sep = '\u251c' + '\u2500'.repeat(modalWidth - 2) + '\u2524'
|
|
192
|
+
this.write(`${CSI}${startRow + 2};${startCol}H${BG_MODAL}${FG_DIM}${sep}${RESET}`)
|
|
193
|
+
|
|
194
|
+
// Agent items
|
|
195
|
+
for (let i = 0; i < agents.length; i++) {
|
|
196
|
+
const agent = agents[i]
|
|
197
|
+
const row = startRow + 3 + i
|
|
198
|
+
const isSelected = i === selected
|
|
199
|
+
const bg = isSelected ? BG_SELECTED : BG_MODAL
|
|
200
|
+
const pointer = isSelected ? `${FG_ACCENT}\u25B8 ` : ' '
|
|
201
|
+
const nameFg = isSelected ? FG_ACCENT : FG_WHITE
|
|
202
|
+
const descFg = FG_DIM
|
|
203
|
+
const nameStr = agent.name
|
|
204
|
+
const descStr = agent.description ? ` ${FG_DIM}— ${agent.description.slice(0, 20)}` : ''
|
|
205
|
+
const content = `${pointer}${nameFg}${nameStr}${descStr}`
|
|
206
|
+
const contentLen = (isSelected ? 2 : 2) + nameStr.length + (agent.description ? 3 + Math.min(20, agent.description.length) : 0)
|
|
207
|
+
const pad = ' '.repeat(Math.max(0, modalWidth - 2 - contentLen))
|
|
208
|
+
this.write(`${CSI}${row};${startCol}H${bg}${FG_DIM}\u2502${RESET}${bg}${content}${pad}${RESET}${BG_MODAL}${FG_DIM}\u2502${RESET}`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Bottom border
|
|
212
|
+
const botRow = startRow + 3 + agents.length
|
|
213
|
+
this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
|
|
214
|
+
|
|
215
|
+
// Hint
|
|
216
|
+
const hint = '\u2191\u2193 navigate · Enter select · q quit'
|
|
217
|
+
const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
|
|
218
|
+
this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
drawModal()
|
|
222
|
+
|
|
223
|
+
// Set raw mode to capture keypresses
|
|
224
|
+
if (process.stdin.isTTY) {
|
|
225
|
+
process.stdin.setRawMode(true)
|
|
226
|
+
}
|
|
227
|
+
process.stdin.resume()
|
|
228
|
+
|
|
229
|
+
const onKey = (data: Buffer) => {
|
|
230
|
+
const key = data.toString()
|
|
231
|
+
|
|
232
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
233
|
+
// Up
|
|
234
|
+
selected = (selected - 1 + agents.length) % agents.length
|
|
235
|
+
drawModal()
|
|
236
|
+
} else if (key === '\x1b[B' || key === 'j') {
|
|
237
|
+
// Down
|
|
238
|
+
selected = (selected + 1) % agents.length
|
|
239
|
+
drawModal()
|
|
240
|
+
} else if (key === '\r' || key === '\n') {
|
|
241
|
+
// Enter — select
|
|
242
|
+
cleanup()
|
|
243
|
+
resolve(selected)
|
|
244
|
+
} else if (key === 'q' || key === '\x1b' || key === '\x03') {
|
|
245
|
+
// q, Escape, Ctrl+C — quit
|
|
246
|
+
cleanup()
|
|
247
|
+
this.write(`${CSI}?25h`) // show cursor
|
|
248
|
+
this.write(`${CSI}?1049l`) // leave alt screen
|
|
249
|
+
process.exit(0)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const cleanup = () => {
|
|
254
|
+
process.stdin.removeListener('data', onKey)
|
|
255
|
+
// Show cursor again
|
|
256
|
+
this.write(`${CSI}?25h`)
|
|
257
|
+
// Clear the modal (will be redrawn by init())
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
process.stdin.on('data', onKey)
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Internal ───────────────────────────────────
|
|
265
|
+
|
|
266
|
+
private write(data: string): void {
|
|
267
|
+
try {
|
|
268
|
+
process.stdout.write(data)
|
|
269
|
+
} catch { /* EPIPE safe */ }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private setScrollRegion(): void {
|
|
273
|
+
// Scroll region from line 2 to line (rows - 1)
|
|
274
|
+
this.write(`${CSI}2;${this.rows - 1}r`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private moveToPtyArea(): void {
|
|
278
|
+
// Move cursor to top of PTY area (line 2)
|
|
279
|
+
this.write(`${CSI}2;1H`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private drawTitleBar(): void {
|
|
283
|
+
const { agentName, cwd, wsStatus } = this.state
|
|
284
|
+
// Move to line 1
|
|
285
|
+
this.write(`${CSI}1;1H`)
|
|
286
|
+
// Clear line and draw
|
|
287
|
+
this.write(`${BG_BAR}${CSI}2K`)
|
|
288
|
+
|
|
289
|
+
const displayCwd = this.shortenPath(cwd)
|
|
290
|
+
|
|
291
|
+
// WS status indicator
|
|
292
|
+
const statusColor = {
|
|
293
|
+
connected: FG_GREEN,
|
|
294
|
+
connecting: FG_YELLOW,
|
|
295
|
+
disconnected: FG_RED,
|
|
296
|
+
pending_approval: FG_YELLOW,
|
|
297
|
+
no_project: FG_DIM,
|
|
298
|
+
}[wsStatus] || FG_DIM
|
|
299
|
+
const statusLabel = {
|
|
300
|
+
connected: 'Connected',
|
|
301
|
+
connecting: 'Connecting...',
|
|
302
|
+
disconnected: 'Disconnected',
|
|
303
|
+
pending_approval: 'Pending',
|
|
304
|
+
no_project: 'No Project',
|
|
305
|
+
}[wsStatus] || wsStatus
|
|
306
|
+
const wsIndicator = `${statusColor}\u25CF ${statusLabel}${RESET}${BG_BAR}`
|
|
307
|
+
|
|
308
|
+
const left = ` ${FG_ACCENT}ctlsurf${RESET}${BG_BAR} ${FG_DIM}\u2502${RESET}${BG_BAR} ${FG_TITLE}${agentName || 'starting...'}${RESET}${BG_BAR} ${FG_DIM}\u2502${RESET}${BG_BAR} ${FG_DIM}${displayCwd}${RESET}${BG_BAR}`
|
|
309
|
+
const right = `${wsIndicator} ${RESET}${BG_BAR}`
|
|
310
|
+
|
|
311
|
+
this.write(left)
|
|
312
|
+
const pad = Math.max(0, this.cols - this.visibleLen(left) - this.visibleLen(right))
|
|
313
|
+
this.write(' '.repeat(pad))
|
|
314
|
+
this.write(right)
|
|
315
|
+
this.write(RESET)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private drawStatusBar(): void {
|
|
319
|
+
const { wsStatus, workerId, cwd } = this.state
|
|
320
|
+
// Move to last line
|
|
321
|
+
this.write(`${CSI}${this.rows};1H`)
|
|
322
|
+
// Clear line and draw
|
|
323
|
+
this.write(`${BG_BAR}${CSI}2K`)
|
|
324
|
+
|
|
325
|
+
const statusColor = {
|
|
326
|
+
connected: FG_GREEN,
|
|
327
|
+
connecting: FG_YELLOW,
|
|
328
|
+
disconnected: FG_RED,
|
|
329
|
+
pending_approval: FG_YELLOW,
|
|
330
|
+
no_project: FG_DIM,
|
|
331
|
+
}[wsStatus] || FG_DIM
|
|
332
|
+
|
|
333
|
+
const statusDot = `${statusColor}\u25CF${RESET}${BG_BAR}`
|
|
334
|
+
const statusLabel = {
|
|
335
|
+
connected: 'Connected',
|
|
336
|
+
connecting: 'Connecting...',
|
|
337
|
+
disconnected: 'Disconnected',
|
|
338
|
+
pending_approval: 'Pending Approval',
|
|
339
|
+
no_project: 'No Project',
|
|
340
|
+
}[wsStatus] || wsStatus
|
|
341
|
+
|
|
342
|
+
const displayCwd = this.shortenPath(cwd)
|
|
343
|
+
const left = ` ${statusDot} ${FG_DIM}${statusLabel}${RESET}${BG_BAR}`
|
|
344
|
+
const right = `${FG_DIM}Ctrl+\\ exit${RESET}${BG_BAR} ${FG_DIM}${displayCwd} ${RESET}${BG_BAR}`
|
|
345
|
+
|
|
346
|
+
this.write(left)
|
|
347
|
+
const pad = Math.max(0, this.cols - this.visibleLen(left) - this.visibleLen(right))
|
|
348
|
+
this.write(' '.repeat(pad))
|
|
349
|
+
this.write(right)
|
|
350
|
+
this.write(RESET)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private shortenPath(p: string): string {
|
|
354
|
+
if (!p) return ''
|
|
355
|
+
const home = process.env.HOME || ''
|
|
356
|
+
if (home && p.startsWith(home)) {
|
|
357
|
+
return '~' + p.slice(home.length)
|
|
358
|
+
}
|
|
359
|
+
return p
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private visibleLen(s: string): number {
|
|
363
|
+
// Strip ANSI codes to get visible length
|
|
364
|
+
return s.replace(/\x1b\[[^m]*m/g, '').length
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import os from 'os'
|
|
2
|
+
import crypto from 'crypto'
|
|
3
|
+
|
|
4
|
+
function log(...args: unknown[]): void {
|
|
5
|
+
try { console.log(...args) } catch { /* EPIPE safe */ }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
9
|
+
const RECONNECT_DELAY_MS = 5_000
|
|
10
|
+
const MAX_RECONNECT_DELAY_MS = 60_000
|
|
11
|
+
|
|
12
|
+
export interface WorkerRegistration {
|
|
13
|
+
machine: string
|
|
14
|
+
cwd: string
|
|
15
|
+
repo_url?: string
|
|
16
|
+
agent: string
|
|
17
|
+
fingerprint: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkerWsEvents {
|
|
21
|
+
onStatusChange: (status: WorkerWsStatus) => void
|
|
22
|
+
onMessage: (message: IncomingMessage) => void
|
|
23
|
+
onRegistered: (data: { worker_id: string; folder_id: string | null; status: string; pending_messages?: IncomingMessage[] }) => void
|
|
24
|
+
onTerminalInput?: (data: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IncomingMessage {
|
|
28
|
+
id: string
|
|
29
|
+
type: string
|
|
30
|
+
content: string
|
|
31
|
+
metadata?: Record<string, unknown> | null
|
|
32
|
+
parent_id?: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type WorkerWsStatus = 'disconnected' | 'connecting' | 'connected' | 'pending_approval'
|
|
36
|
+
|
|
37
|
+
export class WorkerWsClient {
|
|
38
|
+
private ws: WebSocket | null = null
|
|
39
|
+
private apiKey: string | null = null
|
|
40
|
+
private baseUrl: string
|
|
41
|
+
private events: WorkerWsEvents
|
|
42
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
43
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
44
|
+
private reconnectDelay = RECONNECT_DELAY_MS
|
|
45
|
+
private registration: WorkerRegistration | null = null
|
|
46
|
+
private workerId: string | null = null
|
|
47
|
+
private _status: WorkerWsStatus = 'disconnected'
|
|
48
|
+
private shouldReconnect = false
|
|
49
|
+
private fingerprint: string
|
|
50
|
+
|
|
51
|
+
constructor(events: WorkerWsEvents, baseUrl?: string) {
|
|
52
|
+
this.events = events
|
|
53
|
+
this.baseUrl = baseUrl || 'wss://app.ctlsurf.com'
|
|
54
|
+
// Generate a stable machine fingerprint
|
|
55
|
+
this.fingerprint = this.generateFingerprint()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get status(): WorkerWsStatus {
|
|
59
|
+
return this._status
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get currentWorkerId(): string | null {
|
|
63
|
+
return this.workerId
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setApiKey(key: string | null): void {
|
|
67
|
+
this.apiKey = key
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setBaseUrl(url: string): void {
|
|
71
|
+
this.baseUrl = url
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private generateFingerprint(): string {
|
|
75
|
+
const data = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}`
|
|
76
|
+
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private setStatus(status: WorkerWsStatus): void {
|
|
80
|
+
if (this._status !== status) {
|
|
81
|
+
this._status = status
|
|
82
|
+
this.events.onStatusChange(status)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
connect(registration: WorkerRegistration): void {
|
|
87
|
+
this.registration = { ...registration, fingerprint: this.fingerprint }
|
|
88
|
+
this.shouldReconnect = true
|
|
89
|
+
this.doConnect()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
disconnect(): void {
|
|
93
|
+
this.shouldReconnect = false
|
|
94
|
+
this.clearTimers()
|
|
95
|
+
if (this.ws) {
|
|
96
|
+
const oldWs = this.ws
|
|
97
|
+
this.ws = null
|
|
98
|
+
// Remove handlers before closing to prevent stale onclose from firing
|
|
99
|
+
oldWs.onopen = null
|
|
100
|
+
oldWs.onmessage = null
|
|
101
|
+
oldWs.onclose = null
|
|
102
|
+
oldWs.onerror = null
|
|
103
|
+
try { oldWs.close(1000, 'client disconnect') } catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
this.setStatus('disconnected')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
sendResponse(parentId: string, content: string, metadata?: Record<string, unknown>): void {
|
|
109
|
+
this.send({
|
|
110
|
+
type: 'response',
|
|
111
|
+
parent_id: parentId,
|
|
112
|
+
content,
|
|
113
|
+
metadata,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
sendStatusUpdate(status: string): void {
|
|
118
|
+
this.send({ type: 'status_update', status })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
sendAck(messageId: string): void {
|
|
122
|
+
this.send({ type: 'ack', message_id: messageId })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sendTerminalData(data: string): void {
|
|
126
|
+
this.send({ type: 'terminal_stream', data })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
sendTerminalResize(cols: number, rows: number): void {
|
|
130
|
+
this.send({ type: 'terminal_resize', cols, rows })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private doConnect(): void {
|
|
134
|
+
if (!this.apiKey || !this.registration) {
|
|
135
|
+
log('[worker-ws] No API key or registration, skipping connect')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.clearTimers()
|
|
140
|
+
if (this.ws) {
|
|
141
|
+
const oldWs = this.ws
|
|
142
|
+
this.ws = null
|
|
143
|
+
oldWs.onopen = null
|
|
144
|
+
oldWs.onmessage = null
|
|
145
|
+
oldWs.onclose = null
|
|
146
|
+
oldWs.onerror = null
|
|
147
|
+
try { oldWs.close() } catch { /* ignore */ }
|
|
148
|
+
// Let the old connection fully close before opening a new one
|
|
149
|
+
setTimeout(() => this.doConnectNow(), 500)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.doConnectNow()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private doConnectNow(): void {
|
|
157
|
+
if (!this.apiKey || !this.registration) return
|
|
158
|
+
if (!this.shouldReconnect) {
|
|
159
|
+
log('[worker-ws] shouldReconnect is false, aborting connect')
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.setStatus('connecting')
|
|
164
|
+
|
|
165
|
+
// Use ws:// for localhost, wss:// for remote
|
|
166
|
+
const wsBase = this.baseUrl.replace(/^http/, 'ws')
|
|
167
|
+
const url = `${wsBase}/api/ws/worker?token=${encodeURIComponent(this.apiKey)}`
|
|
168
|
+
|
|
169
|
+
log(`[worker-ws] Connecting to ${url.replace(/token=.*/, 'token=***')}...`)
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
this.ws = new WebSocket(url)
|
|
173
|
+
} catch (err) {
|
|
174
|
+
log('[worker-ws] Failed to create WebSocket:', err)
|
|
175
|
+
this.scheduleReconnect()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.ws.onopen = () => {
|
|
180
|
+
log('[worker-ws] Connected, sending register')
|
|
181
|
+
this.reconnectDelay = RECONNECT_DELAY_MS // Reset backoff
|
|
182
|
+
this.send({
|
|
183
|
+
type: 'register',
|
|
184
|
+
...this.registration,
|
|
185
|
+
})
|
|
186
|
+
this.startHeartbeat()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.ws.onmessage = (event) => {
|
|
190
|
+
try {
|
|
191
|
+
const data = JSON.parse(String(event.data))
|
|
192
|
+
this.handleMessage(data)
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log('[worker-ws] Failed to parse message:', err)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.ws.onclose = (event) => {
|
|
199
|
+
log(`[worker-ws] Disconnected: ${event.code} ${event.reason}`)
|
|
200
|
+
this.ws = null
|
|
201
|
+
this.clearHeartbeat()
|
|
202
|
+
this.setStatus('disconnected')
|
|
203
|
+
if (this.shouldReconnect) {
|
|
204
|
+
this.scheduleReconnect()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.ws.onerror = () => {
|
|
209
|
+
log('[worker-ws] WebSocket error')
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private handleMessage(data: Record<string, unknown>): void {
|
|
214
|
+
const msgType = data.type as string
|
|
215
|
+
|
|
216
|
+
switch (msgType) {
|
|
217
|
+
case 'registered': {
|
|
218
|
+
this.workerId = data.worker_id as string
|
|
219
|
+
const workerStatus = data.status as string
|
|
220
|
+
console.log(`[worker-ws] Registered as ${this.workerId}, status: ${workerStatus}`)
|
|
221
|
+
|
|
222
|
+
if (workerStatus === 'pending_approval') {
|
|
223
|
+
this.setStatus('pending_approval')
|
|
224
|
+
} else {
|
|
225
|
+
this.setStatus('connected')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const pendingMessages = (data.pending_messages || []) as IncomingMessage[]
|
|
229
|
+
this.events.onRegistered({
|
|
230
|
+
worker_id: this.workerId,
|
|
231
|
+
folder_id: data.folder_id as string | null,
|
|
232
|
+
status: workerStatus,
|
|
233
|
+
pending_messages: pendingMessages,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Deliver pending messages
|
|
237
|
+
for (const msg of pendingMessages) {
|
|
238
|
+
this.events.onMessage(msg)
|
|
239
|
+
}
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case 'approved': {
|
|
244
|
+
log('[worker-ws] Worker approved!')
|
|
245
|
+
this.setStatus('connected')
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case 'message': {
|
|
250
|
+
const msg = data.message as IncomingMessage
|
|
251
|
+
if (msg) {
|
|
252
|
+
console.log(`[worker-ws] Received message: ${msg.id}`)
|
|
253
|
+
this.events.onMessage(msg)
|
|
254
|
+
}
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'terminal_input': {
|
|
259
|
+
const inputData = data.data as string
|
|
260
|
+
if (inputData && this.events.onTerminalInput) {
|
|
261
|
+
this.events.onTerminalInput(inputData)
|
|
262
|
+
}
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case 'heartbeat_ack':
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
default:
|
|
270
|
+
console.log(`[worker-ws] Unknown message type: ${msgType}`)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private send(data: Record<string, unknown>): void {
|
|
275
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
276
|
+
this.ws.send(JSON.stringify(data))
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
private startHeartbeat(): void {
|
|
282
|
+
this.clearHeartbeat()
|
|
283
|
+
this.heartbeatTimer = setInterval(() => {
|
|
284
|
+
this.send({ type: 'heartbeat' })
|
|
285
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private clearHeartbeat(): void {
|
|
289
|
+
if (this.heartbeatTimer) {
|
|
290
|
+
clearInterval(this.heartbeatTimer)
|
|
291
|
+
this.heartbeatTimer = null
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private scheduleReconnect(): void {
|
|
296
|
+
if (!this.shouldReconnect) return
|
|
297
|
+
console.log(`[worker-ws] Reconnecting in ${this.reconnectDelay / 1000}s...`)
|
|
298
|
+
this.reconnectTimer = setTimeout(() => {
|
|
299
|
+
this.doConnect()
|
|
300
|
+
}, this.reconnectDelay)
|
|
301
|
+
// Exponential backoff
|
|
302
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private clearTimers(): void {
|
|
306
|
+
this.clearHeartbeat()
|
|
307
|
+
if (this.reconnectTimer) {
|
|
308
|
+
clearTimeout(this.reconnectTimer)
|
|
309
|
+
this.reconnectTimer = null
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|