fix: write session logs as plain text instead of raw PTY soup
Recorded logs carried the raw stream: SGR colors, cursor moves, synchronized-output markers, and every TUI redraw frame — unreadable in an editor and far larger than the visible output (a short kimi session logged 21.5KB of which 4.4KB is text). A per-session sanitizer now strips ANSI statefully across chunk boundaries, collapses carriage-return overwrites (progress bars keep only their final text), and suppresses alternate-screen frames with a marker line. Ink-style inline TUIs redraw in the normal buffer and cannot be frame-collapsed without screen emulation; their committed lines are preserved.
This commit is contained in:
1 parent
28d8af6982
commit
458dd9c88b
2 files changed
+166
-1
No files matched your search
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Plain-text sanitizer for session logs.
|
||||
*
|
||||
* Raw PTY output is unreadable in a text file: SGR colors, cursor moves,
|
||||
* synchronized-output markers — and for full-screen TUIs (vim, htop) every
|
||||
* redrawn frame. For each logged session this filter:
|
||||
* 1. strips ANSI escape sequences (CSI / OSC / single-char), statefully
|
||||
* across chunk boundaries (a sequence may split between two PTY chunks);
|
||||
* 2. collapses carriage-return overwrites (progress bars, spinner lines)
|
||||
* keeping only the final text of the line — a `\r` directly followed by
|
||||
* `\n` is a line ending, not an overwrite;
|
||||
* 3. drops output produced while the alternate screen is active (TUI
|
||||
* redraw frames), emitting one marker line per suppressed span.
|
||||
*
|
||||
* Note: inline TUIs that redraw in the *normal* buffer (Ink-based CLIs such
|
||||
* as Claude Code / kimi) cannot be frame-collapsed without full screen
|
||||
* emulation; their committed lines are preserved as-is.
|
||||
*/
|
||||
|
||||
const MARKER = '\n──── [全屏界面(TUI)输出已省略] ────\n'
|
||||
|
||||
type Mode = 'text' | 'esc' | 'esc-skip1' | 'csi' | 'osc' | 'osc-esc'
|
||||
|
||||
/** Alt-screen toggles: CSI ? 1049|1047|47 h / l */
|
||||
const ALT_ON = /^\?(?:1049|1047|47)h$/
|
||||
const ALT_OFF = /^\?(?:1049|1047|47)l$/
|
||||
|
||||
export class LogSanitizer {
|
||||
private mode: Mode = 'text'
|
||||
/** raw bytes of the escape sequence in flight (bounded: OSC can be long) */
|
||||
private seq = ''
|
||||
private alt = false
|
||||
/** some output was suppressed since the last marker */
|
||||
private altDirty = false
|
||||
/** current line, not yet newline-terminated */
|
||||
private line = ''
|
||||
/** previous text char was \r — the next char decides ending vs overwrite */
|
||||
private cr = false
|
||||
|
||||
/** Transform one raw chunk; returns the plain text to append ('' for none). */
|
||||
push(data: string): string {
|
||||
let out = ''
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const c = data[i]
|
||||
|
||||
// A pending \r: `\r\n` completes the line; anything else overwrites it.
|
||||
if (this.cr) {
|
||||
this.cr = false
|
||||
if (c === '\n') {
|
||||
out += this.emitLine()
|
||||
continue
|
||||
}
|
||||
this.line = ''
|
||||
// fall through and process c normally
|
||||
}
|
||||
|
||||
switch (this.mode) {
|
||||
case 'text':
|
||||
if (c === '\x1b') {
|
||||
this.mode = 'esc'
|
||||
this.seq = ''
|
||||
} else if (c === '\r') {
|
||||
this.cr = true
|
||||
} else if (c === '\n') {
|
||||
out += this.emitLine()
|
||||
} else if (c === '\t' || c >= ' ' || c === '\x7f') {
|
||||
// printable + tab; DEL and C0 controls (bell etc.) are dropped
|
||||
if (this.alt) this.altDirty = true
|
||||
else this.line += c
|
||||
}
|
||||
break
|
||||
case 'esc':
|
||||
if (c === '[') this.mode = 'csi'
|
||||
else if (c === ']') this.mode = 'osc'
|
||||
else if (c === '(' || c === ')' || c === '#' || c === '%') this.mode = 'esc-skip1'
|
||||
else this.mode = 'text' // single-character escape, consumed
|
||||
break
|
||||
case 'esc-skip1':
|
||||
this.mode = 'text'
|
||||
break
|
||||
case 'csi':
|
||||
if (c >= '@' && c <= '~') {
|
||||
// the final byte is not part of `seq` — test the full sequence
|
||||
const full = this.seq + c
|
||||
if (ALT_ON.test(full)) {
|
||||
this.alt = true
|
||||
} else if (ALT_OFF.test(full)) {
|
||||
this.alt = false
|
||||
if (this.altDirty) {
|
||||
this.altDirty = false
|
||||
out += MARKER
|
||||
}
|
||||
}
|
||||
this.seq = ''
|
||||
this.mode = 'text'
|
||||
} else if (this.seq.length < 1024) {
|
||||
this.seq += c
|
||||
}
|
||||
break
|
||||
case 'osc':
|
||||
if (c === '\x07') this.mode = 'text'
|
||||
else if (c === '\x1b') this.mode = 'osc-esc'
|
||||
break
|
||||
case 'osc-esc':
|
||||
// ST is ESC \; a new ESC [ / ESC ] starts the next sequence
|
||||
if (c === '\\') this.mode = 'text'
|
||||
else if (c === '[') {
|
||||
this.mode = 'csi'
|
||||
this.seq = ''
|
||||
} else if (c === ']') this.mode = 'osc'
|
||||
else this.mode = 'text'
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Flush the pending partial line at log stop. */
|
||||
flush(): string {
|
||||
let out = ''
|
||||
if (this.cr) {
|
||||
this.cr = false
|
||||
// A trailing \r at stop: treat as line ending rather than overwrite.
|
||||
out += this.emitLine()
|
||||
} else if (this.line && !this.alt) {
|
||||
out += this.line
|
||||
this.line = ''
|
||||
}
|
||||
if (this.altDirty) {
|
||||
this.altDirty = false
|
||||
out += MARKER
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Complete the current line: hand it out with a newline and reset. */
|
||||
private emitLine(): string {
|
||||
if (this.alt) {
|
||||
if (this.line) this.altDirty = true
|
||||
this.line = ''
|
||||
return ''
|
||||
}
|
||||
const out = this.line ? this.line + '\n' : '\n'
|
||||
this.line = ''
|
||||
return out
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user