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
13 changes: 7 additions & 6 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
6153 CLI/cmux_open.swift
6074 Sources/TextBoxInput.swift
5925 cmuxTests/TerminalAndGhosttyTests.swift
5558 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
5591 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
5526 cmuxTests/BrowserConfigTests.swift
4921 Sources/cmuxApp.swift
4467 Sources/Panels/FilePreviewPanel.swift
Expand All @@ -28,7 +28,7 @@
3937 Sources/Feed/FeedPanelView.swift
3926 cmuxTests/TabManagerUnitTests.swift
3903 cmuxTests/WindowAndDragTests.swift
3734 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3770 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3699 cmuxTests/CLIGenericHookPersistenceTests.swift
3397 Sources/CmuxConfig.swift
3331 cmuxTests/TabManagerSessionSnapshotTests.swift
Expand Down Expand Up @@ -73,8 +73,8 @@
1292 Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift
1285 cmuxUITests/SidebarHelpMenuUITests.swift
1276 cmuxTests/MobileHostAuthorizationTests.swift
1275 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
1257 Sources/Feed/FeedCoordinator.swift
1252 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
1228 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift
1197 cmuxTests/CodexAppServerSessionTests.swift
1161 cmuxTests/SidebarOrderingTests.swift
Expand Down Expand Up @@ -107,6 +107,7 @@
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
841 Sources/Panels/MarkdownWebRenderer.swift
830 Sources/TaskManagerTypes.swift
825 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
810 Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift
797 Sources/ClosedItemHistory.swift
779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift
Expand All @@ -120,7 +121,6 @@
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
749 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
746 Sources/App/MenuBarExtraController.swift
825 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
726 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
Expand Down Expand Up @@ -169,13 +169,13 @@
588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift
586 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift
586 Sources/JSONCParser.swift
585 Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift
585 Sources/Cloud/VMClient.swift
580 Packages/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift
580 cmuxTests/CLIHookNoResponseTests.swift
578 cmuxUITests/FeedSidebarUITests.swift
577 cmuxTests/AppearanceSettingsTests.swift
574 Sources/Feed/FeedTextEditorDebugWindowController.swift
568 Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift
566 Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift
562 Packages/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
562 cmuxTests/AgentExecutableResolverTests.swift
Expand All @@ -185,7 +185,9 @@
551 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift
547 Packages/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings.swift
547 Sources/Windowing/WindowGlassEffect.swift
545 Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift
541 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift
541 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
540 Packages/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift
539 CLI/CMUXCLI+Themes.swift
539 CLI/CodexTeamsApprovalBridge.swift
Expand All @@ -194,7 +196,6 @@
534 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift
531 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift
531 Sources/App/WorkspaceRuntimeSettings.swift
530 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
528 cmuxTests/CLINotifyProcessTestSupport.swift
528 cmuxUITests/AutomationSocketUITests.swift
527 CLI/CLISocketPathResolver.swift
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// The Mac's resolved Ghostty terminal theme propagated to a paired phone so it
/// inherits the Mac's palette + default colors instead of a hardcoded Monokai.
///
/// All colors are `#RRGGBB` hex strings. ``palette`` is the 16-color ANSI
/// palette (indices 0...15) or `nil` when the Mac could not resolve a full
/// 16-color palette (so the phone keeps its consistent built-in fallback).
/// ``foreground`` / ``background`` / ``cursor`` are `nil` when the user has not
/// configured that default (so the phone keeps its own default for that color,
/// and a program's live OSC 10/11/12 still wins).
///
/// Resolving this walks the parsed Ghostty config and formats several NSColors,
/// so it must be computed off the per-keystroke render path and reused (cache it
/// and recompute only when the Ghostty config reloads) rather than rebuilt on
/// every render-grid export.
public struct MobileInheritedTerminalTheme: Equatable, Sendable {
/// The 16-color ANSI palette (indices 0...15) as `#RRGGBB` hex, or `nil` when
/// the Mac could not resolve a full 16-color palette.
public var palette: [String]?
/// The default foreground color as `#RRGGBB` hex, or `nil` when the user has
/// not configured one (the phone keeps its own default).
public var foreground: String?
/// The default background color as `#RRGGBB` hex, or `nil` when the user has
/// not configured one (the phone keeps its own default).
public var background: String?
/// The cursor color as `#RRGGBB` hex, or `nil` when the user has not
/// configured one (the phone keeps its own default).
public var cursor: String?

/// Creates an inherited theme. Each color is an optional `#RRGGBB` hex string;
/// see the property docs for what `nil` means per field.
public init(
palette: [String]? = nil,
foreground: String? = nil,
background: String? = nil,
cursor: String? = nil
) {
self.palette = palette
self.foreground = foreground
self.background = background
self.cursor = cursor
}
}

extension MobileTerminalRenderGridFrame {
/// Stamp the Mac's inherited theme onto a full snapshot. The palette always
/// replaces the frame's (libghostty's grid JSON never carries it); the
/// default colors only backfill where the program has not already set a
/// dynamic default (OSC 10/11/12), so a running program's live colors win
/// over the static theme. Meaningful only on a full frame; a delta drops the
/// theme fields, so callers should skip this for deltas.
public mutating func applyInheritedTheme(_ theme: MobileInheritedTerminalTheme) {
// Enforce the same 16-entry invariant the frame initializer applies, so a
// partial palette never reaches OSC 4 (which would mix inherited and
// fallback colors). A non-16 palette is dropped, keeping the phone's
// consistent built-in fallback.
terminalPalette = (theme.palette?.count == 16) ? theme.palette : nil
if terminalForeground == nil { terminalForeground = theme.foreground }
if terminalBackground == nil { terminalBackground = theme.background }
if terminalCursorColor == nil { terminalCursorColor = theme.cursor }
Comment on lines +54 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate palette entry format before accepting inherited palette.

At Line 58, the gate only checks count == 16. That still admits malformed entries, and replay later skips invalid entries individually, which can leave a partially updated ANSI palette (stale slots from prior state). Please normalize atomically: accept the palette only when all 16 entries are valid #RRGGBB, otherwise drop the whole palette to nil.

Proposed fix
 public mutating func applyInheritedTheme(_ theme: MobileInheritedTerminalTheme) {
-    terminalPalette = (theme.palette?.count == 16) ? theme.palette : nil
+    terminalPalette = Self.normalizedTerminalPalette(theme.palette)
     if terminalForeground == nil { terminalForeground = theme.foreground }
     if terminalBackground == nil { terminalBackground = theme.background }
     if terminalCursorColor == nil { terminalCursorColor = theme.cursor }
 }
+
+private static func normalizedTerminalPalette(_ palette: [String]?) -> [String]? {
+    guard let palette, palette.count == 16 else { return nil }
+    let isValidHex: (String) -> Bool = { value in
+        guard value.hasPrefix("#"), value.count == 7 else { return false }
+        return Int(value.dropFirst(), radix: 16) != nil
+    }
+    return palette.allSatisfy(isValidHex) ? palette : nil
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileInheritedTerminalTheme.swift`
around lines 54 - 61, The palette validation in the terminalPalette assignment
only checks that count equals 16, but does not validate the format of individual
entries. This allows malformed color entries to be accepted, causing partial
updates to the ANSI palette when invalid entries are skipped during replay.
Modify the condition to validate not only that theme.palette has exactly 16
entries, but also that each entry is a valid hex color in the format `#RRGGBB`. If
any entry fails validation, set terminalPalette to nil (atomic rejection)
instead of accepting the palette with malformed entries.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
public var terminalForeground: String?
public var terminalBackground: String?
public var terminalCursorColor: String?
/// The Mac's resolved 16-color ANSI palette (palette indices 0...15, oldest
/// is index 0), as `#RRGGBB` hex strings, so the phone inherits the Mac's
/// Ghostty theme palette instead of a hardcoded Monokai. `nil` when the Mac
/// could not resolve a full 16-color palette (so the phone keeps its
/// built-in fallback). Carried only on full snapshots; `filteredRows(full:)`
/// nils it for delta frames. When non-nil it always has exactly 16 entries.
public var terminalPalette: [String]?
/// Count of scrollback lines carried in ``scrollbackSpans`` (rows above the
/// visible viewport, oldest first). Only meaningful on a full primary-screen
/// snapshot; the alternate screen has no scrollback.
Expand All @@ -60,6 +67,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
terminalForeground: String? = nil,
terminalBackground: String? = nil,
terminalCursorColor: String? = nil,
terminalPalette: [String]? = nil,
scrollbackRows: Int = 0,
scrollbackSpans: [RowSpan] = []
) throws {
Expand Down Expand Up @@ -136,6 +144,11 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
self.terminalForeground = terminalForeground
self.terminalBackground = terminalBackground
self.terminalCursorColor = terminalCursorColor
// A palette is only meaningful as a full 16-color set; a partial set
// would leave the phone applying some inherited and some fallback
// entries, which reads worse than keeping the consistent built-in
// fallback. Drop anything that is not exactly 16 entries.
self.terminalPalette = (terminalPalette?.count == 16) ? terminalPalette : nil
self.scrollbackRows = full ? resolvedScrollbackRows : 0
self.scrollbackSpans = full ? scrollbackSpans : []
}
Expand All @@ -157,6 +170,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
let terminalForeground = try container.decodeIfPresent(String.self, forKey: .terminalForeground)
let terminalBackground = try container.decodeIfPresent(String.self, forKey: .terminalBackground)
let terminalCursorColor = try container.decodeIfPresent(String.self, forKey: .terminalCursorColor)
let terminalPalette = try container.decodeIfPresent([String].self, forKey: .terminalPalette)
let scrollbackRows = try container.decodeIfPresent(Int.self, forKey: .scrollbackRows) ?? 0
let scrollbackSpans = try container.decodeIfPresent([RowSpan].self, forKey: .scrollbackSpans) ?? []
try self.init(
Expand All @@ -175,6 +189,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
terminalForeground: terminalForeground,
terminalBackground: terminalBackground,
terminalCursorColor: terminalCursorColor,
terminalPalette: terminalPalette,
scrollbackRows: scrollbackRows,
scrollbackSpans: scrollbackSpans
)
Expand Down Expand Up @@ -293,6 +308,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
terminalForeground: full ? terminalForeground : nil,
terminalBackground: full ? terminalBackground : nil,
terminalCursorColor: full ? terminalCursorColor : nil,
terminalPalette: full ? terminalPalette : nil,
scrollbackRows: full ? scrollbackRows : 0,
scrollbackSpans: full ? scrollbackSpans : []
)
Expand Down Expand Up @@ -403,6 +419,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
case terminalForeground = "terminal_foreground"
case terminalBackground = "terminal_background"
case terminalCursorColor = "terminal_cursor_color"
case terminalPalette = "terminal_palette"
case scrollbackRows = "scrollback_rows"
case scrollbackSpans = "scrollback_spans"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ public struct MobileTerminalRenderGridReplay: Sendable {
bytes.append(Data("\u{1B}c".utf8))
bytes.append(Data("\u{1B}[?2026h".utf8))

// Inherit the Mac's resolved 16-color ANSI palette (OSC 4) so the phone's
// local surface resolves any ANSI-indexed colors (live SGR between
// frames, and the surface's own fallback) against the Mac's theme rather
// than the hardcoded Monokai default. Emitted before the dynamic default
// colors so OSC 10/11 (which may reference palette indices on some
// hosts) see the inherited palette.
if let palette = frame.terminalPalette {
for (index, hex) in palette.enumerated() {
if let osc = Self.oscPaletteBytes(index, hex) { bytes.append(osc) }
}
}

// Dynamic default colors (OSC 10/11/12). Cells already carry explicit
// RGB, so these mainly fix the cursor color and color queries.
if let osc = Self.oscColorBytes(10, frame.terminalForeground) { bytes.append(osc) }
Expand Down Expand Up @@ -239,6 +251,20 @@ public struct MobileTerminalRenderGridReplay: Sendable {
return Data("\u{1B}]\(ps);\(spec)\u{1B}\\".utf8)
}

/// `OSC 4 ; <index> ; rgb:RR/GG/BB ST` — sets one ANSI palette entry. Used to
/// replay the Mac's resolved 16-color palette onto the phone's surface so
/// inherited ANSI-indexed colors match the Mac's theme.
private static func oscPaletteBytes(_ index: Int, _ hex: String?) -> Data? {
guard let rgb = rgbComponents(hex) else { return nil }
let spec = String(
format: "rgb:%02x/%02x/%02x",
rgb.red,
rgb.green,
rgb.blue
)
return Data("\u{1B}]4;\(index);\(spec)\u{1B}\\".utf8)
}

private static func vtPrintableBytes(_ text: String) -> Data {
var output = String()
output.reserveCapacity(text.count)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,132 @@ import Testing
#expect(vt.contains("\u{1B}]12;rgb:ff/ee/dd\u{1B}\\"))
}

@Test func applyInheritedThemeBackfillsDefaultsButProgramColorsWin() throws {
let palette = (0..<16).map { _ in "#445566" }
// The program already set a runtime background (OSC 11) but no foreground.
var frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 1,
columns: 4,
rows: 1,
rowSpans: [],
terminalBackground: "#ABCDEF"
)
frame.applyInheritedTheme(
MobileInheritedTerminalTheme(
palette: palette,
foreground: "#111111",
background: "#222222",
cursor: "#333333"
)
)
// Palette always comes from the theme.
#expect(frame.terminalPalette == palette)
// The program's live background is preserved (theme does not clobber it).
#expect(frame.terminalBackground == "#ABCDEF")
// Unset defaults are backfilled from the theme.
#expect(frame.terminalForeground == "#111111")
#expect(frame.terminalCursorColor == "#333333")
}

@Test func applyInheritedThemeDropsPartialPalette() throws {
// A theme value built with a partial palette must not produce a partial
// OSC 4 frame; the helper enforces the same 16-entry invariant as the frame
// initializer so inherited and fallback colors never mix.
var frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 1,
columns: 4,
rows: 1,
rowSpans: []
)
frame.applyInheritedTheme(
MobileInheritedTerminalTheme(palette: ["#000000", "#111111"], background: "#222222")
)
#expect(frame.terminalPalette == nil)
#expect(frame.terminalBackground == "#222222")
let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8))
#expect(!vt.contains("\u{1B}]4;"))
}

