diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt index 14aa6211..48f6eb28 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt @@ -39,6 +39,10 @@ class DebugDataCollector( private val snapshots = ConcurrentLinkedQueue() private val chunkIndex = AtomicInteger(0) + private companion object { + const val PTY_LOG_ENV = "BOSSTERM_PTY_LOG" + } + /** * Sizes of [chunks] and [snapshots], tracked rather than asked for. * @@ -395,6 +399,34 @@ class DebugDataCollector( * @param filePath Path to the log file * @throws java.io.IOException If the file cannot be created or written to */ + /** + * Start recording to disk automatically when `BOSSTERM_PTY_LOG` is set. + * + * The escaped format [writeChunkToFile] emits is reversible, so a recording made this + * way replays byte-for-byte through `PtyReplay` in the test sources. That is the point: + * a redraw glitch in a TUI is intermittent and unscreenshotable, but the byte stream + * that produced it is a deterministic input. Capture it once, and the bug becomes a + * failing test instead of a theory. + * + * Off unless the variable is set, and `startFileLogging` had NO callers before this - + * the facility existed and was unreachable. + * + * Set to `1` for the default location, or to a directory to choose one: + * BOSSTERM_PTY_LOG=1 ./gradlew :bossterm-app:run + * BOSSTERM_PTY_LOG=/tmp/ptylogs ./gradlew :bossterm-app:run + */ + fun startFileLoggingIfRequested(tabLabel: String) { + val requested = System.getenv(PTY_LOG_ENV)?.takeIf { it.isNotBlank() } ?: return + val dir = if (requested == "1" || requested.equals("true", ignoreCase = true)) { + "${System.getProperty("user.home")}/.bossterm/pty-log" + } else { + requested + } + val safe = tabLabel.replace(Regex("[^A-Za-z0-9._-]"), "_").take(40) + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss").format(Date()) + runCatching { startFileLogging("$dir/$stamp-$safe.log") } + } + fun startFileLogging(filePath: String) { synchronized(this) { stopFileLogging() @@ -448,14 +480,19 @@ class DebugDataCollector( ChunkSource.CONSOLE_LOG -> "LOG#" } writer.print("[$timestamp] $sourceTag ") - // Escape non-printable characters for readability + // Escape non-printables for readability - and LOSSLESSLY, because + // `PtyReplay` in the test sources reverses this to replay a recording + // byte for byte. A literal backslash was previously written through + // unescaped, which made "\\e" ambiguous between an escape character and + // the two characters `\` and `e`: readable, but not replayable. val escaped = chunk.data.joinToString("") { c -> when { + c == '\\' -> "\\\\" c == '\u001b' -> "\\e" c == '\n' -> "\\n" c == '\r' -> "\\r" c == '\t' -> "\\t" - c.code < 32 -> "\\x${c.code.toString(16).padStart(2, '0')}" + c.code < 32 || c.code == 127 -> "\\x${c.code.toString(16).padStart(2, '0')}" else -> c.toString() } } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 8710d694..b8163b2f 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -559,7 +559,7 @@ class TabController( tab = null, // Will be set after tab creation maxChunks = settings.debugMaxChunks, maxSnapshots = settings.debugMaxSnapshots - ) + ).also { it.startFileLoggingIfRequested(tabId ?: "tab") } // Create type-ahead model and manager if enabled val typeAheadModel = if (settings.typeAheadEnabled) { @@ -946,7 +946,7 @@ class TabController( tab = null, // Will be set after tab creation maxChunks = settings.debugMaxChunks, maxSnapshots = settings.debugMaxSnapshots - ) + ).also { it.startFileLoggingIfRequested(tabId ?: "tab") } // Create type-ahead model and manager if enabled val tabCoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplay.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplay.kt new file mode 100644 index 00000000..5df0d249 --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplay.kt @@ -0,0 +1,132 @@ +package ai.rever.bossterm.compose.debug + +import ai.rever.bossterm.compose.ComposeTerminalDisplay +import ai.rever.bossterm.compose.terminal.BlockingTerminalDataStream +import ai.rever.bossterm.compose.terminal.drainTerminalEmulator +import ai.rever.bossterm.terminal.emulator.BossEmulator +import ai.rever.bossterm.terminal.model.BossTerminal +import ai.rever.bossterm.terminal.model.StyleState +import ai.rever.bossterm.terminal.model.TerminalTextBuffer +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Replay a recorded PTY byte stream into a headless terminal and read the grid back. + * + * Why this exists: a redraw glitch in a TUI is intermittent, and a screenshot of one is + * evidence that something is wrong but not evidence of what. The byte stream that produced + * it, on the other hand, is a deterministic input - the same bytes must always produce the + * same grid. So a recording turns "sometimes the wrapped line garbles" into a test. + * + * This matters most for applications that redraw DIFFERENTIALLY. Claude Code, for one, + * brackets each frame in DEC 2026 and then rewrites only the characters that changed, using + * absolute column addressing and no line clears: + * + * CSI 35 C "th" CSI 39 G "nking more with xhigh ef" CSI 64 G "ort" + * + * It is diffing against its own model of our grid. One cell of disagreement anywhere becomes + * permanent visible corruption, because nothing ever repaints the line to resync - and it + * surfaces later, somewhere unrelated to where it started. Comparing a replayed grid against + * the expected one is how that divergence gets localised to the chunk that caused it. + * + * Recordings come from `BOSSTERM_PTY_LOG=1`, which writes the same escaped format this + * parser reverses. + */ +object PtyReplay { + + data class Chunk(val source: String, val data: String) + + private val LINE = Regex("""^\[[^]]*] (PTY>|USR<|EMU!|LOG#) (.*)$""") + + /** + * Reverse [DebugDataCollector]'s file format. + * + * Only `PTY>` lines are terminal output; the others are this side's own writes and log + * noise, and feeding them back would replay input the shell never saw. + */ + fun parseLog(text: String): List = + text.lineSequence() + .mapNotNull { LINE.find(it) } + .map { Chunk(it.groupValues[1], unescape(it.groupValues[2])) } + .toList() + + /** Inverse of the collector's escaper. See its comment for why `\\` must come first. */ + fun unescape(s: String): String { + val out = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + val c = s[i] + if (c != '\\' || i == s.lastIndex) { + out.append(c); i++; continue + } + when (val next = s[i + 1]) { + '\\' -> { out.append('\\'); i += 2 } + 'e' -> { out.append('\u001B'); i += 2 } + 'n' -> { out.append('\n'); i += 2 } + 'r' -> { out.append('\r'); i += 2 } + 't' -> { out.append('\t'); i += 2 } + 'x' -> { + val hex = s.substring(i + 2, minOf(i + 4, s.length)) + val code = hex.toIntOrNull(16) + if (code != null && hex.length == 2) { + out.append(code.toChar()); i += 4 + } else { + out.append(c); i++ + } + } + else -> { out.append(c).append(next); i += 2 } + } + } + return out.toString() + } + + /** + * Feed [chunks] through a real emulator at [width] x [height] and return the visible + * grid, one string per row, trailing blanks trimmed. + * + * Chunk boundaries are preserved rather than concatenated: the batch begin/end they + * drive is part of what is being reproduced, and a CSI split across two reads is + * exactly the kind of thing a recording should be able to re-expose. + */ + fun replay(chunks: List, width: Int, height: Int): List { + val display = ComposeTerminalDisplay() + val styleState = StyleState() + val textBuffer = TerminalTextBuffer(width, height, styleState) + val terminal = BossTerminal(display, textBuffer, styleState) + val dataStream = BlockingTerminalDataStream() + val emulator = BossEmulator(dataStream, terminal) + + dataStream.onChunkStart = textBuffer::beginBatch + dataStream.onChunkEnd = textBuffer::endBatch + + val executor = Executors.newSingleThreadExecutor() + val drain = executor.submit { + drainTerminalEmulator( + emulator = emulator, + dataStream = dataStream, + terminal = terminal, + shouldContinue = { true }, + ) + } + try { + chunks.filter { it.source == "PTY>" }.forEach { dataStream.append(it.data) } + dataStream.close() + drain.get(30, TimeUnit.SECONDS) + } finally { + executor.shutdownNow() + } + + textBuffer.lock() + try { + return (0 until height).map { row -> + textBuffer.getLine(row).text.trimEnd() + } + } finally { + textBuffer.unlock() + } + } + + /** Convenience: parse and replay in one step. */ + fun replayLog(text: String, width: Int, height: Int): List = + replay(parseLog(text), width, height) +} diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplayTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplayTest.kt new file mode 100644 index 00000000..ea33a3c9 --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplayTest.kt @@ -0,0 +1,143 @@ +package ai.rever.bossterm.compose.debug + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The replay harness is only worth anything if a recording round-trips exactly. If the + * escaper and the parser disagree by one character, every conclusion drawn from a replayed + * grid is about the wrong bytes - and it would look like a terminal bug rather than a + * tooling bug, which is the worst possible failure mode for a debugging aid. + */ +class PtyReplayTest { + + private fun roundTrip(payload: String): String { + val file = File.createTempFile("pty-replay", ".log") + file.deleteOnExit() + val collector = DebugDataCollector(tab = null, maxChunks = 16, maxSnapshots = 2) + collector.startFileLogging(file.absolutePath) + collector.recordChunk(payload, ChunkSource.PTY_OUTPUT) + collector.stopFileLogging() + val chunks = PtyReplay.parseLog(file.readText()) + assertEquals(1, chunks.size, "expected exactly one PTY chunk in the recording") + return chunks.single().data + } + + @Test + fun aRecordingRoundTripsThroughTheProductionEscaper() { + // Through the real writeChunkToFile, not a reimplementation of it, so the two halves + // cannot drift apart silently. + listOf( + "plain ascii", + "\u001B[?2026h\u001B[?25l\u001B[H\r\u001B[27B", + "\u001B[38;2;255;193;7m\u001B[1m\u001B[22m\u001B[39m", + "tab\there\nnewline\rcarriage", + "control\u0000nul\u0007bell\u007Fdel", + "unicode ✻ ✽ ✶ 你好 👨\u200D💻 └─", + ).forEach { payload -> + assertEquals(payload, roundTrip(payload), "round trip lost data") + } + } + + @Test + fun aLiteralBackslashIsNotConfusedWithAnEscape() { + // The reason the escaper needed fixing. Unescaped, the two characters `\` and `e` + // are indistinguishable from an ESC, so a Windows path or a regex in the output + // would replay as a control sequence. + listOf( + """C:\Users\kshivang\dev""", + """\e is not an escape here""", + """\\e""", + """grep -E '\d+\s*' file""", + """trailing backslash \""", + ).forEach { payload -> + assertEquals(payload, roundTrip(payload), "backslash handling lost data") + } + } + + @Test + fun onlyTerminalOutputIsReplayed() { + // Replaying USR< would feed our own keystrokes back into the emulator as if the + // shell had echoed them, inventing content the recording never contained. + val file = File.createTempFile("pty-replay-mixed", ".log") + file.deleteOnExit() + val collector = DebugDataCollector(tab = null, maxChunks = 16, maxSnapshots = 2) + collector.startFileLogging(file.absolutePath) + collector.recordChunk("from-pty", ChunkSource.PTY_OUTPUT) + collector.recordChunk("from-user", ChunkSource.USER_INPUT) + collector.stopFileLogging() + + val parsed = PtyReplay.parseLog(file.readText()) + assertEquals(2, parsed.size, "the parser should surface both, and let replay filter") + assertEquals(listOf("PTY>", "USR<"), parsed.map { it.source }) + + val grid = PtyReplay.replay(parsed, width = 20, height = 2) + assertEquals("from-pty", grid[0]) + assertTrue(grid.none { it.contains("from-user") }, "user input must not be replayed") + } + + @Test + fun aDifferentialRedrawReplaysToTheGridTheAppIntended() { + // The shape that produced the reported glitch: absolute column addressing, no line + // clear, only the changed characters rewritten. Frame two turns + // "still thinking with xhigh effort" into "thinking more with xhigh effort" by + // rewriting cols 1-2, skipping col 3, and resuming at col 4 - which is only correct + // if our grid holds exactly what frame one left there. + val esc = "\u001B" + val frame1 = "$esc[H${esc}[Kstill thinking" + + // With the trailing clear the real capture ends on (chunk 16312 finishes with + // CSI[K]), the shorter replacement lands cleanly. + assertEquals( + "thinking more", + PtyReplay.replay( + listOf( + PtyReplay.Chunk("PTY>", frame1), + PtyReplay.Chunk("PTY>", "$esc[H" + "th" + "$esc[4G" + "nking more" + "$esc[K"), + ), + width = 40, height = 2, + )[0], + ) + + // WITHOUT it, the tail of the previous frame survives - "thinking moreg", the + // trailing g of "thinking" left behind because nothing overwrote or cleared it. + // That is the whole bug class this harness exists to catch: a differential redraw + // leaves stale cells whenever the app's model of the grid and ours disagree about + // what is already there, and no full repaint ever comes along to resync. Pinned + // here so the emulator's behaviour is stated rather than assumed - it is correct, + // and it is why one cell of divergence becomes permanent visible corruption. + assertEquals( + "thinking moreg", + PtyReplay.replay( + listOf( + PtyReplay.Chunk("PTY>", frame1), + PtyReplay.Chunk("PTY>", "$esc[H" + "th" + "$esc[4G" + "nking more"), + ), + width = 40, height = 2, + )[0], + ) + } + + @Test + fun chunkBoundariesDoNotSplitAControlSequence() { + // A CSI arriving across two PTY reads is normal and must not corrupt the grid. This + // is the property BlockingTerminalDataStream exists for; replaying preserves the + // boundaries so a recording can re-expose it rather than smoothing it over. + val esc = "\u001B" + val whole = PtyReplay.replay( + listOf(PtyReplay.Chunk("PTY>", "$esc[H${esc}[2Jabc$esc[3Gz")), + width = 10, height = 2, + ) + val split = PtyReplay.replay( + listOf( + PtyReplay.Chunk("PTY>", "$esc[H${esc}[2Jabc$esc["), + PtyReplay.Chunk("PTY>", "3Gz"), + ), + width = 10, height = 2, + ) + assertEquals(whole, split, "a CSI split across reads changed the result") + assertEquals("abz", whole[0]) + } +}