Skip to content

Add customizable terminal faces - #8394

Closed
lawrencecchen wants to merge 2 commits into
mainfrom
feat-terminal-faces
Closed

lawrencecchen wants to merge 2 commits into
mainfrom
feat-terminal-faces

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add customizable terminal faces with global, workspace, and terminal scope
  • react to agent lifecycle states without mutating terminal input or Ghostty configuration
  • persist scoped overrides and expose settings, context-menu, and CLI entry points
  • use static Core Animation shape layers so animation does not redraw the SwiftUI hierarchy
  • credit the MIT-licensed inspiration at https://github.com/isoden/claude-terminal-face/

Test plan

  • swift test --package-path Packages/macOS/CmuxSettings
  • swift test --package-path Packages/macOS/CmuxSettingsUI
  • swift test --package-path Packages/macOS/CmuxControlSocket --filter ControlCommandCoordinatorTabActionTests
  • tagged cloud build and tag-bound customize, toggle, read-screen, editor-window, and CPU verification

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Adds a customizable terminal face overlay that reacts to agent activity and can be configured globally, per workspace, or per terminal. Integrated in the terminal view as a Core Animation overlay with fast rendering and persisted overrides.

  • New Features
    • terminal.face JSON setting with global default, workspace override, and per-terminal override; deterministic resolution and session/workspace persistence.
    • Settings UI: Terminal Face card + full editor (sliders, color hex/picker, override/reset-to-inherited), plus “Customize Terminal Face…” in terminal and workspace context menus.
    • CLI/control socket: new tab actions customize_face and toggle_face; CLI adds customize-face and toggle-face.
    • Rendering: static Core Animation shape layers in an overlay host; animation modes (off/visible/always); maps agent hooks to idle/thinking/working/done/needs-input/error states without touching terminal input or Ghostty config.
    • Schema/i18n/tests: JSON schema and localized strings added; config round‑trip/sanitization tests and supported-actions tests; credit added for MIT‑licensed inspiration.

Written for commit c4882f9. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a customizable Terminal Face overlay drawn behind terminal text, including animation, positioning, glow/opacity, motion/gaze, and agent-reactive state colors.
    • Customize or toggle Terminal Face from terminal and workspace context menus, with per-terminal overrides and inherited workspace settings.
    • Terminal Face preferences persist across sessions and update live during terminal activity.
  • Documentation
    • Added Terminal Face configuration schema and English/Japanese settings documentation.
  • Tests
    • Added coverage for Terminal Face configuration encoding/decoding and sanitization.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds configurable terminal face settings, persistence, SwiftUI/AppKit editing, animated rendering, agent-event reactions, workspace and terminal overrides, and menu/control-command entry points.

Changes

Terminal Face

Layer / File(s) Summary
Configuration contract
Packages/macOS/CmuxSettings/..., web/data/cmux.schema.json, web/messages/*
Defines terminal.face, validates and serializes configuration values, and documents the new schema setting.
Settings and customization UI
Packages/macOS/CmuxSettingsUI/..., Resources/Localizable.xcstrings
Adds settings-card and detailed editor views with sliders, toggles, color wells, localization, and persistence wiring.
Controller and overlay rendering
Sources/TerminalFaceController.swift, Sources/TerminalController.swift, Sources/Panels/..., Sources/AppDelegate.swift, cmux.xcodeproj/project.pbxproj
Tracks face states, maps hook events, renders and animates the AppKit overlay, and initializes it with terminal panels.
Workspace and terminal overrides
Sources/Workspace.swift, Sources/SessionPersistence.swift
Persists, restores, and refreshes workspace-level and terminal-level face overrides.
Menus and control commands
Sources/GhosttyTerminalView.swift, Sources/Sidebar/..., Sources/TerminalController+ControlSystemContext2.swift, CLI/cmux.swift, Packages/macOS/CmuxControlSocket/..., THIRD_PARTY_LICENSES.md
Adds customization and toggle actions to terminal menus, workspace menus, CLI output, and control-socket tab actions, and records the face renderer license.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentHook
  participant TerminalController
  participant TerminalFaceController
  participant TerminalPanel
  participant TerminalFaceOverlayHost
  participant TerminalFaceAppKitView
  AgentHook->>TerminalController: push hook event
  TerminalController->>TerminalFaceController: noteHookEvent(event)
  TerminalFaceController->>TerminalPanel: refresh(panel)
  TerminalFaceController->>TerminalFaceOverlayHost: update configuration and state
  TerminalFaceOverlayHost->>TerminalFaceAppKitView: update overlay
  TerminalFaceAppKitView->>TerminalFaceAppKitView: draw and animate face
Loading

Possibly related PRs


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Full Internationalization ❌ Error New terminal.face copy is only present in en/ja, but web/i18n/routing.ts lists 20 locales and the xcstrings catalog already supports 20, leaving most locales untranslated. Add matching translations for every locale in web/messages and every existing locale in Resources/Localizable.xcstrings for each new user-facing key.
Cmux Architecture Rethink ❌ Error TerminalFaceSettingsCard clears isDirty before async JSONValueModel.set completes, so a failed write can leave draft!=model.current but Apply disabled. Derive dirtiness from draft == model.current and keep Apply enabled until persistence succeeds; sync the draft from the previous model value, not a local flag.
Cmux No Ambient Global State ❌ Error Adds file-scope debugWorkspaceDescriptionPreview and AppDelegate.terminalFaceController, both ambient global surfaces. Make the preview helper private/fileprivate in its consumer file, and inject TerminalFaceController through composition instead of storing it on AppDelegate.shared.
Docstring Coverage ⚠️ Warning Docstring coverage is 3.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (21 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PASS: the new face controller, presentation, settings card, and editor are all MainActor-isolated; JSONValueModel and SettingsErrorLog also stay on MainActor.
Cmux Swift Blocking Runtime ✅ Passed PASS: The diff adds MainActor state updates and CAKeyframeAnimation only; no new Task.sleep, sync waits, semaphores, or locks in the changed lines.
Cmux Browser Automation Off-Main ✅ Passed No browser.* socket commands or routing changes appear in the diff; the PR only adds terminal-face/settings UI and controller code.
Cmux Expensive Synchronous Load ✅ Passed PASS: New terminal-face UI/socket paths only update state; the only RestorableAgentSessionIndex.load() calls are preexisting cached fallbacks via SharedLiveAgentIndex.
Cmux Cache Substitution Correctness ✅ Passed The snapshot-path cache swap uses currentIndexSchedulingRefresh() with a RestorableAgentSessionIndex.load() cold fallback, and the cache itself refreshes when stale.
Cmux No Hacky Sleeps ✅ Passed No changed TS/JS/shell/build-runtime files; the PR diff is Swift, JSON, and project/config only, with no fixed waits introduced in covered code.
Cmux Algorithmic Complexity ✅ Passed New tab actions use O(1) panel lookups; the only added collection scans are linear, periodic maintenance in TerminalFaceController, not nested rescans or hot-path sorts.
Cmux Swift Concurrency ✅ Passed Added async work is lifecycle-bound: the new TerminalFaceController Task is stored and cancelled, and the rest is SwiftUI/AppKit callback boundary code.
Cmux Swift @Concurrent ✅ Passed New async work is UI-bound on @MainActor, and the font-size save already hops to the off-main FontConfigWriter actor.
Cmux Swift Package Boundaries ✅ Passed The new app-target code is mostly UI/AppKit glue and app-state plumbing; reusable config/model logic lives in CmuxSettings/CmuxSettingsUI, so no boundary violation.
Cmux Swiftpm Lockfiles ✅ Passed PR diff only adds TerminalFaceController.swift to cmux.xcodeproj; no Package.resolved, .gitignore, or SwiftPM package-reference changes appear.
Cmux Swift Logging ✅ Passed The commit only adds accessibility labels, dirty-state UI logic, and a window identifier; it introduces no new or changed print/NSLog/dump/Logger calls in runtime code.
Cmux User-Facing Error Privacy ✅ Passed The PR’s user-facing errors/messages are generic; the new tab-action output only adds public action names and exposes no vendor or internal details.
Cmux Swiftui State Layout ✅ Passed PASS: The new SwiftUI code uses @State/@observable and AppKit bridges; no new ObservableObject/@published, lazy-list store refs, or render-time state writes were introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: the new editor window uses stable cmux.terminalFaceEditor and is added to cmuxAuxiliaryWindowIdentifiers; scripts/lint_auxiliary_window_close_shortcuts.py enforces this literal assignment.
Cmux Source Artifacts ✅ Passed All changed paths are hand-written source/config/docs/localization or project files; none are logs, caches, build outputs, scratch dirs, or other artifacts.
Cmux No Test Or Debug Seam In Production Source ✅ Passed New production changes add terminal-face UI/controller code and a window-id allowlist only; I found no new debug/test-only members in the touched Sources files.
Title check ✅ Passed The title clearly and concisely matches the main change: adding customizable terminal faces.
Description check ✅ Passed The description covers the summary and testing well, but it omits the demo video, review-trigger block, and checklist items from the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-terminal-faces

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceConfigurationEditor.swift`:
- Around line 96-118: Add .accessibilityLabel(title) to the Slider and both
interactive controls in colorField: TerminalFaceColorWell and TextField. Apply
the labels directly to each control using the title parameter, preserving the
existing layout and behavior.

In
`@Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceSettingsCard.swift`:
- Around line 27-35: Unify the enable controls in the TerminalFace settings card
so the header Toggle and editor enable toggle use the same persistence path.
Remove the header Toggle’s direct save behavior and route both through the
Apply-owned draft flow, or otherwise persist only the enabled field consistently
without committing pending slider or color edits.

In `@Sources/TerminalFaceController.swift`:
- Around line 128-143: Update TerminalFaceController.panel(for:) to fail closed:
require a valid event.surfaceId and resolve only the terminal panel matching
that exact surface ID. Remove the workspace.focusedPanelId fallback, and reject
events with a conflicting or unresolved workspaceId rather than searching other
workspaces or applying the event to a focused panel.
- Around line 158-167: Update the standalone editor window creation in the
window-opening method to assign a stable cmux.* identifier, then add that
identifier to cmuxAuxiliaryWindowIdentifiers so shared Cmd+W recognizes the
editor window as its close-shortcut owner. Keep the existing window presentation
and lifecycle behavior unchanged.
- Around line 83-89: Replace the substring check in the event-state switch with
structured boolean decoding of event.extraFieldsJSON, using a helper such as
toolUseFailed near the state-selection logic. Parse the JSON into a top-level
dictionary and return true only when the is_error field is actually the Boolean
true, then use that helper for the .postToolUse error case while preserving the
existing state mappings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d24cdf78-cc5e-4d85-872b-413fb2fcee66

📥 Commits

Reviewing files that changed from the base of the PR and between 63ee64f and a260956.

📒 Files selected for processing (26)
  • CLI/cmux.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemTabAction.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorTabActionTests.swift
  • Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift
  • Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/TerminalFaceConfiguration.swift
  • Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/TerminalFaceConfigurationTests.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceConfigurationEditor.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceSettingsCard.swift
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/Panels/TerminalPanelView.swift
  • Sources/SessionPersistence.swift
  • Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCommands.swift
  • Sources/TerminalController+ControlSystemContext2.swift
  • Sources/TerminalController.swift
  • Sources/TerminalFaceController.swift
  • Sources/Workspace.swift
  • THIRD_PARTY_LICENSES.md
  • cmux.xcodeproj/project.pbxproj
  • web/data/cmux.schema.json
  • web/messages/en.json
  • web/messages/ja.json

Comment thread Sources/TerminalFaceController.swift
Comment thread Sources/TerminalFaceController.swift
Comment on lines +158 to +167
let window = NSWindow(contentViewController: NSHostingController(rootView: view))
window.title = title
window.styleMask = [.titled, .closable, .resizable]
window.setContentSize(NSSize(width: 540, height: 650))
window.center()
let controller = NSWindowController(window: window)
editorWindows.removeAll { $0.window?.isVisible != true }
editorWindows.append(controller)
controller.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Register the editor window for shared Cmd+W ownership.

This standalone window has no stable identifier, so Cmd+W can fall through to workspace-panel closing. Assign a cmux.* identifier and register it in cmuxAuxiliaryWindowIdentifiers.

Proposed local change
         let window = NSWindow(contentViewController: NSHostingController(rootView: view))
+        window.identifier = NSUserInterfaceItemIdentifier("cmux.terminalFaceEditor")
         window.title = title

As per coding guidelines, every standalone cmux-owned window must have a stable identifier and use the shared close-shortcut owner.

🤖 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 `@Sources/TerminalFaceController.swift` around lines 158 - 167, Update the
standalone editor window creation in the window-opening method to assign a
stable cmux.* identifier, then add that identifier to
cmuxAuxiliaryWindowIdentifiers so shared Cmd+W recognizes the editor window as
its close-shortcut owner. Keep the existing window presentation and lifecycle
behavior unchanged.

Source: Coding guidelines

@greptile-apps

greptile-apps Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a customizable terminal face overlay that renders dot-matrix shapes behind terminal text and reacts to agent lifecycle events (thinking, working, done, needs input, error). The feature ships global, workspace, and per-terminal scoped overrides persisted in the session manifest, plus settings UI, context-menu items, and customize_face/toggle_face control-socket actions.

  • TerminalFaceController (new, 560 lines) owns configuration resolution, state tracking, animation refresh, and per-scope override editors; it subscribes to the JSON config store and prunes the states dict every 64 events to bound memory.
  • TerminalFaceConfiguration (new, CmuxSettings package) is a fully sanitized, Codable value type; TerminalFaceSettingsCard and TerminalFaceConfigurationEditor (new, CmuxSettingsUI) provide the settings card with an isDirty guard protecting in-progress drafts from external overwrites.
  • Session persistence (SessionTerminalPanelSnapshot.faceOverride, SessionWorkspaceSnapshot.terminalFaceOverride), schema, and all string catalog entries (en + ja) are complete.

Confidence Score: 4/5

Safe to merge after fixing the color-space mismatch in the face renderer.

The renderer's stateColor creates NSColor with calibratedRed: (device color space) while the color picker in TerminalFaceColorWell uses srgbRed:. For the same hex value the two color spaces produce visibly different hues, so the face renders with a different color than the user chose. Everything else — configuration model, session persistence, i18n, animation, control-socket routing, and the isDirty draft guard — looks correct.

Sources/TerminalFaceController.swift — the private stateColor computed property at line 441.

Important Files Changed

Filename Overview
Sources/TerminalFaceController.swift New 560-line file implementing the full face rendering stack (controller, AppKit view, SwiftUI hosts, override editor). Contains a color-space mismatch in stateColor (calibratedRed vs the picker's sRGB); all other logic is sound.
Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/TerminalFaceConfiguration.swift New value type with clamping/sanitization and SettingCodable round-trip. Logic and bounds are correct.
Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceSettingsCard.swift New settings card with isDirty guard on external-change overwrite; draft/model lifecycle is correct.
Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceConfigurationEditor.swift New editor view with sliders and NSColorWell bridge; color(from:) correctly uses sRGB, but that color space differs from the renderer's calibratedRed.
Sources/Panels/TerminalPanel.swift Adds terminalFaceOverride and TerminalFacePresentation properties; setTerminalFaceOverride calls back to AppDelegate.shared following the existing pattern.
Sources/Workspace.swift Adds workspace-level terminalFaceOverride with session save/restore wiring; correctly persists to and restores from SessionWorkspaceSnapshot.
Sources/SessionPersistence.swift Adds optional faceOverride to SessionTerminalPanelSnapshot and terminalFaceOverride to SessionWorkspaceSnapshot; both are backward-compatible optional fields.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent as Agent hook event
    participant TC as TerminalController
    participant TFC as TerminalFaceController
    participant TFP as TerminalFacePresentation
    participant CALayer as CALayer (AppKit)

    Agent->>TC: workstream hook event
    TC->>TFC: noteHookEvent(event) [MainActor Task]
    TFC->>TFC: resolve TerminalFaceState from hookEventName
    TFC->>TFC: "states[panel.id] = state"
    TFC->>TFP: update(configuration:state:)
    TFP-->>CALayer: "@Observable change triggers updateNSView"
    CALayer->>CALayer: rebuildPaths() + refreshAnimations()

    Note over TFC: Config change path
    TFC->>TFC: settings Task observes JSONConfigStore
    TFC->>TFC: "globalConfiguration = value"
    TFC->>TFC: refreshAll() each workspace each TerminalPanel
    TFC->>TFP: update(configuration:state:)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent as Agent hook event
    participant TC as TerminalController
    participant TFC as TerminalFaceController
    participant TFP as TerminalFacePresentation
    participant CALayer as CALayer (AppKit)

    Agent->>TC: workstream hook event
    TC->>TFC: noteHookEvent(event) [MainActor Task]
    TFC->>TFC: resolve TerminalFaceState from hookEventName
    TFC->>TFC: "states[panel.id] = state"
    TFC->>TFP: update(configuration:state:)
    TFP-->>CALayer: "@Observable change triggers updateNSView"
    CALayer->>CALayer: rebuildPaths() + refreshAnimations()

    Note over TFC: Config change path
    TFC->>TFC: settings Task observes JSONConfigStore
    TFC->>TFC: "globalConfiguration = value"
    TFC->>TFC: refreshAll() each workspace each TerminalPanel
    TFC->>TFP: update(configuration:state:)
Loading

Reviews (2): Last reviewed commit: "Address terminal face review feedback" | Re-trigger Greptile

@@ -0,0 +1,536 @@
import AppKit

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 Unbounded states dict growth

Every UUID that ever passes through noteHookEvent is inserted into states but never removed. In a long-lived session where terminals are opened and closed frequently, this dict grows without bound. Because TerminalFaceController is @MainActor and is never deallocated for the lifetime of the app, the accumulated entries are never reclaimed. A compaction pass — e.g., removing entries whose UUID no longer appears in any live mainWindowContext — should be added to refresh(workspace:) or refreshAll().

@@ -0,0 +1,73 @@
import CmuxSettings

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 Unsaved draft silently overwritten on external change

The .onChange(of: model.current) handler overwrites draft with the new external value whenever the persisted setting changes from outside the view. If the user has made edits to sliders or color wells but has not yet saved, those edits are silently discarded the moment any other agent or process writes a new value to the store. A guard (if !userHasEdited) or a confirmation prompt is needed before replacing an in-progress draft.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceSettingsCard.swift`:
- Line 9: Update TerminalFaceSettingsCard’s dirtiness and apply flow so isDirty
is derived from whether draft differs from model.current, rather than being
cleared immediately after model.set. Preserve the draft when persistence fails
by comparing the draft against the previous model value, and keep Apply enabled
until the asynchronous write successfully updates model.current, allowing
retries on failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0a294f55-ffb5-40cd-9e3f-7ff4eb2f4abf

📥 Commits

Reviewing files that changed from the base of the PR and between a260956 and c4882f9.

📒 Files selected for processing (4)
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceConfigurationEditor.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceSettingsCard.swift
  • Sources/TerminalFaceController.swift
  • Sources/cmuxApp.swift

@State private var model: JSONValueModel<TerminalFaceConfiguration>
@State private var draft: TerminalFaceConfiguration
@State private var expanded = false
@State private var isDirty = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep Apply enabled until persistence actually succeeds.

model.set returns before its asynchronous write completes, but Line 70 immediately clears isDirty. If the write fails, draft still differs from model.current while Apply remains disabled, preventing a retry. Derive dirtiness from those values and preserve drafts by comparing against the previous model value.

Proposed fix
-    `@State` private var isDirty = false

-                    .disabled(!isDirty)
+                    .disabled(draft == model.current)

-        .onChange(of: model.current) { _, value in
-            if !isDirty, value != draft { draft = value }
+        .onChange(of: model.current) { previous, value in
+            if draft == previous { draft = value }
         }
-        .onChange(of: draft) { _, value in
-            isDirty = value != model.current
-        }

     private func save() {
         var sanitized = draft
         sanitized.sanitize()
+        draft = sanitized
         model.set(sanitized)
-        isDirty = false
     }

Also applies to: 48-48, 58-70

🤖 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/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/TerminalFace/TerminalFaceSettingsCard.swift`
at line 9, Update TerminalFaceSettingsCard’s dirtiness and apply flow so isDirty
is derived from whether draft differs from model.current, rather than being
cleared immediately after model.set. Preserve the draft when persistence fails
by comparing the draft against the previous model value, and keep Apply enabled
until the asynchronous write successfully updates model.current, allowing
retries on failure.

Comment on lines +439 to +445
let value = UInt64(hex.dropFirst(), radix: 16) ?? 0xFFFFFF
return NSColor(
calibratedRed: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: 1
)

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 stateColor creates an NSColor with calibratedRed: (device/calibrated color space) while the color picker in TerminalFaceColorWell.color(from:) creates it with srgbRed:. For a hex value like #3D70E0 the two color spaces produce observably different values, so the face renders with a different color than the user chose in the editor.

Suggested change
let value = UInt64(hex.dropFirst(), radix: 16) ?? 0xFFFFFF
return NSColor(
calibratedRed: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: 1
)
let value = UInt64(hex.dropFirst(), radix: 16) ?? 0xFFFFFF
return NSColor(
srgbRed: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: 1
)

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Configuration preview: https://cmux-czvfp70e3-manaflow.vercel.app/docs/configuration

Validated live with HTTP 200 at commit c4882f9.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants