claudescreenfix-hardwicksoftware 1.0.0 → 2.0.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/README.md +16 -0
- package/bin/claude-fixed.js +5 -4
- package/glitch-detector.cjs +351 -0
- package/index.cjs +217 -18
- package/loader.cjs +1 -1
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -117,6 +117,22 @@ for resize events, we intercept `process.on('SIGWINCH', ...)` and debounce the h
|
|
|
117
117
|
|
|
118
118
|
bottom line: smooth terminal, no lag, no memory bloat. it just works.
|
|
119
119
|
|
|
120
|
+
## changelog
|
|
121
|
+
|
|
122
|
+
### v1.0.1 (2025-01-08)
|
|
123
|
+
- **FIXED: typing issue** - keystrokes were getting lost because the fix was intercepting stdin echoes
|
|
124
|
+
- now detects stdin echo writes (single chars, backspace, arrow keys) and passes them through unmodified
|
|
125
|
+
- added typing cooldown detection - clears are deferred during active typing
|
|
126
|
+
- periodic clears now use `setImmediate` to not block the event loop
|
|
127
|
+
- added stdin tracking to properly detect user input activity
|
|
128
|
+
- new config option: `typingCooldownMs` (default 500ms) - how long to wait after typing before allowing clears
|
|
129
|
+
|
|
130
|
+
### v1.0.0 (2025-01-08)
|
|
131
|
+
- initial release
|
|
132
|
+
- scrollback clearing after 500 renders or 60 seconds
|
|
133
|
+
- SIGWINCH debouncing for tmux/screen users
|
|
134
|
+
- enhanced /clear command to actually clear scrollback
|
|
135
|
+
|
|
120
136
|
## known issues
|
|
121
137
|
|
|
122
138
|
- some old terminals don't support `\x1b[3J` but that's pretty rare nowadays
|
package/bin/claude-fixed.js
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* wrapper script - runs claude with the terminal fix loaded
|
|
6
6
|
*
|
|
7
7
|
* finds your claude binary and runs it with our fix injected
|
|
8
|
-
*
|
|
8
|
+
* you don't need any manual setup, just run claude-fixed instead of claude
|
|
9
|
+
* it'll handle the rest
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const { spawn, execSync } = require('child_process');
|
|
12
13
|
const path = require('path');
|
|
13
14
|
const fs = require('fs');
|
|
14
15
|
|
|
15
|
-
// find the loader path
|
|
16
|
+
// find the loader path - it's in the parent dir
|
|
16
17
|
const loaderPath = path.join(__dirname, '..', 'loader.cjs');
|
|
17
18
|
|
|
18
19
|
if (!fs.existsSync(loaderPath)) {
|
|
@@ -25,11 +26,11 @@ let claudeBin;
|
|
|
25
26
|
try {
|
|
26
27
|
claudeBin = execSync('which claude', { encoding: 'utf8' }).trim();
|
|
27
28
|
} catch (e) {
|
|
28
|
-
console.error('claude not found in PATH - make sure
|
|
29
|
+
console.error('claude not found in PATH - make sure it\'s installed');
|
|
29
30
|
process.exit(1);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
// run claude with our fix loaded via NODE_OPTIONS
|
|
33
|
+
// run claude with our fix loaded via NODE_OPTIONS - it's the cleanest way
|
|
33
34
|
const env = Object.assign({}, process.env, {
|
|
34
35
|
NODE_OPTIONS: '--require ' + loaderPath + ' ' + (process.env.NODE_OPTIONS || '')
|
|
35
36
|
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* glitch detector - catches when the terminal's cooked
|
|
5
|
+
*
|
|
6
|
+
* watches for these signals:
|
|
7
|
+
* - stdin goes quiet while stdout's still busy (input blocked)
|
|
8
|
+
* - too many resize events too fast (sigwinch spam)
|
|
9
|
+
* - render rate going crazy (ink thrashing)
|
|
10
|
+
*
|
|
11
|
+
* when 2+ signals fire we know shit's broken and try to recover
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const EventEmitter = require('events');
|
|
15
|
+
const { execSync, spawn } = require('child_process');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
|
|
19
|
+
// Default configuration
|
|
20
|
+
const DEFAULT_CONFIG = {
|
|
21
|
+
stdinTimeoutMs: 2000, // stdin silence threshold
|
|
22
|
+
sigwinchThresholdMs: 10, // minimum ms between resize events
|
|
23
|
+
sigwinchStormCount: 5, // resize events/sec to trigger storm alert
|
|
24
|
+
renderRateLimit: 500, // max renders per minute before alert
|
|
25
|
+
lineLimitMax: 120, // max terminal lines before trim
|
|
26
|
+
checkIntervalMs: 500, // how often to check for glitch state
|
|
27
|
+
recoveryDelayMs: 1000, // delay before recovery actions
|
|
28
|
+
debug: process.env.CLAUDE_GLITCH_DETECTOR_DEBUG === '1'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class GlitchDetector extends EventEmitter {
|
|
32
|
+
constructor(config = {}) {
|
|
33
|
+
super();
|
|
34
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
35
|
+
|
|
36
|
+
// State tracking
|
|
37
|
+
this.lastStdinTime = Date.now();
|
|
38
|
+
this.lastStdoutTime = 0;
|
|
39
|
+
this.lastSigwinchTime = 0;
|
|
40
|
+
this.sigwinchCount = 0;
|
|
41
|
+
this.renderTimes = [];
|
|
42
|
+
this.isGlitched = false;
|
|
43
|
+
this.glitchStartTime = null;
|
|
44
|
+
|
|
45
|
+
// Metrics
|
|
46
|
+
this.metrics = {
|
|
47
|
+
glitchesDetected: 0,
|
|
48
|
+
recoveriesAttempted: 0,
|
|
49
|
+
stdinSilenceEvents: 0,
|
|
50
|
+
sigwinchStorms: 0,
|
|
51
|
+
renderSpikes: 0
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Check interval handle
|
|
55
|
+
this.checkInterval = null;
|
|
56
|
+
this.installed = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
log(...args) {
|
|
60
|
+
if (this.config.debug) {
|
|
61
|
+
process.stderr.write('[glitch-detector] ' + args.join(' ') + '\n');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Install the glitch detector - it hooks into process events
|
|
67
|
+
*/
|
|
68
|
+
install() {
|
|
69
|
+
if (this.installed) return;
|
|
70
|
+
|
|
71
|
+
// Track stdin activity
|
|
72
|
+
if (process.stdin.isTTY) {
|
|
73
|
+
process.stdin.on('data', () => {
|
|
74
|
+
this.lastStdinTime = Date.now();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Track SIGWINCH events
|
|
79
|
+
const originalOn = process.on.bind(process);
|
|
80
|
+
process.on = (event, handler) => {
|
|
81
|
+
if (event === 'SIGWINCH') {
|
|
82
|
+
return originalOn(event, (...args) => {
|
|
83
|
+
this.onSigwinch();
|
|
84
|
+
handler(...args);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return originalOn(event, handler);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Start periodic glitch check
|
|
91
|
+
this.checkInterval = setInterval(() => {
|
|
92
|
+
this.checkGlitchState();
|
|
93
|
+
}, this.config.checkIntervalMs);
|
|
94
|
+
|
|
95
|
+
this.installed = true;
|
|
96
|
+
this.log('installed successfully');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Track stdout write for activity monitoring
|
|
101
|
+
* Call this from the stdout.write hook in index.cjs, it won't slow anything down
|
|
102
|
+
*/
|
|
103
|
+
trackStdout() {
|
|
104
|
+
this.lastStdoutTime = Date.now();
|
|
105
|
+
|
|
106
|
+
// Track render times for rate limiting
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
this.renderTimes.push(now);
|
|
109
|
+
|
|
110
|
+
// Keep only last minute of renders
|
|
111
|
+
this.renderTimes = this.renderTimes.filter(t => now - t < 60000);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handle SIGWINCH (resize) events
|
|
116
|
+
*/
|
|
117
|
+
onSigwinch() {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const interval = now - this.lastSigwinchTime;
|
|
120
|
+
this.lastSigwinchTime = now;
|
|
121
|
+
|
|
122
|
+
// Detect resize storm
|
|
123
|
+
if (interval < this.config.sigwinchThresholdMs) {
|
|
124
|
+
this.sigwinchCount++;
|
|
125
|
+
this.log('rapid SIGWINCH detected, interval:', interval, 'ms');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Decay counter over time
|
|
129
|
+
setTimeout(() => {
|
|
130
|
+
if (this.sigwinchCount > 0) this.sigwinchCount--;
|
|
131
|
+
}, 1000);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* SIGNAL 1: Check for stdin silence during output activity
|
|
136
|
+
* This is the main glitch signal - if stdin's dead but stdout's busy, we're cooked
|
|
137
|
+
*/
|
|
138
|
+
checkStdinSilence() {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const stdinSilence = now - this.lastStdinTime;
|
|
141
|
+
const outputActive = (now - this.lastStdoutTime) < 5000; // output in last 5s
|
|
142
|
+
|
|
143
|
+
// stdin's been quiet for 2+ sec while output's still going = we're glitched
|
|
144
|
+
if (stdinSilence > this.config.stdinTimeoutMs && outputActive) {
|
|
145
|
+
this.log('stdin silence detected:', stdinSilence, 'ms');
|
|
146
|
+
this.metrics.stdinSilenceEvents++;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* SIGNAL 2: Check for SIGWINCH storm
|
|
154
|
+
*/
|
|
155
|
+
checkSigwinchStorm() {
|
|
156
|
+
const isStorm = this.sigwinchCount >= this.config.sigwinchStormCount;
|
|
157
|
+
if (isStorm) {
|
|
158
|
+
this.log('SIGWINCH storm detected, count:', this.sigwinchCount);
|
|
159
|
+
this.metrics.sigwinchStorms++;
|
|
160
|
+
}
|
|
161
|
+
return isStorm;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* SIGNAL 3: Check for render rate spike
|
|
166
|
+
*/
|
|
167
|
+
checkRenderSpike() {
|
|
168
|
+
const rendersPerMinute = this.renderTimes.length;
|
|
169
|
+
const isSpike = rendersPerMinute > this.config.renderRateLimit;
|
|
170
|
+
if (isSpike) {
|
|
171
|
+
this.log('render spike detected:', rendersPerMinute, '/min');
|
|
172
|
+
this.metrics.renderSpikes++;
|
|
173
|
+
}
|
|
174
|
+
return isSpike;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Main glitch detection - it combines all signals
|
|
179
|
+
* Uses 2-of-3 voting so we don't get false positives
|
|
180
|
+
*/
|
|
181
|
+
checkGlitchState() {
|
|
182
|
+
const stdinBlocked = this.checkStdinSilence();
|
|
183
|
+
const sigwinchStorm = this.checkSigwinchStorm();
|
|
184
|
+
const renderSpike = this.checkRenderSpike();
|
|
185
|
+
|
|
186
|
+
const signals = [stdinBlocked, sigwinchStorm, renderSpike];
|
|
187
|
+
const activeSignals = signals.filter(Boolean).length;
|
|
188
|
+
|
|
189
|
+
// 2 of 3 signals = we're definitely glitched
|
|
190
|
+
// OR stdin blocked alone (that's the most reliable one)
|
|
191
|
+
const glitched = activeSignals >= 2 || stdinBlocked;
|
|
192
|
+
|
|
193
|
+
if (glitched && !this.isGlitched) {
|
|
194
|
+
this.isGlitched = true;
|
|
195
|
+
this.glitchStartTime = Date.now();
|
|
196
|
+
this.metrics.glitchesDetected++;
|
|
197
|
+
|
|
198
|
+
this.log('GLITCH DETECTED!', {
|
|
199
|
+
stdinBlocked,
|
|
200
|
+
sigwinchStorm,
|
|
201
|
+
renderSpike
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
this.emit('glitch-detected', {
|
|
205
|
+
timestamp: Date.now(),
|
|
206
|
+
signals: { stdinBlocked, sigwinchStorm, renderSpike },
|
|
207
|
+
metrics: { ...this.metrics }
|
|
208
|
+
});
|
|
209
|
+
} else if (!glitched && this.isGlitched) {
|
|
210
|
+
const duration = Date.now() - this.glitchStartTime;
|
|
211
|
+
this.log('glitch resolved after', duration, 'ms');
|
|
212
|
+
this.isGlitched = false;
|
|
213
|
+
this.glitchStartTime = null;
|
|
214
|
+
|
|
215
|
+
this.emit('glitch-resolved', { duration });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return glitched;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check if we're currently in glitched state
|
|
223
|
+
*/
|
|
224
|
+
isInGlitchState() {
|
|
225
|
+
return this.isGlitched;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get glitch duration in ms (0 if we aren't glitched)
|
|
230
|
+
*/
|
|
231
|
+
getGlitchDuration() {
|
|
232
|
+
if (!this.isGlitched || !this.glitchStartTime) return 0;
|
|
233
|
+
return Date.now() - this.glitchStartTime;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Force recovery attempt - it'll try screen commands and scrollback clears
|
|
238
|
+
*/
|
|
239
|
+
async attemptRecovery() {
|
|
240
|
+
if (!this.isGlitched) return false;
|
|
241
|
+
|
|
242
|
+
this.log('attempting recovery...');
|
|
243
|
+
this.metrics.recoveriesAttempted++;
|
|
244
|
+
|
|
245
|
+
this.emit('recovery-started');
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// Method 1: Send Enter via screen (if we've got a session)
|
|
249
|
+
const screenSession = process.env.STY || process.env.SPECMEM_SCREEN_SESSION;
|
|
250
|
+
if (screenSession) {
|
|
251
|
+
this.log('sending Enter via screen session:', screenSession);
|
|
252
|
+
execSync(`screen -S "${screenSession}" -X stuff $'\\r'`, {
|
|
253
|
+
stdio: 'ignore',
|
|
254
|
+
timeout: 5000
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await this.sleep(this.config.recoveryDelayMs);
|
|
258
|
+
|
|
259
|
+
// Check if recovered
|
|
260
|
+
if (!this.checkGlitchState()) {
|
|
261
|
+
this.log('recovery successful via screen');
|
|
262
|
+
this.emit('recovery-success', { method: 'screen' });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Method 2: Force scrollback clear
|
|
268
|
+
this.log('forcing scrollback clear');
|
|
269
|
+
if (process.stdout.isTTY) {
|
|
270
|
+
process.stdout.write('\x1b[3J'); // CLEAR_SCROLLBACK
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await this.sleep(this.config.recoveryDelayMs);
|
|
274
|
+
|
|
275
|
+
if (!this.checkGlitchState()) {
|
|
276
|
+
this.log('recovery successful via scrollback clear');
|
|
277
|
+
this.emit('recovery-success', { method: 'scrollback-clear' });
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.log('recovery failed');
|
|
282
|
+
this.emit('recovery-failed');
|
|
283
|
+
return false;
|
|
284
|
+
|
|
285
|
+
} catch (err) {
|
|
286
|
+
this.log('recovery error:', err.message);
|
|
287
|
+
this.emit('recovery-error', { error: err });
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
sleep(ms) {
|
|
293
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Reset all state (call after recovery)
|
|
298
|
+
*/
|
|
299
|
+
reset() {
|
|
300
|
+
this.lastStdinTime = Date.now();
|
|
301
|
+
this.lastStdoutTime = Date.now();
|
|
302
|
+
this.sigwinchCount = 0;
|
|
303
|
+
this.renderTimes = [];
|
|
304
|
+
this.isGlitched = false;
|
|
305
|
+
this.glitchStartTime = null;
|
|
306
|
+
this.log('state reset');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get current metrics
|
|
311
|
+
*/
|
|
312
|
+
getMetrics() {
|
|
313
|
+
return {
|
|
314
|
+
...this.metrics,
|
|
315
|
+
isGlitched: this.isGlitched,
|
|
316
|
+
glitchDuration: this.getGlitchDuration(),
|
|
317
|
+
renderRate: this.renderTimes.length,
|
|
318
|
+
sigwinchRate: this.sigwinchCount,
|
|
319
|
+
lastStdinAgo: Date.now() - this.lastStdinTime,
|
|
320
|
+
lastStdoutAgo: Date.now() - this.lastStdoutTime
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Disable the detector
|
|
326
|
+
*/
|
|
327
|
+
disable() {
|
|
328
|
+
if (this.checkInterval) {
|
|
329
|
+
clearInterval(this.checkInterval);
|
|
330
|
+
this.checkInterval = null;
|
|
331
|
+
}
|
|
332
|
+
this.installed = false;
|
|
333
|
+
this.log('disabled');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Singleton instance
|
|
338
|
+
let instance = null;
|
|
339
|
+
|
|
340
|
+
function getDetector(config) {
|
|
341
|
+
if (!instance) {
|
|
342
|
+
instance = new GlitchDetector(config);
|
|
343
|
+
}
|
|
344
|
+
return instance;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
GlitchDetector,
|
|
349
|
+
getDetector,
|
|
350
|
+
DEFAULT_CONFIG
|
|
351
|
+
};
|
package/index.cjs
CHANGED
|
@@ -4,15 +4,24 @@
|
|
|
4
4
|
* claudescreenfix-hardwicksoftware - stops the scroll glitch from cooking your terminal
|
|
5
5
|
*
|
|
6
6
|
* the problem:
|
|
7
|
-
* claude code uses ink (react for terminals) and it
|
|
8
|
-
* so after like 30 min your terminal got thousands of lines in the buffer
|
|
7
|
+
* claude code uses ink (react for terminals) and it doesn't clear scrollback
|
|
8
|
+
* so after like 30 min your terminal's got thousands of lines in the buffer
|
|
9
9
|
* every re-render touches ALL of em - O(n) where n keeps growing
|
|
10
10
|
* resize events fire with no debounce so tmux/screen users get cooked
|
|
11
11
|
*
|
|
12
12
|
* what we do:
|
|
13
13
|
* - hook stdout.write to inject scrollback clears periodically
|
|
14
|
-
* - debounce SIGWINCH so resize
|
|
14
|
+
* - debounce SIGWINCH so resize ain't thrashing
|
|
15
15
|
* - enhance /clear to actually clear scrollback not just the screen
|
|
16
|
+
*
|
|
17
|
+
* v1.0.1: fixed the typing bug where keystrokes got eaten
|
|
18
|
+
* - stdin echoes now pass through untouched
|
|
19
|
+
* - clears happen async so typing isn't interrupted
|
|
20
|
+
*
|
|
21
|
+
* v2.0.0: added glitch detection + 120 line limit
|
|
22
|
+
* - actually detects when terminal's fucked instead of just clearing blindly
|
|
23
|
+
* - caps output at 120 lines so buffer won't explode
|
|
24
|
+
* - can force send enter key to break out of frozen state
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
27
|
const CLEAR_SCROLLBACK = '\x1b[3J';
|
|
@@ -21,11 +30,25 @@ const CURSOR_RESTORE = '\x1b[u';
|
|
|
21
30
|
const CLEAR_SCREEN = '\x1b[2J';
|
|
22
31
|
const HOME_CURSOR = '\x1b[H';
|
|
23
32
|
|
|
33
|
+
// Try to load glitch detector (optional dependency)
|
|
34
|
+
let GlitchDetector = null;
|
|
35
|
+
let glitchDetector = null;
|
|
36
|
+
try {
|
|
37
|
+
const detector = require('./glitch-detector.cjs');
|
|
38
|
+
GlitchDetector = detector.GlitchDetector;
|
|
39
|
+
glitchDetector = detector.getDetector();
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// Glitch detector not available, continue without it
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
// config - tweak these if needed
|
|
25
45
|
const config = {
|
|
26
46
|
resizeDebounceMs: 150, // how long to wait before firing resize
|
|
27
47
|
periodicClearMs: 60000, // clear scrollback every 60s
|
|
28
48
|
clearAfterRenders: 500, // or after 500 render cycles
|
|
49
|
+
typingCooldownMs: 500, // wait this long after typing to clear
|
|
50
|
+
maxLineCount: 120, // NEW: max terminal lines before forced trim
|
|
51
|
+
glitchRecoveryEnabled: true, // NEW: enable automatic glitch recovery
|
|
29
52
|
debug: process.env.CLAUDE_TERMINAL_FIX_DEBUG === '1',
|
|
30
53
|
disabled: process.env.CLAUDE_TERMINAL_FIX_DISABLED === '1'
|
|
31
54
|
};
|
|
@@ -36,6 +59,11 @@ let lastResizeTime = 0;
|
|
|
36
59
|
let resizeTimeout = null;
|
|
37
60
|
let originalWrite = null;
|
|
38
61
|
let installed = false;
|
|
62
|
+
let lastTypingTime = 0; // track when user last typed
|
|
63
|
+
let pendingClear = false; // defer clear if typing active
|
|
64
|
+
let clearIntervalId = null;
|
|
65
|
+
let lineCount = 0; // NEW: track output line count for 120-line limit
|
|
66
|
+
let glitchRecoveryInProgress = false; // NEW: prevent recovery loops
|
|
39
67
|
|
|
40
68
|
function log(...args) {
|
|
41
69
|
if (config.debug) {
|
|
@@ -43,9 +71,68 @@ function log(...args) {
|
|
|
43
71
|
}
|
|
44
72
|
}
|
|
45
73
|
|
|
74
|
+
/**
|
|
75
|
+
* check if user's actively typing (within cooldown window)
|
|
76
|
+
*/
|
|
77
|
+
function isTypingActive() {
|
|
78
|
+
return (Date.now() - lastTypingTime) < config.typingCooldownMs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* detect if this looks like a stdin echo (single printable char or short sequence)
|
|
83
|
+
* stdin echoes are typically: single chars, backspace seqs, arrow key echoes
|
|
84
|
+
* we don't wanna mess with these or typing gets wonky
|
|
85
|
+
*/
|
|
86
|
+
function isStdinEcho(chunk) {
|
|
87
|
+
// single printable character (including space)
|
|
88
|
+
if (chunk.length === 1 && chunk.charCodeAt(0) >= 32 && chunk.charCodeAt(0) <= 126) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
// backspace/delete echo (usually 1-3 chars with control codes)
|
|
92
|
+
if (chunk.length <= 4 && (chunk.includes('\b') || chunk.includes('\x7f'))) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
// arrow key echo or cursor movement (short escape sequences)
|
|
96
|
+
if (chunk.length <= 6 && chunk.startsWith('\x1b[') && !chunk.includes('J') && !chunk.includes('H')) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
// enter/newline
|
|
100
|
+
if (chunk === '\n' || chunk === '\r' || chunk === '\r\n') {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* safe clear - defers if typing's active so we don't eat keystrokes
|
|
108
|
+
*/
|
|
109
|
+
function safeClearScrollback() {
|
|
110
|
+
if (isTypingActive()) {
|
|
111
|
+
if (!pendingClear) {
|
|
112
|
+
pendingClear = true;
|
|
113
|
+
log('deferring clear - typing active');
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
pendingClear = false;
|
|
116
|
+
if (!isTypingActive()) {
|
|
117
|
+
safeClearScrollback();
|
|
118
|
+
}
|
|
119
|
+
}, config.typingCooldownMs);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (originalWrite && process.stdout.isTTY) {
|
|
125
|
+
// use setImmediate to not block the event loop
|
|
126
|
+
setImmediate(() => {
|
|
127
|
+
log('executing deferred scrollback clear');
|
|
128
|
+
originalWrite(CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
46
133
|
/**
|
|
47
134
|
* installs the fix - hooks into stdout and sigwinch
|
|
48
|
-
* call this once at startup, calling again
|
|
135
|
+
* call this once at startup, calling again won't do anything
|
|
49
136
|
*/
|
|
50
137
|
function install() {
|
|
51
138
|
if (installed || config.disabled) {
|
|
@@ -55,25 +142,86 @@ function install() {
|
|
|
55
142
|
|
|
56
143
|
originalWrite = process.stdout.write.bind(process.stdout);
|
|
57
144
|
|
|
145
|
+
// track stdin to know when user is typing
|
|
146
|
+
if (process.stdin.isTTY) {
|
|
147
|
+
process.stdin.on('data', () => {
|
|
148
|
+
lastTypingTime = Date.now();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
58
152
|
// hook stdout.write - this is where the magic happens
|
|
59
153
|
process.stdout.write = function(chunk, encoding, callback) {
|
|
154
|
+
// CRITICAL FIX: pass stdin echoes through unmodified
|
|
155
|
+
// this prevents the typing issue where keystrokes get lost
|
|
60
156
|
if (typeof chunk === 'string') {
|
|
157
|
+
// check if this is a stdin echo - if so, pass through immediately
|
|
158
|
+
if (isStdinEcho(chunk)) {
|
|
159
|
+
lastTypingTime = Date.now(); // update typing time
|
|
160
|
+
return originalWrite(chunk, encoding, callback);
|
|
161
|
+
}
|
|
162
|
+
|
|
61
163
|
renderCount++;
|
|
62
164
|
|
|
165
|
+
// track output for glitch detection
|
|
166
|
+
if (glitchDetector) {
|
|
167
|
+
glitchDetector.trackStdout();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// count lines so we can cap at 120
|
|
171
|
+
const newlineCount = (chunk.match(/\n/g) || []).length;
|
|
172
|
+
lineCount += newlineCount;
|
|
173
|
+
|
|
174
|
+
// hit the limit? force a trim
|
|
175
|
+
if (lineCount > config.maxLineCount) {
|
|
176
|
+
log('line limit exceeded (' + lineCount + '/' + config.maxLineCount + '), forcing trim');
|
|
177
|
+
lineCount = 0;
|
|
178
|
+
chunk = CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE + chunk;
|
|
179
|
+
}
|
|
180
|
+
|
|
63
181
|
// ink clears screen before re-render, we piggyback on that
|
|
182
|
+
// but only if not actively typing
|
|
64
183
|
if (chunk.includes(CLEAR_SCREEN) || chunk.includes(HOME_CURSOR)) {
|
|
184
|
+
lineCount = 0; // Reset line count on screen clear
|
|
65
185
|
if (config.clearAfterRenders > 0 && renderCount >= config.clearAfterRenders) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
186
|
+
if (!isTypingActive()) {
|
|
187
|
+
log('clearing scrollback after ' + renderCount + ' renders');
|
|
188
|
+
renderCount = 0;
|
|
189
|
+
chunk = CLEAR_SCROLLBACK + chunk;
|
|
190
|
+
} else {
|
|
191
|
+
log('skipping render-based clear - typing active');
|
|
192
|
+
}
|
|
69
193
|
}
|
|
70
194
|
}
|
|
71
195
|
|
|
72
|
-
// /clear
|
|
196
|
+
// /clear should actually clear everything (it's user-requested so do it now)
|
|
73
197
|
if (chunk.includes('Conversation cleared') || chunk.includes('Chat cleared')) {
|
|
74
198
|
log('/clear detected, nuking scrollback');
|
|
199
|
+
lineCount = 0;
|
|
75
200
|
chunk = CLEAR_SCROLLBACK + chunk;
|
|
76
201
|
}
|
|
202
|
+
|
|
203
|
+
// glitched? try to recover
|
|
204
|
+
if (glitchDetector && config.glitchRecoveryEnabled && !glitchRecoveryInProgress) {
|
|
205
|
+
if (glitchDetector.isInGlitchState()) {
|
|
206
|
+
glitchRecoveryInProgress = true;
|
|
207
|
+
log('GLITCH DETECTED - initiating recovery');
|
|
208
|
+
|
|
209
|
+
// Force clear scrollback immediately
|
|
210
|
+
chunk = CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE + chunk;
|
|
211
|
+
lineCount = 0;
|
|
212
|
+
renderCount = 0;
|
|
213
|
+
|
|
214
|
+
// Attempt full recovery asynchronously
|
|
215
|
+
setImmediate(async () => {
|
|
216
|
+
try {
|
|
217
|
+
await glitchDetector.attemptRecovery();
|
|
218
|
+
} catch (e) {
|
|
219
|
+
log('recovery error:', e.message);
|
|
220
|
+
}
|
|
221
|
+
glitchRecoveryInProgress = false;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
77
225
|
}
|
|
78
226
|
|
|
79
227
|
return originalWrite(chunk, encoding, callback);
|
|
@@ -83,17 +231,36 @@ function install() {
|
|
|
83
231
|
installResizeDebounce();
|
|
84
232
|
|
|
85
233
|
// periodic cleanup so long sessions dont get cooked
|
|
234
|
+
// uses safeClearScrollback which respects typing activity
|
|
86
235
|
if (config.periodicClearMs > 0) {
|
|
87
|
-
setInterval(() => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
originalWrite(CURSOR_SAVE + CLEAR_SCROLLBACK + CURSOR_RESTORE);
|
|
91
|
-
}
|
|
236
|
+
clearIntervalId = setInterval(() => {
|
|
237
|
+
log('periodic clear check');
|
|
238
|
+
safeClearScrollback();
|
|
92
239
|
}, config.periodicClearMs);
|
|
93
240
|
}
|
|
94
241
|
|
|
242
|
+
// hook up the glitch detector
|
|
243
|
+
if (glitchDetector) {
|
|
244
|
+
glitchDetector.install();
|
|
245
|
+
|
|
246
|
+
// Listen for glitch events
|
|
247
|
+
glitchDetector.on('glitch-detected', (data) => {
|
|
248
|
+
log('GLITCH EVENT:', JSON.stringify(data.signals));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
glitchDetector.on('recovery-success', (data) => {
|
|
252
|
+
log('recovery successful via', data.method);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
glitchDetector.on('recovery-failed', () => {
|
|
256
|
+
log('recovery failed - may need manual intervention');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
log('glitch detector installed');
|
|
260
|
+
}
|
|
261
|
+
|
|
95
262
|
installed = true;
|
|
96
|
-
log('installed successfully');
|
|
263
|
+
log('installed successfully - v2.0.0 with glitch detection & 120-line limit');
|
|
97
264
|
}
|
|
98
265
|
|
|
99
266
|
function installResizeDebounce() {
|
|
@@ -133,7 +300,7 @@ function installResizeDebounce() {
|
|
|
133
300
|
}
|
|
134
301
|
|
|
135
302
|
/**
|
|
136
|
-
* manually clear scrollback - call this whenever you want
|
|
303
|
+
* manually clear scrollback - call this whenever you want, it won't break anything
|
|
137
304
|
*/
|
|
138
305
|
function clearScrollback() {
|
|
139
306
|
if (originalWrite) {
|
|
@@ -148,12 +315,40 @@ function clearScrollback() {
|
|
|
148
315
|
* get current stats for debugging
|
|
149
316
|
*/
|
|
150
317
|
function getStats() {
|
|
151
|
-
|
|
318
|
+
const stats = {
|
|
152
319
|
renderCount,
|
|
320
|
+
lineCount,
|
|
153
321
|
lastResizeTime,
|
|
154
322
|
installed,
|
|
155
323
|
config
|
|
156
324
|
};
|
|
325
|
+
|
|
326
|
+
// add glitch stats if available
|
|
327
|
+
if (glitchDetector) {
|
|
328
|
+
stats.glitch = glitchDetector.getMetrics();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return stats;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* force recovery if shit hits the fan
|
|
336
|
+
*/
|
|
337
|
+
async function forceRecovery() {
|
|
338
|
+
if (glitchDetector) {
|
|
339
|
+
log('forcing recovery manually');
|
|
340
|
+
return await glitchDetector.attemptRecovery();
|
|
341
|
+
}
|
|
342
|
+
// Fallback if no detector
|
|
343
|
+
clearScrollback();
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* check if terminal's currently cooked
|
|
349
|
+
*/
|
|
350
|
+
function isGlitched() {
|
|
351
|
+
return glitchDetector ? glitchDetector.isInGlitchState() : false;
|
|
157
352
|
}
|
|
158
353
|
|
|
159
354
|
/**
|
|
@@ -167,7 +362,7 @@ function setConfig(key, value) {
|
|
|
167
362
|
}
|
|
168
363
|
|
|
169
364
|
/**
|
|
170
|
-
* disable the fix (mostly for testing)
|
|
365
|
+
* disable the fix (it's mostly for testing)
|
|
171
366
|
*/
|
|
172
367
|
function disable() {
|
|
173
368
|
if (originalWrite) {
|
|
@@ -182,5 +377,9 @@ module.exports = {
|
|
|
182
377
|
getStats,
|
|
183
378
|
setConfig,
|
|
184
379
|
disable,
|
|
185
|
-
config
|
|
380
|
+
config,
|
|
381
|
+
// NEW v2.0 exports
|
|
382
|
+
forceRecovery,
|
|
383
|
+
isGlitched,
|
|
384
|
+
getDetector: () => glitchDetector
|
|
186
385
|
};
|
package/loader.cjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* use like: node --require claudescreenfix-hardwicksoftware/loader.cjs $(which claude)
|
|
7
7
|
*
|
|
8
8
|
* this auto-installs the fix before claude code even starts
|
|
9
|
-
*
|
|
9
|
+
* you don't need to change any code in claude itself - it just works
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const fix = require('./index.cjs');
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudescreenfix-hardwicksoftware",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "fixes the scroll glitch in claude code cli -
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "fixes the scroll glitch in claude code cli - now with GLITCH DETECTION, 120-line limit enforcement, and auto-recovery",
|
|
5
5
|
"main": "index.cjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-fixed": "./bin/claude-fixed.js"
|
|
8
8
|
},
|
|
9
|
-
"scripts": {
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node -e \"const fix = require('./index.cjs'); fix.install(); console.log(fix.getStats());\""
|
|
11
|
+
},
|
|
10
12
|
"keywords": [
|
|
11
13
|
"claude",
|
|
12
14
|
"terminal",
|
|
@@ -15,7 +17,10 @@
|
|
|
15
17
|
"ink",
|
|
16
18
|
"cli",
|
|
17
19
|
"glitch",
|
|
18
|
-
"performance"
|
|
20
|
+
"performance",
|
|
21
|
+
"glitch-detection",
|
|
22
|
+
"recovery",
|
|
23
|
+
"120-line-limit"
|
|
19
24
|
],
|
|
20
25
|
"author": "jonhardwick-spec",
|
|
21
26
|
"license": "MIT",
|
|
@@ -32,6 +37,7 @@
|
|
|
32
37
|
"files": [
|
|
33
38
|
"index.cjs",
|
|
34
39
|
"loader.cjs",
|
|
40
|
+
"glitch-detector.cjs",
|
|
35
41
|
"bin/",
|
|
36
42
|
"README.md",
|
|
37
43
|
"LICENSE"
|