diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 27436b09b9e0..7d9ba8b9ddb3 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -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) } @@ -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) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index ee0f88b941db..2498eb53f879 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -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( + 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") + } + func testSessionScrollbackPersistenceHonorsReportedShellState() { XCTAssertTrue( Workspace.shouldPersistSessionScrollback(