@phenx-inc/ctlsurf 0.1.4 → 0.1.7
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/out/headless/index.mjs +3468 -28
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +3474 -53
- package/package.json +2 -1
- package/src/main/bridge.ts +75 -43
- package/src/main/orchestrator.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
|
|
5
5
|
"main": "out/main/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@monaco-editor/react": "^4.7.0",
|
|
47
47
|
"@xterm/addon-fit": "^0.10.0",
|
|
48
48
|
"@xterm/addon-web-links": "^0.11.0",
|
|
49
|
+
"@xterm/headless": "^5.5.0",
|
|
49
50
|
"@xterm/xterm": "^5.5.0",
|
|
50
51
|
"electron-store": "^10.0.0",
|
|
51
52
|
"esbuild": "^0.27.4",
|
package/src/main/bridge.ts
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
1
|
+
import { Terminal } from '@xterm/headless'
|
|
1
2
|
import { WorkerWsClient } from './workerWs'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Conversation Bridge
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* Uses a headless xterm terminal emulator to process raw PTY output
|
|
8
|
+
* (cursor movements, overwrites, clears) into clean rendered text.
|
|
9
|
+
* Periodically diffs the terminal buffer and sends new content
|
|
10
|
+
* to the backend via WebSocket for S3-backed chat logging.
|
|
8
11
|
*/
|
|
9
12
|
export class ConversationBridge {
|
|
10
13
|
private wsClient: WorkerWsClient | null = null
|
|
11
|
-
private buffer: string = ''
|
|
12
14
|
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
13
|
-
private flushIntervalMs: number = 3000
|
|
15
|
+
private flushIntervalMs: number = 3000
|
|
14
16
|
private sessionActive: boolean = false
|
|
15
17
|
private inputBuffer: string = ''
|
|
16
18
|
|
|
19
|
+
// Virtual terminal for processing escape sequences into rendered text
|
|
20
|
+
private terminal: Terminal
|
|
21
|
+
private lastSnapshotLines: number = 0 // how many lines we've already sent
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
this.terminal = new Terminal({ cols: 120, rows: 50, scrollback: 10000 })
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
setWsClient(ws: WorkerWsClient): void {
|
|
18
28
|
this.wsClient = ws
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
/**
|
|
22
|
-
* Start a new logging session.
|
|
23
|
-
*/
|
|
24
31
|
startSession(): void {
|
|
25
|
-
this.
|
|
32
|
+
this.terminal.reset()
|
|
33
|
+
this.lastSnapshotLines = 0
|
|
26
34
|
this.inputBuffer = ''
|
|
27
35
|
this.sessionActive = true
|
|
28
36
|
console.log('[bridge] Session started')
|
|
@@ -30,56 +38,68 @@ export class ConversationBridge {
|
|
|
30
38
|
|
|
31
39
|
/**
|
|
32
40
|
* Feed terminal output data into the bridge.
|
|
33
|
-
*
|
|
41
|
+
* The headless terminal processes all escape sequences (cursor moves,
|
|
42
|
+
* line clears, color codes) so the buffer contains clean rendered text.
|
|
34
43
|
*/
|
|
35
44
|
feedOutput(data: string): void {
|
|
36
45
|
if (!this.sessionActive) return
|
|
37
46
|
|
|
38
|
-
this.
|
|
47
|
+
this.terminal.write(data)
|
|
39
48
|
|
|
40
|
-
// Reset flush timer
|
|
41
49
|
if (this.flushTimer) {
|
|
42
50
|
clearTimeout(this.flushTimer)
|
|
43
51
|
}
|
|
44
52
|
this.flushTimer = setTimeout(() => this.flush(), this.flushIntervalMs)
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
/**
|
|
48
|
-
* Feed user input data into the bridge.
|
|
49
|
-
*/
|
|
50
55
|
feedInput(data: string): void {
|
|
51
56
|
if (!this.sessionActive) return
|
|
52
57
|
this.inputBuffer += data
|
|
53
58
|
|
|
54
|
-
// Detect Enter key (newline) — flush the input as a user prompt
|
|
55
59
|
if (data.includes('\r') || data.includes('\n')) {
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
this.sendEntry('user_input',
|
|
60
|
+
const cleaned = cleanInput(this.inputBuffer)
|
|
61
|
+
if (cleaned.length > 0) {
|
|
62
|
+
this.sendEntry('user_input', cleaned)
|
|
59
63
|
}
|
|
60
64
|
this.inputBuffer = ''
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/**
|
|
65
|
-
*
|
|
69
|
+
* Resize the virtual terminal to match the actual PTY dimensions.
|
|
70
|
+
*/
|
|
71
|
+
resize(cols: number, rows: number): void {
|
|
72
|
+
this.terminal.resize(cols, rows)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read new content from the terminal buffer since the last flush.
|
|
66
77
|
*/
|
|
67
78
|
private flush(): void {
|
|
68
|
-
|
|
79
|
+
const buf = this.terminal.buffer.active
|
|
80
|
+
const totalLines = buf.baseY + buf.cursorY + 1
|
|
81
|
+
|
|
82
|
+
if (totalLines <= this.lastSnapshotLines) return
|
|
83
|
+
|
|
84
|
+
// Extract only the new lines since last flush
|
|
85
|
+
const newLines: string[] = []
|
|
86
|
+
for (let i = this.lastSnapshotLines; i < totalLines; i++) {
|
|
87
|
+
const line = buf.getLine(i)
|
|
88
|
+
if (line) {
|
|
89
|
+
newLines.push(line.translateToString(true))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
this.buffer = ''
|
|
93
|
+
this.lastSnapshotLines = totalLines
|
|
72
94
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
const content = newLines.join('\n')
|
|
96
|
+
// Collapse excessive blank lines
|
|
97
|
+
const cleaned = content.replace(/\n{3,}/g, '\n\n').trim()
|
|
98
|
+
if (cleaned.length === 0) return
|
|
76
99
|
|
|
77
100
|
this.sendEntry('terminal_output', cleaned)
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
/**
|
|
81
|
-
* Send an entry to the backend via WebSocket.
|
|
82
|
-
*/
|
|
83
103
|
private sendEntry(type: string, content: string): void {
|
|
84
104
|
if (!this.wsClient) return
|
|
85
105
|
this.wsClient.sendChatLog({
|
|
@@ -89,13 +109,9 @@ export class ConversationBridge {
|
|
|
89
109
|
})
|
|
90
110
|
}
|
|
91
111
|
|
|
92
|
-
/**
|
|
93
|
-
* End the current session.
|
|
94
|
-
*/
|
|
95
112
|
endSession(): void {
|
|
96
113
|
if (!this.sessionActive) return
|
|
97
114
|
|
|
98
|
-
// Flush remaining buffer
|
|
99
115
|
this.flush()
|
|
100
116
|
|
|
101
117
|
if (this.flushTimer) {
|
|
@@ -104,28 +120,44 @@ export class ConversationBridge {
|
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
this.sessionActive = false
|
|
107
|
-
this.buffer = ''
|
|
108
123
|
this.inputBuffer = ''
|
|
109
124
|
console.log('[bridge] Session ended')
|
|
110
125
|
}
|
|
111
126
|
}
|
|
112
127
|
|
|
113
128
|
/**
|
|
114
|
-
* Strip ANSI escape codes from
|
|
129
|
+
* Strip ANSI escape codes from a string.
|
|
115
130
|
*/
|
|
116
131
|
function stripAnsi(str: string): string {
|
|
117
132
|
return str
|
|
118
|
-
// CSI sequences (e.g. \x1b[0m, \x1b[?2004h, \x1b[1;32m)
|
|
119
133
|
.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, '')
|
|
120
|
-
// OSC sequences (e.g. \x1b]0;title\x07)
|
|
121
134
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
122
|
-
// Other escape sequences (charset, keypad mode, etc.)
|
|
123
135
|
.replace(/\x1b[^[\]](.|$)/g, '')
|
|
124
|
-
// Remaining single ESC
|
|
125
136
|
.replace(/\x1b/g, '')
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Process backspace characters: each \x7f or \b deletes the preceding char.
|
|
141
|
+
*/
|
|
142
|
+
function processBackspaces(str: string): string {
|
|
143
|
+
const result: string[] = []
|
|
144
|
+
for (const ch of str) {
|
|
145
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
146
|
+
result.pop()
|
|
147
|
+
} else {
|
|
148
|
+
result.push(ch)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result.join('')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Clean user input: strip ANSI, process backspaces, remove control chars.
|
|
156
|
+
*/
|
|
157
|
+
function cleanInput(str: string): string {
|
|
158
|
+
let cleaned = stripAnsi(str)
|
|
159
|
+
cleaned = processBackspaces(cleaned)
|
|
160
|
+
// eslint-disable-next-line no-control-regex
|
|
161
|
+
cleaned = cleaned.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
162
|
+
return cleaned.trim()
|
|
131
163
|
}
|
package/src/main/orchestrator.ts
CHANGED