@phenx-inc/ctlsurf 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
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,7 +46,6 @@
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",
50
49
  "@xterm/xterm": "^5.5.0",
51
50
  "electron-store": "^10.0.0",
52
51
  "esbuild": "^0.27.4",
@@ -1,63 +1,38 @@
1
- import { Terminal } from '@xterm/headless'
2
1
  import { WorkerWsClient } from './workerWs'
3
2
 
4
3
  /**
5
4
  * Conversation Bridge
6
5
  *
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.
6
+ * Captures PTY output, strips ANSI codes and terminal artifacts,
7
+ * and sends cleaned text to the backend via WebSocket for chat logging.
8
+ * Uses a simple buffer + byte threshold approach (no timers, since
9
+ * setTimeout/setInterval don't reliably fire under TUI raw mode).
11
10
  */
12
11
  export class ConversationBridge {
13
12
  private wsClient: WorkerWsClient | null = null
14
13
  private sessionActive: boolean = false
15
14
  private inputBuffer: string = ''
16
- private bytesAccumulated: number = 0
15
+ private outputBuffer: string = ''
17
16
 
18
- // Virtual terminal for processing escape sequences into rendered text
19
- private terminal: Terminal
20
- private lastSnapshot: string = ''
21
-
22
- // Flush after accumulating enough data or on a timer
23
- private readonly FLUSH_BYTES = 2000
24
- private flushTimer: ReturnType<typeof setInterval> | null = null
25
-
26
- constructor() {
27
- this.terminal = new Terminal({ cols: 120, rows: 50, scrollback: 10000, allowProposedApi: true })
28
- }
17
+ private readonly FLUSH_BYTES = 500
29
18
 
30
19
  setWsClient(ws: WorkerWsClient): void {
31
20
  this.wsClient = ws
32
21
  }
33
22
 
34
23
  startSession(): void {
35
- this.terminal.reset()
36
- this.lastSnapshot = ''
37
- this.bytesAccumulated = 0
24
+ this.outputBuffer = ''
38
25
  this.inputBuffer = ''
39
26
  this.sessionActive = true
40
-
41
- // Periodic flush via setInterval (setTimeout may not fire under raw-mode TUI)
42
- if (this.flushTimer) clearInterval(this.flushTimer)
43
- this.flushTimer = setInterval(() => this.flush(), 5000)
44
-
45
27
  console.log('[bridge] Session started')
46
28
  }
47
29
 
48
- /**
49
- * Feed terminal output data into the bridge.
50
- * xterm.write() is async — we track pending writes and only flush
51
- * when all writes have been processed into the buffer.
52
- */
53
30
  feedOutput(data: string): void {
54
31
  if (!this.sessionActive) return
55
32
 
56
- this.terminal.write(data)
57
- this.bytesAccumulated += data.length
33
+ this.outputBuffer += data
58
34
 
59
- // Flush when enough data accumulated
60
- if (this.bytesAccumulated >= this.FLUSH_BYTES) {
35
+ if (this.outputBuffer.length >= this.FLUSH_BYTES) {
61
36
  this.flush()
62
37
  }
63
38
  }
@@ -75,45 +50,17 @@ export class ConversationBridge {
75
50
  }
76
51
  }
77
52
 
78
- resize(cols: number, rows: number): void {
79
- this.terminal.resize(cols, rows)
53
+ resize(_cols: number, _rows: number): void {
54
+ // no-op now (was used for xterm)
80
55
  }
81
56
 
82
- /**
83
- * Read new content from the terminal buffer since the last flush.
84
- * Only flushes when all pending writes have completed.
85
- */
86
57
  private flush(): void {
87
- const buf = this.terminal.buffer.active
88
- // Read all lines: scrollback (baseY) + visible area (rows)
89
- const totalLines = buf.baseY + this.terminal.rows
90
-
91
- const allLines: string[] = []
92
- for (let i = 0; i < totalLines; i++) {
93
- const line = buf.getLine(i)
94
- if (line) {
95
- allLines.push(line.translateToString(true))
96
- }
97
- }
98
-
99
- const currentSnapshot = allLines.join('\n')
100
-
101
- // Diff: find new content since last snapshot
102
- let newContent: string
103
- if (currentSnapshot.startsWith(this.lastSnapshot)) {
104
- // Common case: new content appended
105
- newContent = currentSnapshot.slice(this.lastSnapshot.length)
106
- } else {
107
- // Screen was cleared/rewritten — send full current state
108
- newContent = currentSnapshot
109
- }
110
-
111
- this.lastSnapshot = currentSnapshot
58
+ if (this.outputBuffer.length === 0) return
112
59
 
113
- const cleaned = newContent.replace(/\n{3,}/g, '\n\n').trim()
114
-
115
- this.bytesAccumulated = 0
60
+ const raw = this.outputBuffer
61
+ this.outputBuffer = ''
116
62
 
63
+ const cleaned = cleanOutput(raw)
117
64
  if (cleaned.length === 0) return
118
65
 
119
66
  this.sendEntry('terminal_output', cleaned)
@@ -131,16 +78,11 @@ export class ConversationBridge {
131
78
  endSession(): void {
132
79
  if (!this.sessionActive) return
133
80
 
134
- // Give xterm a moment to process any remaining writes
135
81
  this.flush()
136
82
 
137
- if (this.flushTimer) {
138
- clearInterval(this.flushTimer)
139
- this.flushTimer = null
140
- }
141
-
142
83
  this.sessionActive = false
143
84
  this.inputBuffer = ''
85
+ this.outputBuffer = ''
144
86
  console.log('[bridge] Session ended')
145
87
  }
146
88
  }
@@ -150,9 +92,15 @@ export class ConversationBridge {
150
92
  */
151
93
  function stripAnsi(str: string): string {
152
94
  return str
95
+ // CSI sequences (e.g. \x1b[0m, \x1b[?2004h, \x1b[1;32m, \x1b[38;2;r;g;bm)
153
96
  .replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, '')
97
+ // OSC sequences (e.g. \x1b]0;title\x07, \x1b]...\x1b\\)
154
98
  .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
155
- .replace(/\x1b[^[\]](.|$)/g, '')
99
+ // DCS/PM/APC sequences
100
+ .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
101
+ // Other escape sequences (charset, keypad mode, etc.)
102
+ .replace(/\x1b[^[\]PX^_](.|$)/g, '')
103
+ // Remaining single ESC
156
104
  .replace(/\x1b/g, '')
157
105
  }
158
106
 
@@ -181,3 +129,20 @@ function cleanInput(str: string): string {
181
129
  cleaned = cleaned.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
182
130
  return cleaned.trim()
183
131
  }
132
+
133
+ /**
134
+ * Clean terminal output: strip ANSI, remove control chars, collapse noise.
135
+ */
136
+ function cleanOutput(str: string): string {
137
+ let cleaned = stripAnsi(str)
138
+ // Remove carriage returns
139
+ cleaned = cleaned.replace(/\r/g, '')
140
+ // Remove control characters except newline/tab
141
+ // eslint-disable-next-line no-control-regex
142
+ cleaned = cleaned.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
143
+ // Collapse 3+ consecutive newlines into 2
144
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n')
145
+ // Remove lines that are only whitespace
146
+ cleaned = cleaned.split('\n').filter(line => line.trim().length > 0).join('\n')
147
+ return cleaned.trim()
148
+ }