@Test func renderGridFullSnapshotReplaysInheritedPalette() throws {
// The Mac's resolved 16-color palette is replayed as OSC 4 so the phone's
// surface resolves ANSI-indexed colors against the Mac's theme instead of
// its hardcoded Monokai fallback.
let palette = (0..<16).map { index in
String(format: "#%02X0000", index * 16)
}
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 1,
columns: 4,
rows: 1,
rowSpans: [],
terminalPalette: palette
)

#expect(frame.terminalPalette == palette)
let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8))
// Index 1 is "#100000" -> rgb:10/00/00 at OSC 4 index 1.
#expect(vt.contains("\u{1B}]4;0;rgb:00/00/00\u{1B}\\"))
#expect(vt.contains("\u{1B}]4;1;rgb:10/00/00\u{1B}\\"))
#expect(vt.contains("\u{1B}]4;15;rgb:f0/00/00\u{1B}\\"))
// Every one of the 16 indices is emitted.
for index in 0..<16 {
#expect(vt.contains("\u{1B}]4;\(index);"))
}
}

@Test func renderGridPaletteRoundTripsThroughJSON() throws {
let palette = (0..<16).map { String(format: "#0000%02X", $0 * 16) }
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 3,
columns: 4,
rows: 1,
rowSpans: [],
terminalPalette: palette
)
let decoded = try MobileTerminalRenderGridFrame.decodeJSONObject(frame.jsonObject())
#expect(decoded == frame)
#expect(decoded.terminalPalette == palette)
}

@Test func renderGridPaletteRequiresSixteenEntries() throws {
// A partial palette is dropped so the phone keeps its consistent built-in
// fallback rather than mixing inherited and fallback entries.
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 4,
columns: 4,
rows: 1,
rowSpans: [],
terminalPalette: ["#000000", "#111111", "#222222"]
)
#expect(frame.terminalPalette == nil)
let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8))
#expect(!vt.contains("\u{1B}]4;"))
}

@Test func renderGridDeltaDropsInheritedPalette() throws {
let palette = (0..<16).map { _ in "#123456" }
let full = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 5,
columns: 4,
rows: 2,
rowSpans: [.init(row: 0, column: 0, text: "hi")],
terminalPalette: palette
)
#expect(full.terminalPalette == palette)
// The delta derived from the full frame must not carry the palette; only the
// full snapshot establishes the theme.
let delta = try full.filteredRows([0], full: false)
#expect(delta.terminalPalette == nil)
let vt = try #require(String(data: delta.vtPatchBytes(), encoding: .utf8))
#expect(!vt.contains("\u{1B}]4;"))
}

@Test func renderGridEncodesFullStateFields() throws {
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
Expand Down
Loading
Loading