Skip to content
Merged
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
100 changes: 99 additions & 1 deletion Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1964,7 +1964,14 @@ enum SessionScrollbackReplayStore {
private static func normalizedScrollback(_ scrollback: String?) -> String? {
guard let scrollback else { return nil }
guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil }
guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil }
// Restored history must not reconfigure the live terminal's colors: the
// active theme owns the default foreground/background (and palette), so
// default-colored cells track it. The captured scrollback bakes the
// capture-time theme via terminal-color OSC sequences (e.g. OSC 10/11),
// which would otherwise survive a theme change as white-on-white output
// (issue #5165). Strip them before replay.
let themePortable = strippingTerminalColorOSCSequences(scrollback)
guard let truncated = SessionPersistencePolicy.truncatedScrollback(themePortable) else { return nil }
return ansiSafeReplayText(truncated)
}

Expand All @@ -1981,6 +1988,97 @@ enum SessionScrollbackReplayStore {
return output
}

/// Removes terminal-color OSC sequences (palette entries and the dynamic
/// foreground/background/cursor/highlight colors plus their resets) from
/// captured scrollback so the restored history does not reconfigure the live
/// terminal's colors.
///
/// Ghostty's `write_screen_file:copy,vt` export bakes the capture-time theme
/// by prepending `OSC 10` / `OSC 11` (and resolving palette entries). Replaying
/// those into a freshly launched terminal would override the active theme's
/// default colors, so restored default-colored cells would keep the old theme
/// (white-on-white after a theme change — issue #5165). Explicit per-cell SGR
/// colors and every non-color escape sequence (titles, hyperlinks, prompt
/// marks, …) are preserved verbatim.
private static func strippingTerminalColorOSCSequences(_ text: String) -> String {
let escByte: UInt8 = 0x1B
let oscIntroducer: UInt8 = 0x5D // ]
let bel: UInt8 = 0x07
let backslash: UInt8 = 0x5C
let zero: UInt8 = 0x30
let nine: UInt8 = 0x39

let bytes = Array(text.utf8)
guard bytes.contains(escByte) else { return text }

var output = [UInt8]()
output.reserveCapacity(bytes.count)
let count = bytes.count
var index = 0
while index < count {
let byte = bytes[index]
guard byte == escByte,
index + 1 < count,
bytes[index + 1] == oscIntroducer else {
output.append(byte)
index += 1
continue
}

// Parse the OSC numeric command (Ps) following `ESC ]`.
var cursor = index + 2
var code = 0
var sawDigit = false
while cursor < count, bytes[cursor] >= zero, bytes[cursor] <= nine {
code = (code * 10) + Int(bytes[cursor] - zero)
sawDigit = true
cursor += 1
if code > 100_000 { break } // overflow guard for malformed input
}

guard sawDigit, isTerminalColorOSCCode(code) else {
// Not a terminal-color OSC; emit `ESC` and resume scanning so the
// rest of the preserved sequence is copied verbatim.
output.append(byte)
index += 1
continue
}

// Consume through the OSC terminator (BEL or `ESC \` / ST). A truncated
// (unterminated) color OSC at the end of the buffer is dropped as well.
var end = cursor
var terminated = false
while end < count {
if bytes[end] == bel {
end += 1
terminated = true
break
}
if bytes[end] == escByte, end + 1 < count, bytes[end + 1] == backslash {
end += 2
terminated = true
break
}
end += 1
}
index = terminated ? end : count
}

return String(decoding: output, as: UTF8.self)
}

/// Returns `true` for OSC command numbers that configure terminal colors
/// (palette entries and the dynamic foreground/background/cursor/highlight
/// colors plus their resets), which restored scrollback must not carry.
private static func isTerminalColorOSCCode(_ code: Int) -> Bool {
switch code {
case 4, 5, 104, 105: return true // palette / special color set + reset
case 10...19: return true // dynamic colors (fg, bg, cursor, …)
case 110...119: return true // dynamic color resets
default: return false
}
}

private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? {
guard let data = contents.data(using: .utf8) else { return nil }
let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true)
Expand Down
66 changes: 66 additions & 0 deletions cmuxTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,72 @@ final class SessionPersistenceTests: XCTestCase {
XCTAssertTrue(contents.hasSuffix(reset))
}

// Regression for https://github.com/manaflow-ai/cmux/issues/5165.
//
// Ghostty's `write_screen_file:copy,vt` export (used to capture session
// scrollback) prepends OSC 10 / OSC 11 sequences that bake the capture-time
// theme's default foreground/background. Replaying those into a freshly
// launched terminal reconfigures the live terminal's dynamic colors, so
// restored default-colored cells keep the OLD theme instead of tracking the
// active one — producing white-on-white scrollback after a theme change.
// The active theme owns default fg/bg, so the restored history must not carry
// these terminal-color OSC sequences.
func testScrollbackReplayStripsThemeBakedDefaultColorOSCSequences() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }

let esc = "\u{001B}"
// Captured under a dark theme: default fg baked white, default bg baked dark.
let setForeground = "\(esc)]10;rgb:ff/ff/ff\(esc)\\"
let setBackground = "\(esc)]11;rgb:28/2c/34\(esc)\\"
// A BEL-terminated cursor-color OSC, the other dynamic-color terminator form.
let setCursor = "\(esc)]12;rgb:c0/c1/b5\u{0007}"
// Palette set/reset and a dynamic-color reset are equally theme state that
// restored history must not re-impose, so they are stripped too.
let setPalette = "\(esc)]4;1;rgb:aa/00/00\(esc)\\"
let resetPalette = "\(esc)]104;1\(esc)\\"
let resetForeground = "\(esc)]110;\(esc)\\"
let red = "\(esc)[31m"
let reset = "\(esc)[0m"
// OSC 8 hyperlinks are scrollback content, not terminal color config; keep them.
let hyperlink = "\(esc)]8;;https://example.com\(esc)\\link\(esc)]8;;\(esc)\\"
let source = "\(setForeground)\(setBackground)\(setCursor)"
+ "\(setPalette)\(resetPalette)\(resetForeground)plain default text\n"
+ "\(red)RED\(reset) \(hyperlink)\n"

let environment = SessionScrollbackReplayStore.replayEnvironment(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: This test calls SessionScrollbackReplayStore.replayEnvironment(for:tempDirectory:) and asserts that OSC color sequences are stripped, but the implementation in SessionScrollbackReplayStore does not appear to have been modified in this PR to actually perform the stripping. As-is, this test will fail on CI and the white-on-white regression remains unfixed for users.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/SessionPersistenceTests.swift, line 518:

<comment>This test calls `SessionScrollbackReplayStore.replayEnvironment(for:tempDirectory:)` and asserts that OSC color sequences are stripped, but the implementation in `SessionScrollbackReplayStore` does not appear to have been modified in this PR to actually perform the stripping. As-is, this test will fail on CI and the white-on-white regression remains unfixed for users.</comment>

<file context>
@@ -486,6 +486,62 @@ final class SessionPersistenceTests: XCTestCase {
+        let source = "\(setForeground)\(setBackground)\(setCursor)plain default text\n"
+            + "\(red)RED\(reset) \(hyperlink)\n"
+
+        let environment = SessionScrollbackReplayStore.replayEnvironment(
+            for: source,
+            tempDirectory: tempDir
</file context>

for: source,
tempDirectory: tempDir
)

guard let path = environment[SessionScrollbackReplayStore.environmentKey] else {
XCTFail("Expected replay file path")
return
}
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
XCTFail("Expected replay file contents")
return
}

// Terminal-color OSC sequences must be stripped so the active theme owns
// default fg/bg/cursor and restored default cells track it.
XCTAssertFalse(contents.contains("\(esc)]10;"), "OSC 10 (set foreground) must be stripped")
XCTAssertFalse(contents.contains("\(esc)]11;"), "OSC 11 (set background) must be stripped")
XCTAssertFalse(contents.contains("\(esc)]12;"), "OSC 12 (set cursor color) must be stripped")
XCTAssertFalse(contents.contains("\(esc)]4;"), "OSC 4 (set palette entry) must be stripped")
XCTAssertFalse(contents.contains("\(esc)]104;"), "OSC 104 (reset palette entry) must be stripped")
XCTAssertFalse(contents.contains("\(esc)]110;"), "OSC 110 (reset foreground) must be stripped")
XCTAssertFalse(contents.contains("rgb:ff/ff/ff"), "baked default-color payload must be gone")
XCTAssertFalse(contents.contains("rgb:aa/00/00"), "baked palette payload must be gone")

// Explicit SGR colors, plain text, and hyperlinks are preserved verbatim.
XCTAssertTrue(contents.contains("plain default text"))
XCTAssertTrue(contents.contains("\(red)RED\(reset)"))
XCTAssertTrue(contents.contains(hyperlink), "non-color OSC sequences must be preserved")
Comment on lines +540 to +552

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test coverage doesn't match the stated stripping scope

The PR description explicitly commits to stripping OSC 4, 5, 13–19, 104, 105, and 110–119 in addition to 10–12, but the test only asserts on 10, 11, and 12. When the fix lands, none of those additional sequences will be regression-covered. At minimum, representative assertions for the palette group (OSC 4), its reset (OSC 104), and one from the 110–119 reset block should be added so the test actually guards the full claimed scope.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment on lines +499 to +553

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fix commit is missing — bug is still live after merge

CLAUDE.md requires both commits in the PR: "Commit 1: Add the failing test only … Commit 2: Add the fix." This PR contains only commit 1. SessionScrollbackReplayStore.ansiSafeReplayText in Sources/SessionPersistence.swift is unchanged; it only prepends/appends ESC[0m and never strips OSC sequences. As written, the test will fail on every CI run and the white-on-white regression remains unfixed for users who merge this branch.


func testSessionScrollbackPersistenceHonorsReportedShellState() {
XCTAssertTrue(
Workspace.shouldPersistSessionScrollback(
Expand Down
Loading