From 766f0ded2802580a6146153ead3ff00ff4cc0fc2 Mon Sep 17 00:00:00 2001 From: icefery Date: Thu, 22 Oct 2026 19:35:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(macos):=20=E5=88=B7=E6=96=B0=E5=B7=B2?= =?UTF-8?q?=E6=89=93=E5=BC=80=E7=BB=88=E7=AB=AF=E7=9A=84=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Ports/WorkbenchBackgroundRendering.swift | 6 - .../MacOS/Terminal/MacTerminalTransport.swift | 23 ++- .../Views/Terminal/TerminalSurfaceView.swift | 38 +++- .../Tests/LitheTests/BundledUIFontTests.swift | 2 +- .../LitheTests/TerminalInputFocusTests.swift | 163 +++++++++++++++--- .../TerminalPlacementFeatureModelTests.swift | 9 +- .../features/terminal-canvas-compositing.json | 5 +- 7 files changed, 193 insertions(+), 53 deletions(-) delete mode 100644 macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift diff --git a/macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift b/macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift deleted file mode 100644 index a4b0d0404f..0000000000 --- a/macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift +++ /dev/null @@ -1,6 +0,0 @@ -/// Native surfaces that can reveal the workbench wallpaper behind their content. -/// Platform-native views own the concrete rendering and restoration behavior. -@MainActor -protocol WorkbenchBackgroundRendering: AnyObject { - func setWorkbenchBackgroundVisible(_ isVisible: Bool) -} diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 4f560c7f18..72e2d47299 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -158,19 +158,20 @@ final class LitheTerminalView: LocalProcessTerminalView { override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() - applyThemeColors() + applyThemeColors(theme: LitheTheme.activeTheme) } - func applyThemeColors() { - // Outside a window, effectiveAppearance follows the system; the app theme decides the reported background. + func applyThemeColors(theme: AppColorTheme, isDark requestedIsDark: Bool? = nil) { let isDark: Bool - if window == nil, let preferredAppearanceIsDark { + if let requestedIsDark { + isDark = requestedIsDark + } else if window == nil, let preferredAppearanceIsDark { isDark = preferredAppearanceIsDark() ?? (effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua) } else { isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } - let editorColor = LitheTheme.nsColor(.editor, isDark: isDark) + let editorColor = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark) let palette = MacTerminalPalette( preferences: UserDefaults.standard.persistentDomain(forName: "com.apple.Terminal"), backgroundColor: editorColor @@ -192,7 +193,7 @@ final class LitheTerminalView: LocalProcessTerminalView { // Terminal.app profiles may use a foreground color that assumes their // own opaque background. The terminal is composited over Lithe's // editor or wallpaper, so keep the default text tied to the app theme. - nativeForegroundColor = LitheTheme.nsColor(.primaryText, isDark: isDark) + nativeForegroundColor = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark) caretColor = isDark ? NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1) : NSColor(srgbRed: 0.18, green: 0.43, blue: 0.79, alpha: 1) @@ -220,13 +221,12 @@ final class LitheTerminalView: LocalProcessTerminalView { guard process.running else { return } terminal.sendResponse(text: MacTerminalBackgroundReporting.changeNotification(for: nativeBackgroundColor)) } - - func setWorkbenchBackgroundVisible(_ isVisible: Bool) { - guard showsWorkbenchBackground != isVisible else { return } + func updateWorkbenchSurface(isVisible: Bool, theme: AppColorTheme, isDark: Bool) { showsWorkbenchBackground = isVisible - applyThemeColors() + applyThemeColors(theme: theme, isDark: isDark) } + override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { onOpenLink?(link, params) } @@ -241,7 +241,6 @@ final class LitheTerminalView: LocalProcessTerminalView { } } -extension LitheTerminalView: WorkbenchBackgroundRendering {} /// Report pushed when the background changes: SwiftTerm's own OSC 11 answer plus the kitty DSR 997 notification. enum MacTerminalBackgroundReporting { @@ -444,7 +443,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } view.font = Self.preferredTerminalFont() view.preferredAppearanceIsDark = preferredAppearanceIsDark - view.applyThemeColors() + view.applyThemeColors(theme: LitheTheme.activeTheme) view.allowMouseReporting = true view.linkReporting = .implicit view.linkHighlightMode = .hoverWithModifier diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift index d398beb4f9..a12414d778 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift @@ -4,7 +4,9 @@ import LitheTerminalModule struct TerminalSurfaceView: View { @ObservedObject var session: TerminalSession + @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings @State private var focusRequestID = 0 var body: some View { @@ -13,6 +15,8 @@ struct TerminalSurfaceView: View { TerminalNativeSurface( nativeView: nativeView, focusRequestID: focusRequestID, + theme: settings.colorTheme, + isDark: colorScheme == .dark, showsWorkbenchBackground: model.workbenchBackgroundFeature.hasImage ) } else { @@ -71,24 +75,38 @@ struct TerminalSurfaceView: View { private struct TerminalNativeSurface: NSViewRepresentable { let nativeView: NSView let focusRequestID: Int + let theme: AppColorTheme + let isDark: Bool let showsWorkbenchBackground: Bool - func makeCoordinator() -> Coordinator { - Coordinator() - } - + func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> TerminalSurfaceHostView { let hostView = TerminalSurfaceHostView(frame: .zero) hostView.attach(nativeView) - (nativeView as? WorkbenchBackgroundRendering)? - .setWorkbenchBackgroundVisible(showsWorkbenchBackground) + (nativeView as? LitheTerminalView)?.updateWorkbenchSurface( + isVisible: showsWorkbenchBackground, + theme: theme, + isDark: isDark + ) + context.coordinator.lastTheme = theme + context.coordinator.lastIsDark = isDark + context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground return hostView } func updateNSView(_ hostView: TerminalSurfaceHostView, context: Context) { hostView.attach(nativeView) - (nativeView as? WorkbenchBackgroundRendering)? - .setWorkbenchBackgroundVisible(showsWorkbenchBackground) + if context.coordinator.lastTheme != theme || context.coordinator.lastIsDark != isDark + || context.coordinator.lastShowsWorkbenchBackground != showsWorkbenchBackground { + (nativeView as? LitheTerminalView)?.updateWorkbenchSurface( + isVisible: showsWorkbenchBackground, + theme: theme, + isDark: isDark + ) + context.coordinator.lastTheme = theme + context.coordinator.lastIsDark = isDark + context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground + } guard context.coordinator.lastFocusRequestID != focusRequestID else { return } context.coordinator.lastFocusRequestID = focusRequestID DispatchQueue.main.async { @@ -106,9 +124,13 @@ private struct TerminalNativeSurface: NSViewRepresentable { final class Coordinator: NSObject { var lastFocusRequestID = -1 + var lastTheme: AppColorTheme? + var lastIsDark: Bool? + var lastShowsWorkbenchBackground: Bool? } } + private final class TerminalSurfaceHostView: NSView { private weak var terminalView: NSView? diff --git a/macos/Tests/LitheTests/BundledUIFontTests.swift b/macos/Tests/LitheTests/BundledUIFontTests.swift index 7be0ace3d5..bba98f18a9 100644 --- a/macos/Tests/LitheTests/BundledUIFontTests.swift +++ b/macos/Tests/LitheTests/BundledUIFontTests.swift @@ -90,7 +90,7 @@ struct BundledUIFontTests { let prompt = "\u{1b}[38;2;154;52;142m\u{e0b6}\u{1b}[48;2;154;52;142m\u{1b}[37muser \u{1b}[38;2;154;52;142m\u{1b}[48;2;218;98;125m\u{e0b0}\u{1b}[37m …/Lithe-IDEA \u{1b}[38;2;218;98;125m\u{1b}[48;2;252;161;125m\u{e0b0}\u{1b}[37m \u{f418} codex/issue-1082 $ \u{1b}[38;2;252;161;125m\u{1b}[48;2;134;187;216m\u{e0b0}\u{1b}[37m \u{e76f} v1.3.12 \u{1b}[38;2;134;187;216m\u{1b}[48;2;51;101;138m\u{e0b0}\u{1b}[37m ♥ 01:23 \u{1b}[0m\u{1b}[38;2;51;101;138m\u{e0b0}\u{1b}[0m\r\n" for dark in [false, true] { terminal.view.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) - terminal.view.applyThemeColors() + terminal.view.applyThemeColors(theme: .lithe, isDark: dark) terminal.view.feed(text: "\u{1b}[2J\u{1b}[H" + prompt + prompt) let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1440, pixelsHigh: 300, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, diff --git a/macos/Tests/LitheTests/TerminalInputFocusTests.swift b/macos/Tests/LitheTests/TerminalInputFocusTests.swift index ab42f79fd2..e28c76806f 100644 --- a/macos/Tests/LitheTests/TerminalInputFocusTests.swift +++ b/macos/Tests/LitheTests/TerminalInputFocusTests.swift @@ -1,9 +1,11 @@ import AppKit +import SwiftUI import MetalKit import os import SwiftTerm import Testing @testable import Lithe +import LitheTerminalModule @Suite("Terminal input focus") @MainActor @@ -36,6 +38,20 @@ struct TerminalInputFocusTests { #expect(missing.ansiColors.allSatisfy { $0 == nil }) } + @Test + func terminalPaletteRefreshesOnThemeChange() { + let terminal = LitheTerminalView(frame: .zero) + terminal.applyThemeColors(theme: .lithe, isDark: true) + let litheBackground = terminal.nativeBackgroundColor + let litheTerminalBackground = terminal.terminal.backgroundColor + + terminal.applyThemeColors(theme: .codex, isDark: true) + + #expect(terminal.nativeBackgroundColor != litheBackground) + #expect(terminal.terminal.backgroundColor != litheTerminalBackground) + #expect(terminal.nativeBackgroundColor == LitheTheme.nsColor(.editor, theme: .codex, isDark: true)) + } + @Test func terminalPaletteAppliesContrastGatePerImportedEntry() throws { func archive(_ color: NSColor) throws -> Data { @@ -315,29 +331,38 @@ struct TerminalInputFocusTests { } } -@Suite("Terminal canvas compositing") +@Suite("Terminal canvas compositing", .serialized) @MainActor struct TerminalCanvasCompositingTests { + @Test + func unmountedTerminalUsesAppAppearancePreference() { + let terminal = LitheTerminalView(frame: .zero) + terminal.appearance = NSAppearance(named: .darkAqua) + terminal.preferredAppearanceIsDark = { false } + + terminal.applyThemeColors(theme: .lithe) + + let expected = LitheTheme.nsColor(.editor, theme: .lithe, isDark: false) + #expect(terminal.window == nil) + #expect(terminal.nativeBackgroundColor.isEqual(expected)) + } + @Test func canvasLeavesSharedPaneBackgroundVisibleWithWallpaper() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) - terminal.appearance = NSAppearance(named: .aqua) - terminal.setWorkbenchBackgroundVisible(true) - terminal.applyThemeColors() + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 0) // Metal clear colors are premultiplied, so a transparent canvas clears to black rather than the workbench color. let clearColor = terminal.nativeBackgroundColor.usingColorSpace(.sRGB) #expect(clearColor?.redComponent == 0) #expect(clearColor?.greenComponent == 0) #expect(clearColor?.blueComponent == 0) - // The layer must stay clear as well: the Core Graphics fallback paints its background from this layer. #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: false) #expect(renderer.layer?.isOpaque == false) - // Focus changes repaint the paused Metal surface, so the decision must survive them too. terminal.hasFocus = false #expect(renderer.layer?.isOpaque == false) } @@ -347,7 +372,7 @@ struct TerminalCanvasCompositingTests { for name in [NSAppearance.Name.aqua, .darkAqua] { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: name) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: name == .darkAqua) let editorColor = LitheTheme.nsColor(.editor, isDark: name == .darkAqua) #expect(terminal.nativeBackgroundColor.alphaComponent == 1) #expect(terminal.nativeBackgroundColor.isEqual(editorColor)) @@ -355,7 +380,7 @@ struct TerminalCanvasCompositingTests { #expect(terminal.layer?.isOpaque == true) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: name == .darkAqua) #expect(renderer.layer?.isOpaque == true) } } @@ -364,18 +389,16 @@ struct TerminalCanvasCompositingTests { func canvasFollowsWorkbenchBackgroundChanges() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: .aqua) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: false) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - - terminal.setWorkbenchBackgroundVisible(true) + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 0) #expect(terminal.nativeBackgroundColor.usingColorSpace(.sRGB)?.redComponent == 0) #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) #expect(renderer.layer?.isOpaque == false) - - terminal.setWorkbenchBackgroundVisible(false) + terminal.updateWorkbenchSurface(isVisible: false, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 1) #expect(terminal.layer?.isOpaque == true) #expect(renderer.layer?.isOpaque == true) @@ -383,24 +406,124 @@ struct TerminalCanvasCompositingTests { @Test func osc11ReportedBackgroundMatchesCanvasClearColor() throws { - // SwiftTerm answers OSC 11 from the terminal background color, which mirrors the canvas clear color. - // Reporting black while a wallpaper is visible is the documented tradeoff of the premultiplied clear. let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: .aqua) - terminal.applyThemeColors() - let editorColor = LitheTheme.nsColor(.editor, isDark: false).usingColorSpace(.sRGB) + terminal.applyThemeColors(theme: .lithe, isDark: false) + let editorColor = LitheTheme.nsColor(.editor, theme: .lithe, isDark: false).usingColorSpace(.sRGB) let expected = try #require(editorColor) let reported = terminal.terminal.backgroundColor #expect(reported.red == UInt16((expected.redComponent * 65535).rounded())) #expect(reported.green == UInt16((expected.greenComponent * 65535).rounded())) #expect(reported.blue == UInt16((expected.blueComponent * 65535).rounded())) - - terminal.setWorkbenchBackgroundVisible(true) + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) let transparent = terminal.terminal.backgroundColor #expect(transparent.red == 0) #expect(transparent.green == 0) #expect(transparent.blue == 0) } + + @Test + func mountedTerminalRefreshesTheSameSurfaceInDarkAppearance() async throws { + try await assertMountedTerminalThemeRefresh(scheme: .dark) + } + + @Test + func mountedTerminalRefreshesTheSameSurfaceInLightAppearance() async throws { + try await assertMountedTerminalThemeRefresh(scheme: .light) + } + + private func assertMountedTerminalThemeRefresh(scheme: ColorScheme) async throws { + let domain = "Lithe.TerminalThemeRefresh.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + let originalTheme = AppThemeRuntime.shared.activeTheme + defer { + defaults.removePersistentDomain(forName: domain) + AppThemeRuntime.shared.activate(originalTheme) + } + let isDark = scheme == .dark + let store = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: store) + settings.themePreference = isDark ? .dark : .light + settings.colorTheme = .lithe + let model = AppModel(settings: settings, + services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) + let terminal = LitheTerminalView(frame: .zero) + let transport = ThemeRefreshTestTransport(view: terminal) + let session = TerminalSession(transport: transport) + session.start(in: URL(fileURLWithPath: "/workspace/theme-refresh"), shellPath: "/bin/zsh") + let host = NSHostingView(rootView: TerminalSurfaceView(session: session) + .environmentObject(model).environmentObject(settings).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua) + window.contentView = host + let initialBackground = LitheTheme.nsColor(.editor, theme: .lithe, isDark: isDark) + let initialForeground = LitheTheme.nsColor(.primaryText, theme: .lithe, isDark: isDark) + let initialDeadline = ContinuousClock().now.advanced(by: .seconds(2)) + while (!terminal.nativeBackgroundColor.isEqual(initialBackground) + || !terminal.nativeForegroundColor.isEqual(initialForeground)), + ContinuousClock().now < initialDeadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + #expect(terminal.nativeBackgroundColor.isEqual(initialBackground)) + #expect(terminal.nativeForegroundColor.isEqual(initialForeground)) + #expect(session.isRunning) + let clock = ContinuousClock() + for theme in [AppColorTheme.codex, .linear, .lithe] { + settings.colorTheme = theme + let expected = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark) + let expectedForeground = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark) + let deadline = clock.now.advanced(by: .seconds(2)) + while (!terminal.nativeBackgroundColor.isEqual(expected) + || !terminal.nativeForegroundColor.isEqual(expectedForeground)), + clock.now < deadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + let sRGB = try #require(expected.usingColorSpace(.sRGB)) + let reported = terminal.terminal.backgroundColor + let reportedForeground = terminal.terminal.foregroundColor + let expectedForegroundSRGB = try #require(expectedForeground.usingColorSpace(.sRGB)) + #expect(terminal.nativeBackgroundColor.isEqual(expected)) + #expect(terminal.nativeForegroundColor.isEqual(expectedForeground)) + #expect(reported.red == UInt16(sRGB.redComponent * 65535)) + #expect(reported.green == UInt16(sRGB.greenComponent * 65535)) + #expect(reported.blue == UInt16(sRGB.blueComponent * 65535)) + #expect(abs(Int(reportedForeground.red) - Int(expectedForegroundSRGB.redComponent * 65535)) <= 1) + #expect(abs(Int(reportedForeground.green) - Int(expectedForegroundSRGB.greenComponent * 65535)) <= 1) + #expect(abs(Int(reportedForeground.blue) - Int(expectedForegroundSRGB.blueComponent * 65535)) <= 1) + #expect(session.isRunning) + } + session.stop() + window.contentView = nil + window.close() + await model.shutdownProjectSession() + } +} +@MainActor +private final class ThemeRefreshTestTransport: TerminalTransport { + let nativeView: AnyObject + var isRunning = false + var processID: Int32? { isRunning ? 1234 : nil } + var shellName = "zsh" + var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? + var onTitle: ((String) -> Void)? + var onDirectoryUpdate: ((String?) -> Void)? + var onLink: ((String, [String: String]) -> Void)? + + init(view: LitheTerminalView) { nativeView = view } + func defaultShellPath() -> String { "/bin/zsh" } + func defaultEnvironment() -> [String: String] { [:] } + func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true } + func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 { isRunning = true; return 1234 } + func send(_ input: Data) throws {} + func interrupt() throws {} + func focus() {} + func clear() {} + func stop() { isRunning = false } } @MainActor diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 0291ab1c58..94f7e27567 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -259,7 +259,8 @@ struct TerminalPlacementFeatureModelTests { let feature = try #require(model.terminalFeature) let store = WorkbenchToolContentStore() let host = NSHostingView(rootView: RetainedTerminalReviewHost(state: state, store: store, - model: model, feature: feature).environment(\.colorScheme, scheme)) + model: model, feature: feature).environmentObject(model).environmentObject(model.settings) + .environment(\.colorScheme, scheme)) host.sizingOptions = [] host.frame = NSRect(x: 0, y: 0, width: 480, height: 400) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), @@ -343,9 +344,9 @@ struct TerminalPlacementFeatureModelTests { model.selectTerminalSession(b) let store = WorkbenchToolContentStore() let host = NSHostingView(rootView: VStack(spacing: 0) { - TerminalSurfaceView(session: c).environmentObject(model).frame(height: 160) + TerminalSurfaceView(session: c).environmentObject(model).environmentObject(model.settings).frame(height: 160) RetainedTerminalReviewHost(state: state, store: store, model: model, feature: feature) - }) + }.environmentObject(model).environmentObject(model.settings)) host.sizingOptions = [] let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), styleMask: [.borderless], backing: .buffered, defer: false) @@ -558,7 +559,7 @@ private struct RetainedTerminalReviewHost: View { Color(nsColor: NSColor(srgbRed: 0.8, green: 0.1, blue: 0.6, alpha: 1)) if state.visible { RetainedWorkbenchToolContent(store: store, tool: .terminal, - content: TerminalView(feature: feature).environmentObject(model), + content: TerminalView(feature: feature).environmentObject(model).environmentObject(model.settings), onActivate: { model.activeToolTerminalSession?.focus() }) .environment(\.workbenchHasBackgroundImage, state.hasImage) } diff --git a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json index ecc9f0dfe3..a69940b2ba 100644 --- a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json +++ b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json @@ -2,12 +2,13 @@ "id": "terminal-canvas-compositing", "area": "工作台", "group": "终端", - "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色),背景实际变化时向终端应用推送 OSC 11 报告与 kitty 变更通知 DSR 997", + "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色);已打开终端随 Color theme 变化刷新背景和前景及 OSC 11/10 应答;背景实际变化时向终端应用推送 OSC 11 报告与 kitty 变更通知 DSR 997", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift", + "macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift", "macos/Tests/LitheTests/TerminalInputFocusTests.swift", "macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift" ] @@ -20,5 +21,5 @@ ] }, "owner": "Terminal", - "verification": "亮色主题无壁纸时确认终端字缘无白色小框、正文不发浅,画布不透明且底色随明暗外观切换;开启壁纸后确认壁纸正常透出且字缘干净。OSC 11 探针无壁纸应答编辑器底色(亮色 rgb:ffff/ffff/ffff),有壁纸应答 rgb:0000/0000/0000。明暗切换或开关壁纸时,已运行且跟踪背景的 TUI(如 oh-my-pi)应无需重启即跟随重查;不跟踪的程序不受推送影响。已知取舍:SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,有壁纸时报告黑色;若上游改为预乘清屏可恢复报告编辑器底色。" + "verification": "无壁纸时分别使用 Dark+Lithe、Dark+Codex、Dark+Linear,在同一已打开终端切换 Color theme 后查询 OSC 11,确认分别报告 rgb:1919/1a1a/1c1c、rgb:1111/1111/1111、rgb:0f0f/0f0f/1111;Light 模式分别查询 OSC 11 和 OSC 10,确认 Lithe 与 Codex 的 OSC 11 都为白色、Linear 为 rgb:fcfc/fcfc/fdfd,OSC 10 可区分 Lithe rgb:1f3b/2312/2937、Codex rgb:0d0d/0d0d/0d0d、Linear rgb:1b1b/1b1b/1b1b。Dark OSC 10 前景依次应为 Lithe rgb:dfff/e1c9/e5e2、Codex rgb:fcfc/fcfc/fcfc、Linear rgb:e3e3/e4e4/e6e6。切换主题后 shell PID 不变;开启壁纸后确认壁纸透出、字缘干净且 OSC 11 报告 rgb:0000/0000/0000。明暗切换或开关壁纸时,已运行且跟踪背景的 TUI(如 oh-my-pi)应无需重启即跟随重查;不跟踪的程序不受推送影响。亮色主题下同时确认字缘无白色小框、正文不发浅。SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,因此有壁纸时按既有取舍报告黑色;若上游改为预乘清屏可恢复报告编辑器底色。" } From 2d2d09801db6526efefbd105b49473cc1255618b Mon Sep 17 00:00:00 2001 From: icefery Date: Thu, 22 Oct 2026 10:46:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(macos):=20=E7=BB=88=E7=AB=AF=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=AF=BC=E5=87=BA=20COLORFGBG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `COLORFGBG` 是 Terminal.app、iTerm2、tmux 等终端的行业惯例环境变量;omp 等 TUI 在进程启动时同步读取其 bg 字段(<8 深、>=8 浅)选择配色,先于任何 OSC 往返,可消除首帧按回退深色渲染的闪变。 - Lithe 终端此前未导出该变量;且终端视图加入窗口前 effectiveAppearance 跟随系统外观,直接读它会与 Lithe 主题不一致。 - `TerminalTransport` 协议新增 `reportsLightBackground: Bool?`(宿主是否渲染浅色背景,无法判定时为 nil),extension 默认 nil - `TerminalSession` 以可选值直赋导出 `COLORFGBG`(浅色 `0;15`、深色 `15;0`;fg;bg 惯例,判读位是 bg 字段),宿主无法判定时同时清除从外层终端继承的同名值 - macOS 实现按应用主题偏好解析:视图未挂载窗口时用主题 pin 的明暗,挂载后用窗口外观 - `TerminalModuleTests` 新增 4 个用例(浅色、深色、无法判定不写入、清除继承值);更新 `terminal-shell.json` 能力矩阵 - `TerminalModuleTests` 全绿(含 4 个新增) - 平台能力矩阵门禁通过 - 真机浅色/深色首帧配色正确,无闪变;`echo $COLORFGBG` 输出 `0;15` / `15;0` --- macos/Sources/Lithe/LitheApp.swift | 1 + .../Models/AppModel/AppModel+Terminal.swift | 8 ++- .../MacOS/Terminal/MacTerminalTransport.swift | 20 ++++++ .../Application/TerminalFeatureModel.swift | 12 +++- .../Ports/TerminalTransport.swift | 11 +++ .../Runtime/TerminalSession.swift | 3 + .../TerminalModuleTests.swift | 67 +++++++++++++++++++ .../LitheTests/TerminalInputFocusTests.swift | 36 ++++++++++ .../features/terminal-shell.json | 2 +- 9 files changed, 154 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 6c18180399..9b22acf939 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -1313,6 +1313,7 @@ extension AppThemePreference { case .dark: true } } + var preferredColorScheme: ColorScheme? { switch self { case .system: nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index e16e83895d..256cb8c787 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -61,7 +61,8 @@ extension AppModel { let session = feature.createSession( in: workspaceURL, shellPath: shellPath ?? settings.terminalShellPath, - preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent } + preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent }, + showsWorkbenchBackground: workbenchBackgroundFeature.hasImage ) configureTerminalSession(session) terminalPlacementFeature.registerSession(session.id) @@ -131,7 +132,10 @@ extension AppModel { TerminalEnvironmentChange(name: $0.name, value: $0.value) } ) - let created = try feature.createProcessSession(launch) { [weak self] output in + let created = try feature.createProcessSession( + launch, + showsWorkbenchBackground: workbenchBackgroundFeature.hasImage + ) { [weak self] output in self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output) } configureTerminalSession(created.session) diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 72e2d47299..7bf8d80378 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -18,6 +18,14 @@ final class LitheTerminalView: LocalProcessTerminalView { var preferredAppearanceIsDark: (() -> Bool?)? private var lastReportedBackgroundPayload: String? + /// Whether the background the host reports is light: the app theme while outside any window, then the window's appearance. + var rendersLightBackground: Bool { + if window == nil, let preferredAppearanceIsDark, let pinned = preferredAppearanceIsDark() { + return !pinned + } + return effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) != .darkAqua + } + override init(frame frameRect: NSRect) { super.init(frame: frameRect) configureRendererPolicy() @@ -221,6 +229,12 @@ final class LitheTerminalView: LocalProcessTerminalView { guard process.running else { return } terminal.sendResponse(text: MacTerminalBackgroundReporting.changeNotification(for: nativeBackgroundColor)) } + + func updateWorkbenchSurface(isVisible: Bool) { + showsWorkbenchBackground = isVisible + applyThemeColors(theme: LitheTheme.activeTheme) + } + func updateWorkbenchSurface(isVisible: Bool, theme: AppColorTheme, isDark: Bool) { showsWorkbenchBackground = isVisible applyThemeColors(theme: theme, isDark: isDark) @@ -427,6 +441,12 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L var nativeView: AnyObject { view } + func configureInitialSurface(showsWorkbenchBackground: Bool) { + view.updateWorkbenchSurface(isVisible: showsWorkbenchBackground) + } + + var reportsLightBackground: Bool? { view.rendersLightBackground } + private let scrollbackLines: Int init(scrollbackLines: Int = 2_000, preferredAppearanceIsDark: (() -> Bool?)? = nil) { diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index b7de6ac4f4..41267cb7f3 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -48,9 +48,12 @@ public final class TerminalFeatureModel: ObservableObject { public func createSession( in workspaceURL: URL, shellPath: String? = nil, - preferredToolTabTitle: String? = nil + preferredToolTabTitle: String? = nil, + showsWorkbenchBackground: Bool = false ) -> TerminalSession { - let session = TerminalSession(transport: terminalFactory(), preferredToolTabTitle: preferredToolTabTitle) + let transport = terminalFactory() + transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + let session = TerminalSession(transport: transport, preferredToolTabTitle: preferredToolTabTitle) session.start(in: workspaceURL, shellPath: shellPath) terminalSessions.append(session) activeTerminalSessionID = session.id @@ -60,9 +63,12 @@ public final class TerminalFeatureModel: ObservableObject { @discardableResult public func createProcessSession( _ launch: TerminalProcessLaunch, + showsWorkbenchBackground: Bool = false, onOutput: ((String) -> Void)? = nil ) throws -> (session: TerminalSession, processID: Int32) { - let session = TerminalSession(transport: terminalFactory()) + let transport = terminalFactory() + transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + let session = TerminalSession(transport: transport) session.onOutput = onOutput let processID = try session.startProcess(launch) terminalSessions.append(session) diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift index d928618cb3..3e4c7eb30b 100644 --- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift +++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -70,6 +70,14 @@ public protocol TerminalTransport: AnyObject { var onTitle: ((String) -> Void)? { get set } var onDirectoryUpdate: ((String?) -> Void)? { get set } var onLink: ((String, [String: String]) -> Void)? { get set } + /// Whether the host terminal renders a light background, advertised through `COLORFGBG` + /// so TUIs pick a palette without querying; hosts that cannot tell keep it unset. + var reportsLightBackground: Bool? { get } + + /// Applies the host surface state to the native view before the shell + /// starts, so the exported `COLORFGBG` matches the OSC 11 answer the + /// mounted view will report. Hosts without wallpaper keep it a no-op. + func configureInitialSurface(showsWorkbenchBackground: Bool) func defaultShellPath() -> String func defaultEnvironment() -> [String: String] @@ -94,4 +102,7 @@ public extension TerminalTransport { var termProgramVersion: String? { nil } func stopAndWait() async { stop() } func runningPrograms() -> [TerminalRunningProgram] { [] } + var reportsLightBackground: Bool? { nil } + /// Hosts without wallpaper keep the initial surface configuration a no-op. + func configureInitialSurface(showsWorkbenchBackground: Bool) {} } diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift index 9f06b088f4..a33fbcbdd0 100644 --- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift +++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -180,6 +180,9 @@ public final class TerminalSession: ObservableObject, Identifiable { environment["TERM_PROGRAM"] = "Lithe" // Direct assignment drops any value inherited from the outer terminal when the transport cannot report one. environment["TERM_PROGRAM_VERSION"] = transport.termProgramVersion + // fg;bg per the xterm convention; consumers like oh-my-pi read the bg field (<8 dark, >=8 light). + // Direct assignment drops any value inherited from the outer terminal when the host cannot report one. + environment["COLORFGBG"] = transport.reportsLightBackground.map { $0 ? "0;15" : "15;0" } for change in changes { if let value = change.value { environment[change.name] = value diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index 002cdf1b7b..d609c64e4f 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -117,6 +117,69 @@ struct TerminalModuleTests { #expect(transport.stopCount == 1) } + @Test + func interactiveShellEnvironmentExportsColorFgbgForLightBackground() throws { + let transport = TestTransport() + transport.reportsLightBackground = true + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == "0;15") + } + + @Test + func interactiveShellEnvironmentExportsColorFgbgForDarkBackground() throws { + let transport = TestTransport() + transport.reportsLightBackground = false + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == "15;0") + } + + @Test + func interactiveShellEnvironmentOmitsColorFgbgWhenHostCannotTell() throws { + let transport = TestTransport() + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == nil) + } + + @Test + func createSessionAppliesWallpaperStateBeforeSpawn() throws { + let transport = TestTransport() + transport.reportsLightBackground = true + let feature = TerminalFeatureModel(terminalFactory: { transport }) + defer { feature.stopAllSessions() } + let session = feature.createSession( + in: URL(fileURLWithPath: "/workspace"), + shellPath: "/bin/zsh", + showsWorkbenchBackground: true + ) + defer { session.stop() } + + #expect(transport.shellEnvironments.first?["COLORFGBG"] == "15;0") + } + + @Test + func interactiveShellEnvironmentClearsInheritedColorFgbgWhenHostCannotTell() throws { + let transport = TestTransport() + transport.inheritedEnvironment["COLORFGBG"] = "7;0" + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == nil) + } + @Test func managedProcessForwardsInputToItsOwnPTY() throws { let transport = TestTransport() @@ -241,6 +304,7 @@ private final class TestTransport: TerminalTransport { var termProgramVersion: String? var shellStarts: [String] = [] var shellEnvironments: [[String: String]] = [] + var reportsLightBackground: Bool? var inheritedEnvironment: [String: String] = ["REMOVE_ME": "old"] var startError: Error? var onTermination: ((Int32?) -> Void)? @@ -253,6 +317,9 @@ private final class TestTransport: TerminalTransport { var processEnvironments: [[String: String]] = [] var sentInputs: [String] = [] var outputOnStart: String? + func configureInitialSurface(showsWorkbenchBackground: Bool) { + if showsWorkbenchBackground { reportsLightBackground = false } + } func defaultShellPath() -> String { "/bin/zsh" } func defaultEnvironment() -> [String: String] { inheritedEnvironment } func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { diff --git a/macos/Tests/LitheTests/TerminalInputFocusTests.swift b/macos/Tests/LitheTests/TerminalInputFocusTests.swift index e28c76806f..a5970482fb 100644 --- a/macos/Tests/LitheTests/TerminalInputFocusTests.swift +++ b/macos/Tests/LitheTests/TerminalInputFocusTests.swift @@ -570,3 +570,39 @@ private final class CursorDrawingRecorder: NSObject, MTKViewDelegate { private final class TerminalOtherResponderView: NSView { override var acceptsFirstResponder: Bool { true } } + +/// Appearance resolution behind the exported COLORFGBG and the OSC 11 answer. +@Suite("MacOS appearance resolution") +@MainActor +struct MacTerminalAppearanceResolutionTests { + @Test + func themePreferenceOverridesSystemAppearanceBeforeMount() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { true }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == false) + } + + @Test + func effectiveAppearanceUsedWhenNoPreference() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { nil }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == true) + } + + @Test + func wallpaperConfiguredBeforeSpawnReportsDarkAndAnswersBlack() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { nil }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == true) + transport.configureInitialSurface(showsWorkbenchBackground: true) + #expect(transport.reportsLightBackground == false) + // The canvas is transparent; OSC 11 answers this same black, so the env export stays consistent. + #expect(view.nativeBackgroundColor == NSColor.black.withAlphaComponent(0)) + } +} diff --git a/shared/platform-feature-matrix/features/terminal-shell.json b/shared/platform-feature-matrix/features/terminal-shell.json index 767c980f94..c1eb9acdbf 100644 --- a/shared/platform-feature-matrix/features/terminal-shell.json +++ b/shared/platform-feature-matrix/features/terminal-shell.json @@ -30,5 +30,5 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效;macOS 新建终端下拉框复用 Project 下拉框的共享菜单样式,菜单操作与原有 Shell 选择、重新检测功能一致;验证环境变量、工作目录和不可用 Shell 的提示。 设置页终端分组新增回滚行数配置(默认 2000,范围 100–50000,步进 500),读取、写入、恢复默认均做上下限夹取,对新建会话生效(TerminalScrollbackSettingsTests);设置搜索按界面名称 Terminal scrollback/终端回滚行数/行数 均可命中终端页(SettingsSearchVocabularyTests)。 macOS 终端环境随 TERM_PROGRAM=Lithe 一并导出 TERM_PROGRAM_VERSION(取应用打包版本,无法取得时不写入),与 Windows 端一致,供工具按版本门控终端能力(TerminalModuleTests)。" + "verification": "验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效;macOS 新建终端下拉框复用 Project 下拉框的共享菜单样式,菜单操作与原有 Shell 选择、重新检测功能一致;验证环境变量、工作目录和不可用 Shell 的提示。 设置页终端分组新增回滚行数配置(默认 2000,范围 100–50000,步进 500),读取、写入、恢复默认均做上下限夹取,对新建会话生效(TerminalScrollbackSettingsTests);设置搜索按界面名称 Terminal scrollback/终端回滚行数/行数 均可命中终端页(SettingsSearchVocabularyTests)。 macOS 终端环境随 TERM_PROGRAM=Lithe 一并导出 TERM_PROGRAM_VERSION(取应用打包版本,无法取得时不写入),与 Windows 端一致,供工具按版本门控终端能力(TerminalModuleTests);按当前外观导出 COLORFGBG(浅色 0;15、深色 15;0,宿主无法判定时不写入),TUI 无需 OSC 查询即可选择配色(TerminalModuleTests)。" } From c293f351cd20a58c7920ae830f41e7f7c37dd7e5 Mon Sep 17 00:00:00 2001 From: icefery Date: Fri, 23 Oct 2026 16:27:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(macos):=20=E8=83=8C=E6=99=AF=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=BB=85=E6=8E=A8=E9=80=81=E7=BB=99=E8=AE=A2=E9=98=85?= =?UTF-8?q?=20kitty=20DEC=20mode=202031=20=E7=9A=84=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 背景变化通知由无条件推送改为按订阅发送: 解析进程输出中的 CSI ? 2031 h/l (含组合 DEC mode 与跨块分片), 未订阅的 shell 不再收到响应字节 - DSR 997 按背景亮度如实报告: 997;1n 暗色、997;2n 亮色 - 新进程启动与终端 RIS 重置清除订阅状态 - 单测 15 用例: 通知编码门控与明暗状态 + 订阅解析全路径 --- .../MacOS/Terminal/MacTerminalTransport.swift | 125 +++++++++++++++--- .../MacTerminalBackgroundReportingTests.swift | 125 +++++++++++++++++- .../features/terminal-canvas-compositing.json | 4 +- 3 files changed, 234 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 7bf8d80378..a518205334 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -17,14 +17,7 @@ final class LitheTerminalView: LocalProcessTerminalView { /// App theme light/dark preference while the view is not yet in a window; nil follows the system appearance. var preferredAppearanceIsDark: (() -> Bool?)? private var lastReportedBackgroundPayload: String? - - /// Whether the background the host reports is light: the app theme while outside any window, then the window's appearance. - var rendersLightBackground: Bool { - if window == nil, let preferredAppearanceIsDark, let pinned = preferredAppearanceIsDark() { - return !pinned - } - return effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) != .darkAqua - } + private var backgroundChangeNotifications = MacTerminalBackgroundSubscription() override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -221,13 +214,17 @@ final class LitheTerminalView: LocalProcessTerminalView { reportBackgroundChangeIfNeeded() } - /// On reported-background change, sends the OSC 11 answer plus kitty DSR 997; trackers re-query, others ignore. + /// Reports background changes only to programs subscribed to kitty DEC mode 2031. private func reportBackgroundChangeIfNeeded() { let payload = MacTerminalBackgroundReporting.osc11Payload(for: nativeBackgroundColor) guard payload != lastReportedBackgroundPayload else { return } lastReportedBackgroundPayload = payload - guard process.running else { return } - terminal.sendResponse(text: MacTerminalBackgroundReporting.changeNotification(for: nativeBackgroundColor)) + guard process.running, + let notification = MacTerminalBackgroundReporting.changeNotification( + for: nativeBackgroundColor, + isSubscribed: backgroundChangeNotifications.isSubscribed + ) else { return } + terminal.sendResponse(text: notification) } func updateWorkbenchSurface(isVisible: Bool) { @@ -246,17 +243,22 @@ final class LitheTerminalView: LocalProcessTerminalView { } override func dataReceived(slice: ArraySlice) { + backgroundChangeNotifications.feed(slice) onProcessOutput?(Data(slice)) super.dataReceived(slice: slice) } + func resetBackgroundChangeNotifications() { + backgroundChangeNotifications.reset() + } + deinit { NotificationCenter.default.removeObserver(self) } } -/// Report pushed when the background changes: SwiftTerm's own OSC 11 answer plus the kitty DSR 997 notification. +/// Reports changed backgrounds to subscribers; kitty DSR 997 uses 1 for dark and 2 for light. enum MacTerminalBackgroundReporting { static func osc11Payload(for color: NSColor) -> String { let srgb = color.usingColorSpace(.sRGB) ?? color @@ -268,8 +270,99 @@ enum MacTerminalBackgroundReporting { return "rgb:\(channel(red))/\(channel(green))/\(channel(blue))" } - static func changeNotification(for color: NSColor) -> String { - "\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;2n" + static func reportsDarkBackground(for color: NSColor) -> Bool { + let srgb = color.usingColorSpace(.sRGB) ?? color + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 1 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + func linearize(_ component: CGFloat) -> Double { + let value = min(1, max(0, Double(component))) + return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + let luminance = 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue) + return luminance < 0.5 + } + + static func changeNotification(for color: NSColor, isSubscribed: Bool) -> String? { + guard isSubscribed else { return nil } + let backgroundState = reportsDarkBackground(for: color) ? 1 : 2 + return "\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;\(backgroundState)n" + } +} + +/// Tracks kitty DEC mode 2031 so change notifications are sent only to programs that requested them. +struct MacTerminalBackgroundSubscription { + private(set) var isSubscribed = false + private var pendingEscape: [UInt8] = [] + + private static let escape = UInt8(0x1b) + private static let mode2031: [UInt8] = [0x32, 0x30, 0x33, 0x31] + private static let altScreenModes: [[UInt8]] = [ + [0x34, 0x37], + [0x31, 0x30, 0x34, 0x37], + [0x31, 0x30, 0x34, 0x39], + ] + private static let maximumSequenceLength = 64 + + init() { + pendingEscape.reserveCapacity(Self.maximumSequenceLength) + } + + mutating func feed(_ bytes: S) where S.Element == UInt8 { + for byte in bytes { + if pendingEscape.isEmpty { + if byte == Self.escape { pendingEscape.append(byte) } + continue + } + if byte == Self.escape { + pendingEscape = [Self.escape] + continue + } + pendingEscape.append(byte) + if pendingEscape == [Self.escape, 0x63] { + isSubscribed = false + pendingEscape.removeAll(keepingCapacity: true) + continue + } + if pendingEscape.count == 2 { + if byte != 0x5b { pendingEscape.removeAll(keepingCapacity: true) } + continue + } + if pendingEscape.count == 3 { + if byte != 0x3f { pendingEscape.removeAll(keepingCapacity: true) } + continue + } + if (0x40...0x7e).contains(byte) { + if byte == 0x68 || byte == 0x6c, + contains(Self.mode2031, in: pendingEscape[3..<(pendingEscape.count - 1)]) { + isSubscribed = byte == 0x68 + } else if byte == 0x6c, + leavesAltScreen(pendingEscape[3..<(pendingEscape.count - 1)]) { + // A subscribed TUI runs inside the alternate screen; when it leaves, the shell must not inherit pushes. + isSubscribed = false + } + pendingEscape.removeAll(keepingCapacity: true) + } else if pendingEscape.count >= Self.maximumSequenceLength { + pendingEscape.removeAll(keepingCapacity: true) + } + } + } + + mutating func reset() { + isSubscribed = false + pendingEscape.removeAll(keepingCapacity: true) + } + + private func contains(_ parameter: [UInt8], in parameters: ArraySlice) -> Bool { + var start = parameters.startIndex + for index in parameters.indices where parameters[index] == 0x3b { + if parameters[start..) -> Bool { + Self.altScreenModes.contains { contains($0, in: parameters) } } } @@ -445,7 +538,8 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L view.updateWorkbenchSurface(isVisible: showsWorkbenchBackground) } - var reportsLightBackground: Bool? { view.rendersLightBackground } + // Keep the exported COLORFGBG in sync with the OSC 11 answer: wallpaper transparency reports black, so it must export dark. + var reportsLightBackground: Bool? { !MacTerminalBackgroundReporting.reportsDarkBackground(for: view.nativeBackgroundColor) } private let scrollbackLines: Int @@ -545,6 +639,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L selectedShellPath = executablePath view.terminal.resetToInitialState() + view.resetBackgroundChangeNotifications() var options = view.terminal.options options.termName = environment["TERM"] ?? "xterm-256color" view.terminal.options = options diff --git a/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift b/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift index c35156e1d9..648a8389fd 100644 --- a/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift +++ b/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift @@ -2,7 +2,7 @@ import AppKit import Testing @testable import Lithe -/// Sequence emitted when the background changes: SwiftTerm's OSC 11 answer plus the kitty DSR 997 notification. +/// Notifications emitted on background changes for programs subscribed to kitty DEC mode 2031. @Suite("Terminal background change reporting") struct MacTerminalBackgroundReportingTests { @Test @@ -21,8 +21,127 @@ struct MacTerminalBackgroundReportingTests { } @Test - func changeNotificationAppendsKittyRequeryRequest() { - let notification = MacTerminalBackgroundReporting.changeNotification(for: .white) + func doesNotNotifyWhenUnsubscribed() { + #expect(MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: false) == nil) + } + + @Test + func changeNotificationReportsLightStateForLightBackground() { + let notification = MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: true) #expect(notification == "\u{1b}]11;rgb:ffff/ffff/ffff\u{1b}\\\u{1b}[?997;2n") } + + @Test + func changeNotificationReportsDarkStateForDarkBackground() { + let notification = MacTerminalBackgroundReporting.changeNotification(for: .black, isSubscribed: true) + #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") + } + + @Test + func changeNotificationReportsDarkStateForWallpaperTransparency() { + let transparentBlack = NSColor.black.withAlphaComponent(0) + let notification = MacTerminalBackgroundReporting.changeNotification(for: transparentBlack, isSubscribed: true) + #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") + } +} + +@Suite("Kitty background-change subscription") +struct MacTerminalBackgroundSubscriptionTests { + @Test + func startsUnsubscribed() { + #expect(!MacTerminalBackgroundSubscription().isSubscribed) + } + + @Test + func subscribesOnDecMode2031Set() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func subscribesWhenMode2031IsCombinedWithOtherDecModes() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?1;2031h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func doesNotSubscribeToModeWithSimilarNumber() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?20310h".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func unsubscribesOnDecMode2031Reset() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?2031l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func detectsMarkerSplitAcrossChunks() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?20".utf8) + #expect(!subscription.isSubscribed) + subscription.feed("31h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func terminalResetClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}c".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func processRestartClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.reset() + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen1049ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1049l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen47ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?47l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen1047ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1047l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreenEnterKeepsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1049h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func plainOutputStaysUnsubscribed() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("ls -la\r\nhello world".utf8) + #expect(!subscription.isSubscribed) + } } diff --git a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json index a69940b2ba..40986c2926 100644 --- a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json +++ b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json @@ -2,7 +2,7 @@ "id": "terminal-canvas-compositing", "area": "工作台", "group": "终端", - "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色);已打开终端随 Color theme 变化刷新背景和前景及 OSC 11/10 应答;背景实际变化时向终端应用推送 OSC 11 报告与 kitty 变更通知 DSR 997", + "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色);已打开终端随 Color theme 变化刷新背景和前景及 OSC 11/10 应答;背景变化时仅向订阅 kitty DEC mode 2031 的终端应用推送 OSC 11 与 DSR 997,997;1n 报暗、997;2n 报亮", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -21,5 +21,5 @@ ] }, "owner": "Terminal", - "verification": "无壁纸时分别使用 Dark+Lithe、Dark+Codex、Dark+Linear,在同一已打开终端切换 Color theme 后查询 OSC 11,确认分别报告 rgb:1919/1a1a/1c1c、rgb:1111/1111/1111、rgb:0f0f/0f0f/1111;Light 模式分别查询 OSC 11 和 OSC 10,确认 Lithe 与 Codex 的 OSC 11 都为白色、Linear 为 rgb:fcfc/fcfc/fdfd,OSC 10 可区分 Lithe rgb:1f3b/2312/2937、Codex rgb:0d0d/0d0d/0d0d、Linear rgb:1b1b/1b1b/1b1b。Dark OSC 10 前景依次应为 Lithe rgb:dfff/e1c9/e5e2、Codex rgb:fcfc/fcfc/fcfc、Linear rgb:e3e3/e4e4/e6e6。切换主题后 shell PID 不变;开启壁纸后确认壁纸透出、字缘干净且 OSC 11 报告 rgb:0000/0000/0000。明暗切换或开关壁纸时,已运行且跟踪背景的 TUI(如 oh-my-pi)应无需重启即跟随重查;不跟踪的程序不受推送影响。亮色主题下同时确认字缘无白色小框、正文不发浅。SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,因此有壁纸时按既有取舍报告黑色;若上游改为预乘清屏可恢复报告编辑器底色。" + "verification": "无壁纸时分别使用 Dark+Lithe、Dark+Codex、Dark+Linear,在同一已打开终端切换 Color theme 后查询 OSC 11,确认分别报告 rgb:1919/1a1a/1c1c、rgb:1111/1111/1111、rgb:0f0f/0f0f/1111;Light 模式分别查询 OSC 11 和 OSC 10,确认 Lithe 与 Codex 的 OSC 11 都为白色、Linear 为 rgb:fcfc/fcfc/fdfd,OSC 10 可区分 Lithe rgb:1f3b/2312/2937、Codex rgb:0d0d/0d0d/0d0d、Linear rgb:1b1b/1b1b/1b1b。Dark OSC 10 前景依次应为 Lithe rgb:dfff/e1c9/e5e2、Codex rgb:fcfc/fcfc/fcfc、Linear rgb:e3e3/e4e4/e6e6。切换主题后 shell PID 不变;开启壁纸后确认壁纸透出、字缘干净且 OSC 11 报告 rgb:0000/0000/0000。明暗切换或开关壁纸时,仅已运行且订阅 kitty DEC mode 2031 的 TUI(如 oh-my-pi)应收到背景变化通知并无需重启即跟随重查;未订阅的 zsh/cat 切换时不得收到回显字节;验证订阅、退订、跨输出块切分及终端 RIS 重置;DSR 997 暗色发送 997;1n、亮色发送 997;2n。亮色主题下同时确认字缘无白色小框、正文不发浅。SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,因此有壁纸时按既有取舍报告黑色;有壁纸时 COLORFGBG 导出深色 15;0,与 OSC 11 黑色应答和 DSR 997;1n 同源一致;若上游改为预乘清屏可恢复报告编辑器底色。" }