@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.1.4",
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",
@@ -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
- * Taps the pty output stream, strips ANSI codes, buffers output,
7
- * and sends cleaned chunks to the backend via WebSocket for S3-backed chat logging.
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 // flush every 3 seconds
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.buffer = ''
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
- * Buffers and flushes periodically.
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.buffer += data
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 input = this.inputBuffer.trim()
57
- if (input.length > 0) {
58
- this.sendEntry('user_input', 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
- * Flush buffered output via WebSocket.
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
- if (this.buffer.length === 0) return
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
- const chunk = this.buffer
71
- this.buffer = ''
93
+ this.lastSnapshotLines = totalLines
72
94
 
73
- // Strip ANSI escape codes for cleaner log entries
74
- const cleaned = stripAnsi(chunk)
75
- if (cleaned.trim().length === 0) return
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 terminal output.
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
- // Carriage returns
127
- .replace(/\r/g, '')
128
- // Control characters except newline/tab
129
- // eslint-disable-next-line no-control-regex
130
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
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
  }
@@ -330,6 +330,7 @@ export class Orchestrator {
330
330
 
331
331
  resizePty(cols: number, rows: number): void {
332
332
  this.ptyManager?.resize(cols, rows)
333
+ this.bridge.resize(cols, rows)
333
334
  this.workerWs.sendTerminalResize(cols, rows)
334
335
  }
335
336