glad-web 1.0.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.
@@ -0,0 +1,241 @@
1
+ const pty = require('node-pty');
2
+ const os = require('os');
3
+ const crypto = require('crypto');
4
+ const logger = require('../utils/logger');
5
+
6
+ function previewText(text, maxChars = 500) {
7
+ const normalized = String(text || '')
8
+ .replace(/\r/g, '\\r')
9
+ .replace(/\n/g, '\\n')
10
+ .replace(/\t/g, '\\t')
11
+ .replace(/\x1b/g, '\\x1b');
12
+ return normalized.length > maxChars ? normalized.slice(0, maxChars) + '...' : normalized;
13
+ }
14
+
15
+ class PTYManager {
16
+ constructor(tool, workingDir, buffer, options = {}) {
17
+ this.tool = tool;
18
+ this.workingDir = workingDir;
19
+ this.buffer = buffer;
20
+ this.silent = options.silent || false;
21
+ this.ptyProcess = null;
22
+ this.onDataCallback = null;
23
+ this.onExitCallback = null;
24
+ this.localResizeListener = null;
25
+
26
+ this.tuiTools = ['opencode', 'kilo'];
27
+ this.isTUIMode = this.tuiTools.includes(tool.key);
28
+
29
+ this.recentOutputs = [];
30
+ this.duplicateThresholdMs = 150;
31
+ this.maxRecentOutputs = 10;
32
+ this.debugOutputCount = 0;
33
+ }
34
+
35
+ // Start PTY process
36
+ start(additionalArgs = []) {
37
+ const isWindows = os.platform() === 'win32';
38
+ const args = [...this.tool.args, ...additionalArgs];
39
+
40
+ logger.info(`Starting ${this.tool.displayName}...`);
41
+
42
+ try {
43
+ if (!this.silent) {
44
+ process.stdout.write('\x1b[?2004l');
45
+ }
46
+
47
+ let spawnCommand = this.tool.command;
48
+ let spawnArgs = args;
49
+
50
+ if (isWindows) {
51
+ spawnCommand = 'cmd.exe';
52
+ spawnArgs = ['/c', this.tool.command, ...args];
53
+ } else {
54
+ spawnCommand = 'bash';
55
+ const escaped = [this.tool.command, ...args]
56
+ .map(a => `'${String(a).replace(/'/g, "'\\''")}'`)
57
+ .join(' ');
58
+ spawnArgs = ['-i', '-c', escaped];
59
+ }
60
+
61
+ let spawnEnv = {
62
+ ...process.env,
63
+ TERM: 'xterm-256color',
64
+ COLORTERM: 'truecolor',
65
+ FORCE_COLOR: '1',
66
+ CLICOLOR: '1'
67
+ };
68
+ delete spawnEnv.NO_COLOR;
69
+ delete spawnEnv.STY;
70
+ delete spawnEnv.WINDOW;
71
+ delete spawnEnv.TERMCAP;
72
+
73
+ if (isWindows) {
74
+ const path = require('path');
75
+ const npmGlobalBin = path.join(process.env.APPDATA || '', 'npm');
76
+ const currentPath = process.env.PATH || '';
77
+
78
+ spawnEnv = {
79
+ ...spawnEnv,
80
+ PATH: `${npmGlobalBin};${currentPath}`
81
+ };
82
+ }
83
+
84
+ logger.debugInfo(`[pty-start] tool=${this.tool.key}; displayName=${this.tool.displayName}; command=${spawnCommand}; args=${JSON.stringify(spawnArgs)}; cwd=${this.workingDir}`);
85
+ logger.debugInfo(`[pty-start] PATH=${spawnEnv.PATH || ''}; SHELL=${spawnEnv.SHELL || ''}; TERM=${spawnEnv.TERM || ''}; HOME=${spawnEnv.HOME || ''}`);
86
+
87
+ this.ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
88
+ name: 'xterm-256color',
89
+ cols: this.silent ? 80 : (process.stdout.columns || 80),
90
+ rows: this.silent ? 24 : (process.stdout.rows || 24),
91
+ cwd: this.workingDir,
92
+ env: spawnEnv
93
+ });
94
+
95
+ logger.success(`${this.tool.displayName} started (PID: ${this.ptyProcess.pid})`);
96
+
97
+ this.ptyProcess.onData((data) => {
98
+ this.handlePTYOutput(data);
99
+ });
100
+
101
+ this.ptyProcess.onExit((exitCode) => {
102
+ this.handlePTYExit(exitCode);
103
+ });
104
+
105
+ if (!this.silent && process.stdin.isTTY) {
106
+ process.stdin.setRawMode(true);
107
+ process.stdin.setEncoding('utf8');
108
+ process.stdin.resume();
109
+
110
+ process.stdin.on('data', (data) => {
111
+ const filtered = data.toString()
112
+ .replace(/\x1b\[I/g, '')
113
+ .replace(/\x1b\[O/g, '');
114
+
115
+ if (filtered.length > 0) {
116
+ this.ptyProcess.write(filtered);
117
+ }
118
+
119
+ if (data === '\u0003') {
120
+ this.kill();
121
+ process.exit(0);
122
+ }
123
+ });
124
+ }
125
+
126
+ if (!this.silent) {
127
+ this.setupLocalResizeListener();
128
+ }
129
+
130
+ return true;
131
+ } catch (err) {
132
+ logger.error(`Failed to start ${this.tool.displayName}: ${err.message}`);
133
+ return false;
134
+ }
135
+ }
136
+
137
+ // Handle PTY output
138
+ handlePTYOutput(data) {
139
+ let filtered = data
140
+ .replace(/\x1b\[I/g, '')
141
+ .replace(/\x1b\[O/g, '');
142
+
143
+ if (filtered.length > 0) {
144
+ if (this.debugOutputCount < 12) {
145
+ this.debugOutputCount += 1;
146
+ logger.debugInfo(`[pty-output] tool=${this.tool.key}; chunk=${this.debugOutputCount}; bytes=${Buffer.byteLength(filtered, 'utf8')}; preview="${previewText(filtered)}"`);
147
+ }
148
+
149
+ if (os.platform() === 'win32') {
150
+ const now = Date.now();
151
+ const hash = crypto.createHash('sha256').update(filtered).digest('hex');
152
+ this.recentOutputs = this.recentOutputs.filter(entry => now - entry.timestamp < this.duplicateThresholdMs);
153
+ const duplicate = this.recentOutputs.find(entry => entry.hash === hash);
154
+ if (duplicate) return;
155
+ this.recentOutputs.push({ hash, timestamp: now });
156
+ if (this.recentOutputs.length > this.maxRecentOutputs) this.recentOutputs.shift();
157
+ }
158
+
159
+ if (!this.silent) {
160
+ process.stdout.write(filtered);
161
+ }
162
+
163
+ let forMobile = filtered;
164
+ if (os.platform() === 'win32' && forMobile.startsWith('\x1b[H\x1b[K')) {
165
+ forMobile = '\x1b[2J\x1b[3J\x1b[H' + forMobile.slice(6);
166
+ }
167
+
168
+ if (!this.isTUIMode) {
169
+ this.buffer.append(forMobile);
170
+ }
171
+
172
+ if (this.onDataCallback) {
173
+ this.onDataCallback(forMobile);
174
+ }
175
+ }
176
+ }
177
+
178
+ handlePTYExit(exitCode) {
179
+ logger.info(`${this.tool.displayName} exited with code ${exitCode.exitCode}`);
180
+ logger.debugInfo(`[pty-exit] tool=${this.tool.key}; exitCode=${exitCode.exitCode}; signal=${exitCode.signal || ''}`);
181
+ if (this.onExitCallback) {
182
+ this.onExitCallback(exitCode);
183
+ }
184
+ }
185
+
186
+ write(data) {
187
+ if (this.ptyProcess) {
188
+ this.ptyProcess.write(data);
189
+ return true;
190
+ }
191
+ return false;
192
+ }
193
+
194
+ resize(cols, rows) {
195
+ if (this.ptyProcess) {
196
+ this.ptyProcess.resize(cols, rows);
197
+ }
198
+ }
199
+
200
+ setupLocalResizeListener() {
201
+ if (!process.stdout.isTTY) return;
202
+ this.localResizeListener = () => {
203
+ const cols = process.stdout.columns || 80;
204
+ const rows = process.stdout.rows || 24;
205
+ this.resize(cols, rows);
206
+ };
207
+ process.stdout.on('resize', this.localResizeListener);
208
+ }
209
+
210
+ onData(callback) {
211
+ this.onDataCallback = callback;
212
+ }
213
+
214
+ onExit(callback) {
215
+ this.onExitCallback = callback;
216
+ }
217
+
218
+ kill() {
219
+ if (this.ptyProcess) {
220
+ this.ptyProcess.kill();
221
+ this.ptyProcess = null;
222
+ }
223
+ if (this.localResizeListener && process.stdout.off) {
224
+ process.stdout.off('resize', this.localResizeListener);
225
+ this.localResizeListener = null;
226
+ }
227
+ if (!this.silent && process.stdin.isTTY && process.stdin.setRawMode) {
228
+ process.stdin.setRawMode(false);
229
+ }
230
+ }
231
+
232
+ isRunning() {
233
+ return this.ptyProcess !== null;
234
+ }
235
+
236
+ getPid() {
237
+ return this.ptyProcess ? this.ptyProcess.pid : null;
238
+ }
239
+ }
240
+
241
+ module.exports = PTYManager;
@@ -0,0 +1,225 @@
1
+ let TerminalCtor = null;
2
+
3
+ function getTerminalCtor() {
4
+ if (TerminalCtor) return TerminalCtor;
5
+ if (typeof global.window === 'undefined') {
6
+ global.window = {};
7
+ }
8
+ ({ Terminal: TerminalCtor } = require('xterm-headless'));
9
+ return TerminalCtor;
10
+ }
11
+
12
+ class RenderedHistory {
13
+ constructor(options = {}) {
14
+ const Terminal = getTerminalCtor();
15
+
16
+ this.maxBytes = options.maxBytes || 5 * 1024 * 1024;
17
+ this.debugLabel = options.debugLabel || 'session';
18
+ this.cols = Math.max(2, options.cols || 80);
19
+ this.rows = Math.max(1, options.rows || 24);
20
+ this.minCols = Math.max(2, options.minCols || 20);
21
+ this.minRows = Math.max(1, options.minRows || 8);
22
+ this.updatedAt = Date.now();
23
+ this.totalWrites = 0;
24
+ this.totalBytes = 0;
25
+ this.pendingWrites = 0;
26
+ this.resizeEvents = 0;
27
+ this.truncated = false;
28
+ this.lastEvents = [];
29
+ this.archivedLines = [];
30
+ this.archivedBytes = 0;
31
+ this.term = new Terminal({
32
+ cols: this.cols,
33
+ rows: this.rows,
34
+ scrollback: Math.max(20000, options.scrollback || 20000),
35
+ allowProposedApi: true
36
+ });
37
+ this.hookBufferTrim();
38
+ }
39
+
40
+ write(data) {
41
+ if (!this.term) return;
42
+ if (!data) return;
43
+ const text = String(data);
44
+ this.totalWrites += 1;
45
+ this.totalBytes += Buffer.byteLength(text, 'utf8');
46
+ this.pendingWrites += 1;
47
+ this.updatedAt = Date.now();
48
+
49
+ this.term.write(text, () => {
50
+ this.pendingWrites = Math.max(0, this.pendingWrites - 1);
51
+ this.updatedAt = Date.now();
52
+ });
53
+ }
54
+
55
+ resize(cols, rows) {
56
+ if (!this.term) return;
57
+ const rawCols = Number.parseInt(cols, 10);
58
+ const rawRows = Number.parseInt(rows, 10);
59
+ const nextCols = Math.max(2, rawCols || this.cols);
60
+ const nextRows = Math.max(1, rawRows || this.rows);
61
+
62
+ // Hidden or unstable layouts can briefly report pathological sizes like 10x6.
63
+ // Ignore those for rendered history so we don't reflow the whole buffer into noise.
64
+ if (nextCols < this.minCols || nextRows < this.minRows) {
65
+ this.recordEvent(`resize ignored ${nextCols}x${nextRows}`);
66
+ return;
67
+ }
68
+
69
+ if (nextCols === this.cols && nextRows === this.rows) return;
70
+
71
+ this.cols = nextCols;
72
+ this.rows = nextRows;
73
+ this.resizeEvents += 1;
74
+ this.updatedAt = Date.now();
75
+ this.recordEvent(`resize ${this.cols}x${this.rows}`);
76
+ this.term.resize(this.cols, this.rows);
77
+ }
78
+
79
+ toJSON() {
80
+ const snapshot = this.buildSnapshot();
81
+ return {
82
+ text: snapshot.text,
83
+ updatedAt: this.updatedAt,
84
+ truncated: snapshot.truncated,
85
+ bytes: snapshot.bytes,
86
+ lines: snapshot.lines.length
87
+ };
88
+ }
89
+
90
+ getDebugSnapshot(options = {}) {
91
+ const tailLines = options.tailLines || 12;
92
+ const snapshot = this.buildSnapshot();
93
+ const active = this.term.buffer.active;
94
+ return {
95
+ label: this.debugLabel,
96
+ updatedAt: this.updatedAt,
97
+ truncated: snapshot.truncated,
98
+ cols: this.cols,
99
+ rows: this.rows,
100
+ totalWrites: this.totalWrites,
101
+ totalBytes: this.totalBytes,
102
+ pendingWrites: this.pendingWrites,
103
+ resizeEvents: this.resizeEvents,
104
+ bufferLines: active.length,
105
+ baseY: active.baseY,
106
+ cursorY: active.cursorY,
107
+ cursorX: active.cursorX,
108
+ lastEvents: [...this.lastEvents],
109
+ tailPreview: this.previewText(snapshot.lines.slice(-tailLines).join('\n'))
110
+ };
111
+ }
112
+
113
+ dispose() {
114
+ if (this.term) {
115
+ this.term.dispose();
116
+ this.term = null;
117
+ }
118
+ }
119
+
120
+ hookBufferTrim() {
121
+ const lines = this.term && this.term._core && this.term._core.buffer && this.term._core.buffer.lines;
122
+ if (!lines || typeof lines.trimStart !== 'function') return;
123
+
124
+ const originalPush = lines.push.bind(lines);
125
+ lines.push = (value) => {
126
+ if (lines.isFull) {
127
+ const line = lines.get(0);
128
+ this.archiveLines([line ? line.translateToString(true) : '']);
129
+ }
130
+ return originalPush(value);
131
+ };
132
+
133
+ if (typeof lines.recycle === 'function') {
134
+ const originalRecycle = lines.recycle.bind(lines);
135
+ lines.recycle = () => {
136
+ if (lines.isFull) {
137
+ const line = lines.get(0);
138
+ this.archiveLines([line ? line.translateToString(true) : '']);
139
+ }
140
+ return originalRecycle();
141
+ };
142
+ }
143
+
144
+ const originalTrimStart = lines.trimStart.bind(lines);
145
+ lines.trimStart = (amount) => {
146
+ const removed = [];
147
+ for (let i = 0; i < amount; i++) {
148
+ const line = lines.get(i);
149
+ removed.push(line ? line.translateToString(true) : '');
150
+ }
151
+ this.archiveLines(removed);
152
+ return originalTrimStart(amount);
153
+ };
154
+ }
155
+
156
+ archiveLines(lines) {
157
+ if (!lines || lines.length === 0) return;
158
+ for (const line of lines) {
159
+ this.archivedLines.push(line);
160
+ this.archivedBytes += Buffer.byteLength(line, 'utf8') + 1;
161
+ }
162
+ this.trimArchivedBytes();
163
+ }
164
+
165
+ buildSnapshot() {
166
+ const active = this.term.buffer.active;
167
+ let lines = [...this.archivedLines];
168
+
169
+ for (let i = 0; i < active.length; i++) {
170
+ const line = active.getLine(i);
171
+ lines.push(line ? line.translateToString(true) : '');
172
+ }
173
+
174
+ let truncated = false;
175
+
176
+ let bytes = Buffer.byteLength(lines.join('\n'), 'utf8');
177
+ while (bytes > this.maxBytes && lines.length > 1) {
178
+ const removed = lines.shift();
179
+ bytes -= Buffer.byteLength(removed, 'utf8') + 1;
180
+ truncated = true;
181
+ }
182
+
183
+ if (bytes > this.maxBytes && lines.length === 1) {
184
+ const line = lines[0];
185
+ const keepChars = Math.max(1, Math.floor(this.maxBytes / 2));
186
+ lines[0] = line.slice(-keepChars);
187
+ bytes = Buffer.byteLength(lines[0], 'utf8');
188
+ truncated = true;
189
+ }
190
+
191
+ const text = lines.join('\n').replace(/\s+$/g, '');
192
+ this.truncated = truncated;
193
+ return {
194
+ lines,
195
+ text,
196
+ bytes: Buffer.byteLength(text, 'utf8'),
197
+ truncated
198
+ };
199
+ }
200
+
201
+ recordEvent(message) {
202
+ this.lastEvents.push(`${new Date().toISOString()} ${message}`);
203
+ if (this.lastEvents.length > 25) this.lastEvents.shift();
204
+ }
205
+
206
+ trimArchivedBytes() {
207
+ while (this.archivedBytes > this.maxBytes && this.archivedLines.length > 0) {
208
+ const removed = this.archivedLines.shift();
209
+ this.archivedBytes -= Buffer.byteLength(removed, 'utf8') + 1;
210
+ this.truncated = true;
211
+ }
212
+ }
213
+
214
+ previewText(text, maxChars = 400) {
215
+ if (!text) return '';
216
+ const normalized = String(text)
217
+ .replace(/\r/g, '\\r')
218
+ .replace(/\n/g, '\\n')
219
+ .replace(/\t/g, '\\t')
220
+ .replace(/\x1b/g, '\\x1b');
221
+ return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
222
+ }
223
+ }
224
+
225
+ module.exports = RenderedHistory;