Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class DebugDataCollector(
private val snapshots = ConcurrentLinkedQueue<TerminalSnapshot>()
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.
*
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Chunk> =
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<Chunk>, width: Int, height: Int): List<String> {
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<String> =
replay(parseLog(text), width, height)
}
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading