Skip to content

feat: cmux.json for custom commands#2011

Merged
lawrencecchen merged 13 commits intomanaflow-ai:mainfrom
pratikpakhale:cmux-json
Mar 25, 2026
Merged

feat: cmux.json for custom commands#2011
lawrencecchen merged 13 commits intomanaflow-ai:mainfrom
pratikpakhale:cmux-json

Conversation

@pratikpakhale
Copy link
Copy Markdown
Contributor

@pratikpakhale pratikpakhale commented Mar 23, 2026

Summary

  • Add cmux.json configuration support for defining custom commands and workspace layouts
  • Users can place a cmux.json in their project root (per-project) or ~/.config/cmux/cmux.json
    (global) to define commands that appear in the command palette
  • Supports simple shell commands (run in focused terminal) and full workspace commands with custom
    split layouts, browser panes, named tabs, env vars, and auto-run commands
  • Includes live file watching with automatic reload on config changes, local/global config merging
    (local takes precedence), and restart behavior control (ignore/recreate/confirm)
  • Adds docs page for Custom Commands with full schema reference and examples, localized across all
    supported languages

Testing

  • Verified cmux.json parsing with simple commands and workspace layout definitions
  • Tested command palette integration: custom commands appear and execute correctly
  • Tested workspace creation with nested horizontal/vertical splits, custom cwd, env vars, and auto-run
    commands
  • Tested restart behaviors: ignore switches to existing workspace, recreate closes and recreates,
    confirm shows dialog
  • Tested live reload: editing/creating/deleting cmux.json updates commands in real time
  • Tested local config overrides global config for same-named commands
  • Verified browser surface type creates browser panes with specified URLs
  • Wrote unit tests to confirm the parsing of the json file

Demo Video

Screen.Recording.2026-03-24.at.1.58.09.AM.mov

Checklist

  • I tested the change locally
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed
  • I requested bot reviews after my latest commit (copy/paste block above or equivalent)
  • All code review bot comments are resolved
  • All human review comments are resolved

Summary by cubic

Adds support for cmux.json to define custom commands and workspace layouts with live reload and command palette integration. Adds directory trust with an auto-saving Settings editor so confirm-required shell commands from trusted paths run without future prompts.

  • New Features

    • Config loading: ./cmux.json (per-project) and ~/.config/cmux/cmux.json (global); walks up from active tab cwd; local overrides; per-window isolation; if none found, watches current dir.
    • Command types: shell (runs in focused terminal; optional confirm) and workspace (splits, browser panes, named tabs, cwd/env, auto-run) with restart policies (ignore, recreate, confirm).
    • Command palette: namespaced IDs, subtitle from descriptions, sanitized labels/keywords, empty baseCwd fallback, mixed input handling that coalesces CRLF.
    • Directory trust: confirm dialog shows sanitized command text (strips BiDi/zero‑width chars) with “Always trust this folder”; trust scoped to repo root when present; global config always trusted; persisted at ~/Library/Application Support/cmux/trusted-directories.json; manage in Settings via an editable textarea that auto-saves.
    • Validation/docs/tests: strict JSON validation (reject hybrid/empty commands, ambiguous nodes, wrong split child counts, empty pane surfaces); docs at /docs/custom-commands; tests for parsing, layout, validation, ID rules; redact command text in timeout logs.
  • Migration

    • Optional: add a cmux.json in your project root or ~/.config/cmux/ to start using custom commands. You can trust a folder once to skip future confirms. No app restart needed.

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

Summary by CodeRabbit

  • New Features

    • Custom commands in the command palette to run shell or workspace workflows with optional confirmation, restart policies, and configured layouts
    • Trusted directories support for safer command execution (UI to manage trusted paths)
  • Documentation

    • Detailed "Custom Commands" docs page added and localized across many languages; docs nav entry included
  • Improvements

    • Live monitoring and hot-reload of local/global config files
    • Command palette integration and improved terminal input handling
  • Tests

    • Unit tests covering config decoding, layout handling, command identity, split behavior, and path resolution

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 23, 2026

@pratikpakhale is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@pratikpakhale
Copy link
Copy Markdown
Contributor Author

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds CMUX support: new Codable models, a MainActor store that discovers/merges/watches local+global cmux.json, dynamic command-palette contributions and executor for commands/workspaces, layout application into workspaces, terminal input routing, unit tests, and localized docs + nav entries.

Changes

Cohort / File(s) Summary
Xcode & Localizable
GhosttyTabs.xcodeproj/project.pbxproj, Resources/Localizable.xcstrings
Registered three new Swift sources and a test file in the Xcode project; added CMUX localization keys for confirmation dialogs and command UI.
Models & Store
Sources/CmuxConfig.swift
New Codable models (commands, workspace/layout/pane/surface) and @MainActor CmuxConfigStore implementing discovery (local+global), merge precedence, cwd resolution, JSON parsing, and resilient file/directory watchers with reattach/retry logic.
Executor & Trusting
Sources/CmuxConfigExecutor.swift, Sources/CmuxDirectoryTrust.swift
New executor that runs shell or workspace commands (confirm dialog, directory trust flow, restart policies), plus a singleton to persist/manage trusted directories and compute trust keys (repo-root or parent dir).
SwiftUI Integration
Sources/AppDelegate.swift, Sources/cmuxApp.swift, Sources/ContentView.swift
Instantiate/inject CmuxConfigStore, wire directory tracking and initial load, include configRevision in command fingerprinting, add dynamic command-palette contributions, and register per-command handlers to invoke the executor.
Workspace Layout Application
Sources/Workspace.swift
Added applyCustomLayout(_:baseCwd:) to build split/pane tree, reuse/update placeholder panels, set divider positions, focus panels, and send startup input with readiness observer/timeout.
Terminal Input Path
Sources/GhosttyTerminalView.swift, Sources/Panels/TerminalPanel.swift
Added TerminalSurface.sendInput(_:) implementing scalar-by-scalar buffering and discrete key events for control characters; TerminalPanel.sendInput(_:) forwards text to the surface.
Tests
cmuxTests/CmuxConfigTests.swift
New XCTest file covering JSON decoding/encoding, model validation/invariants, layout parsing, id determinism, split clamping, and cwd resolution.
Docs & i18n / Web
web/app/[locale]/docs/custom-commands/page.tsx, web/app/[locale]/components/docs-nav-items.ts, web/messages/*.json
Added a localized “Custom Commands” docs page, nav entry, metadata, examples, and translations across many locales (locale JSON files updated).

Sequence Diagram(s)

sequenceDiagram
    participant App as App/Scene
    participant Store as CmuxConfigStore
    participant Watcher as FileWatcher
    participant Loader as ConfigLoader
    participant UI as ContentView/CmdPalette
    participant Executor as CmuxConfigExecutor
    participant TabMgr as TabManager
    participant Workspace as Workspace
    participant Panel as TerminalPanel
    participant Ghostty as GhosttyTerminalView

    App->>Store: wireDirectoryTracking(tabManager)
    App->>Store: loadAll()
    Store->>Watcher: start watching local/global paths
    Watcher-->>Store: file change event
    Store->>Loader: parse local then global cmux.json
    Loader-->>Store: update loadedCommands & bump configRevision
    Store-->>UI: publish commands

    UI->>Executor: user invokes command (id)
    Executor->>Store: resolveCwd(if needed)
    Executor->>TabMgr: find or create workspace (apply restart policy)
    Executor->>Workspace: applyCustomLayout(layoutNode, baseCwd)
    Workspace->>Panel: create/configure panes & surfaces
    Panel->>Ghostty: sendInput() / key events for initial commands
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Poem

🐇 I nibble JSON by lantern light,
I hop when files awake at night,
Panes split tidy, commands set sail,
Terminals hum as inputs trail,
A rabbit cheers — the workspace tells the tale!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: cmux.json for custom commands' is concise, clear, and accurately summarizes the main feature addition—support for a cmux.json configuration file to define custom commands and workspace layouts.
Description check ✅ Passed The pull request description follows the required template structure with Summary, Testing, Demo Video, and Checklist sections, providing comprehensive detail about the cmux.json feature implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Mar 23, 2026

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@pratikpakhale I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

@pratikpakhale Sure! I'll kick off a full review of PR #2011 now.


🧠 Learnings used
Learnt from: homanp
Repo: manaflow-ai/cmux PR: 883
File: CLI/cmux.swift:1569-1586
Timestamp: 2026-03-04T22:05:07.913Z
Learning: In manaflow-ai/cmux CLI (CLI/cmux.swift), for parity with existing browser commands, markdown.open intentionally normalizes --surface before resolving --workspace/--window, relying on server-side resolution to disambiguate final routing. Avoid one-off reordering in markdown; consider any change only as a coordinated, cross-command refactor.

Learnt from: gaelic-ghost
Repo: manaflow-ai/cmux PR: 1926
File: scripts/build-sign-upload.sh:8-10
Timestamp: 2026-03-22T00:14:23.473Z
Learning: Repo: manaflow-ai/cmux — `scripts/lib/cmux-paths.sh` `cmux_paths_init()` intentionally preserves any pre-set `CMUX_*` environment variable overrides verbatim (does not canonicalize relative paths to absolute). This is a deliberate Stage 1 design choice; do not flag relative-override canonicalization as a bug unless a concrete reproducer is provided or a later stage explicitly tightens the override-semantics contract.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 318
File: Sources/Panels/BrowserPanel.swift:0-0
Timestamp: 2026-03-17T05:34:44.905Z
Learning: In manaflow-ai/cmux (PR `#318`), the browser import wizard in Sources/Panels/BrowserPanel.swift now exposes an Additional data checkbox and passes its state into BrowserImportScope.fromSelection so users can select .everything (including when only additional-data is chosen). Unit and UI tests cover the additional‑data‑only mapping.

Learnt from: outoftime
Repo: manaflow-ai/cmux PR: 1528
File: Resources/shell-integration/fish/vendor_conf.d/cmux-fish-integration.fish:541-546
Timestamp: 2026-03-17T13:59:10.665Z
Learning: Repo: manaflow-ai/cmux — The socket command `report_git_branch` (parsed in Sources/TerminalController.swift) expects the branch name as an **unquoted, bare token**. Wrapping the branch name in double quotes causes it to be silently discarded by the parser. This matches the bash/zsh shell integration convention. Do not suggest quoting the branch argument for this command; the fix was attempted in commit 829437c7 and immediately reverted because it broke branch reporting.

Learnt from: tayl0r
Repo: manaflow-ai/cmux PR: 1909
File: Sources/ContentView.swift:2156-2171
Timestamp: 2026-03-23T06:08:14.740Z
Learning: Repo: manaflow-ai/cmux — In Sources/ContentView.swift, openFileInTextEditor(_:) must attempt workspace.newTextEditorSplit(from:orientation:filePath:focus:) and, if that returns nil, fall back to workspace.newTextEditorSurface(inPane:filePath:focus:) using bonsplitController.focusedPaneId. Rationale: avoid dropping file-open requests when the focused panel is not pane-backed.

Learnt from: gaelic-ghost
Repo: manaflow-ai/cmux PR: 1771
File: Sources/Workspace.swift:8516-8558
Timestamp: 2026-03-18T16:46:47.403Z
Learning: Repo manaflow-ai/cmux — Preference: keep lower-confidence geometry/focus/portal behavior changes out of stabilization checkpoints; evaluate them in a separate, narrowly scoped follow-up PR with DEBUG-gated toggles and diagnostics.

Learnt from: atani
Repo: manaflow-ai/cmux PR: 819
File: Sources/AppDelegate.swift:3491-3493
Timestamp: 2026-03-04T14:06:16.241Z
Learning: For manaflow-ai/cmux PR `#819` (Japanese i18n), keep scope limited to localization changes; UX enhancements like preferring workspace.customTitle in workspaceDisplayName() or altering move-target labels should be handled in a separate follow-up issue.

Learnt from: jt-hsiao
Repo: manaflow-ai/cmux PR: 1423
File: Sources/AppDelegate.swift:11220-11226
Timestamp: 2026-03-14T07:06:01.466Z
Learning: Repo: manaflow-ai/cmux — Command-backtick (Cmd+`, keyCode 50) is intentionally excluded from direct menu routing by shouldRouteCommandEquivalentDirectlyToMainMenu; tests assert this behavior. Do not add a window/main-menu bypass for Cmd+`.

Learnt from: SuperManfred
Repo: manaflow-ai/cmux PR: 1150
File: Sources/AppDelegate.swift:7394-7401
Timestamp: 2026-03-10T10:24:14.017Z
Learning: Repo: manaflow-ai/cmux — For Cmd+W behavior with browser popups: BrowserPopupPanel.performKeyEquivalent is the primary handler to close popups. AppDelegate.handleCustomShortcut(_:), in its Cmd+W fallback, must check both NSApp.keyWindow and event.window for identifier "cmux.browser-popup" and close it if found, before routing to workspace/settings close logic.
✅ Actions performed

Full review triggered.

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

1 similar comment
@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@pratikpakhale pratikpakhale changed the title Cmux json feat: cmux.json for custom commands Mar 23, 2026
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 32 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/messages/da.json">

<violation number="1" location="web/messages/da.json:352">
P2: Danish translation typo in `surfaceDefinition` produces malformed user-facing docs text.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread web/messages/da.json Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 23, 2026

Greptile Summary

This PR introduces cmux.json configuration support, allowing users to define custom commands in a per-project or global config file that appear in the command palette. It adds live file watching, local/global config merging, workspace layout creation (with nested splits, browser panes, env vars, and auto-run commands), and three restart behaviors. The implementation is well-structured — CmuxConfig.swift cleanly models the schema, CmuxConfigExecutor.swift handles command dispatch, and the Workspace.swift extension recursively builds split trees from the config. Test coverage is solid.

Key issues found:

  • description field unused in palette: CmuxCommandDefinition.description is part of the documented schema but is never forwarded to CommandPaletteCommandContribution, so descriptions have no visible effect for users.
  • Silent no-op for misconfigured commands: If a command entry has neither workspace nor command (e.g. a typo in the key name), execute silently does nothing — the command appears in the palette but produces no feedback.
  • Redundant loadAll() on startup: wireDirectoryTracking already calls loadAll() for the initial directory; the explicit cmuxConfigStore.loadAll() call immediately after in cmuxApp.swift is a duplicate parse.
  • Confirm dialog omits command string: The confirm: true alert shows a generic message without displaying which command will be run, limiting its usefulness for destructive operations.

Confidence Score: 4/5

  • Safe to merge with a small fix to surface the description field and handle the no-op case.
  • The core feature is well-implemented with good test coverage, proper file watching lifecycle management, and correct @MainActor threading discipline. Two P1 issues exist — the unused description field (schema/doc inconsistency) and the silent no-op for misconfigured commands — but neither breaks the primary user path. The redundant loadAll() and missing command detail in the confirm dialog are non-blocking style issues. A targeted fix to these four points would make this ready.
  • Sources/CmuxConfigExecutor.swift (silent no-op, confirm dialog UX) and Sources/ContentView.swift (unused description field)

Important Files Changed

Filename Overview
Sources/CmuxConfig.swift New file: defines all config data models and CmuxConfigStore with live file watching via DispatchSource for both local and global config paths. Logic is well-structured; minor concern around double loadAll() at startup.
Sources/CmuxConfigExecutor.swift New file: executes cmux.json commands; silent no-op when neither workspace nor command is set, and confirm dialog omits the actual command string. Both are actionable issues.
Sources/ContentView.swift Integrates CmuxConfigStore into the command palette; description field from CmuxCommandDefinition is never forwarded to CommandPaletteCommandContribution, so user-provided descriptions have no visible effect.
Sources/Workspace.swift Adds applyCustomLayout and supporting helpers for building split trees from config; sendInputWhenReady handles async surface readiness with a 3s timeout fallback. Logic appears correct.
Sources/GhosttyTerminalView.swift Adds sendInput() that correctly routes control characters (Return, Tab, Escape) as key events while buffering regular text for batch delivery.
Sources/cmuxApp.swift Wires CmuxConfigStore into the app lifecycle; explicit loadAll() call after wireDirectoryTracking is redundant as wireDirectoryTracking already triggers loadAll() for the initial directory.
cmuxTests/CmuxConfigTests.swift Comprehensive unit tests covering simple commands, workspace definitions, nested split trees, surface fields, cwd resolution, and restart behaviors. Good coverage of the new schema.

Sequence Diagram

sequenceDiagram
    participant App as cmuxApp (onAppear)
    participant Store as CmuxConfigStore
    participant FS as FileSystem (DispatchSource)
    participant CV as ContentView
    participant Exec as CmuxConfigExecutor
    participant WS as Workspace

    App->>Store: wireDirectoryTracking(tabManager)
    Store->>Store: updateLocalConfigPath(directory)
    Store->>Store: loadAll() — parses local + global cmux.json
    App->>Store: loadAll() (redundant second call)

    FS-->>Store: file write/delete/rename event
    Store->>Store: loadAll() — reloads config
    Store->>CV: @Published loadedCommands / configRevision changes

    CV->>CV: commandPaletteCommandsFingerprint changes
    CV->>CV: registerCommandPaletteCommands() — re-registers handlers

    note over CV: User selects custom command in palette
    CV->>Exec: CmuxConfigExecutor.execute(command, tabManager, baseCwd)

    alt has workspace definition
        Exec->>Exec: check for existing workspace by name
        alt restart == ignore
            Exec->>WS: selectWorkspace(existing)
        else restart == recreate
            Exec->>WS: closeWorkspace(existing)
            Exec->>WS: addWorkspace + applyCustomLayout
        else restart == confirm
            Exec->>Exec: show NSAlert
            Exec->>WS: closeWorkspace + addWorkspace + applyCustomLayout
        end
    else has shell command
        Exec->>Exec: optionally show confirm NSAlert
        Exec->>WS: focusedTerminalPanel.sendInput(command + newline)
    end
Loading

Comments Outside Diff (4)

  1. Sources/ContentView.swift, line 5963-5821 (link)

    P2 description field defined but unused in command palette

    CmuxCommandDefinition declares a description field that is part of the documented cmux.json schema, but it is never passed to CommandPaletteCommandContribution. If the contribution type supports a detail/description parameter, users who set description in their config will see no effect and may be confused.

    contributions.append(
        CommandPaletteCommandContribution(
            commandId: command.id,
            title: constant(String(localized: "command.cmuxConfig.customTitle", defaultValue: "Custom: \(commandName)")),
            subtitle: cmuxConfigSubtitle,
            keywords: command.keywords ?? []
            // description: command.description — field is never forwarded
        )
    )

    Please verify whether CommandPaletteCommandContribution supports a description/detail field and thread command.description through if so, or remove the description field from the schema if it is intentionally unused.

  2. Sources/CmuxConfigExecutor.swift, line 1703-1709 (link)

    P2 Silent no-op for commands with neither workspace nor command

    When a CmuxCommandDefinition has neither a workspace nor a command key (e.g., a user misspells "command" as "commnd" in their cmux.json), execute silently does nothing. The command appears in the palette, the user selects it, and nothing happens — with no feedback or error.

    Consider adding a fallback log or user-visible alert:

    static func execute(
        command: CmuxCommandDefinition,
        tabManager: TabManager,
        baseCwd: String
    ) {
        if let workspace = command.workspace {
            executeWorkspaceCommand(command: command, workspace: workspace, tabManager: tabManager, baseCwd: baseCwd)
        } else if let shellCommand = command.command {
            executeSimpleCommand(shellCommand, confirm: command.confirm ?? false, tabManager: tabManager)
        } else {
            NSLog("[CmuxConfig] command '%@' has neither 'workspace' nor 'command' — nothing to execute", command.name)
        }
    }
  3. Sources/cmuxApp.swift, line 2179-2180 (link)

    P2 Double loadAll() invocation on startup

    wireDirectoryTracking already calls loadAll() at the end of updateLocalConfigPath when a selected workspace has a current directory. The explicit cmuxConfigStore.loadAll() on the very next line then triggers a second full parse of both config files.

    cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager)
    cmuxConfigStore.loadAll()  // redundant — wireDirectoryTracking already called loadAll() above

    Consider removing the explicit loadAll() call here (the wireDirectoryTracking path already handles the initial load), or guarding it to only call when there is no active workspace directory.

  4. Sources/CmuxConfigExecutor.swift, line 1718-1727 (link)

    P2 Confirm dialog doesn't show which command will be run

    The confirmation alert for a simple command uses the generic message "Are you sure you want to run this command?" without displaying the actual command string. When confirm: true is set, users typically want to verify the exact command before approving, especially for destructive operations like make deploy.

    Consider including the command in the informativeText:

    alert.informativeText = String(
        localized: "dialog.cmuxConfig.confirmCommand.message",
        defaultValue: "Are you sure you want to run this command?\n\n\(command)"
    )

Reviews (1): Last reviewed commit: "tests: add cmux json tests" | Re-trigger Greptile

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (2)
Sources/CmuxConfig.swift (2)

118-148: Singleton deinit is effectively dead code.

Since CmuxConfigStore.shared is a static singleton, deinit will never be called during normal app lifecycle. The cleanup code in lines 146-148 is unreachable.

This isn't a bug—it's just unnecessary code. You could remove the deinit entirely, or keep it for completeness if the singleton pattern might change in the future.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 118 - 148, The deinit in
CmuxConfigStore is unreachable because the singleton instance
CmuxConfigStore.shared never deallocates; remove the deinit block (the cancel
calls for localFileWatchSource and globalFileWatchSource) to avoid dead code, or
alternatively replace it by a public cleanup method (e.g., shutdown or tearDown)
that explicitly cancels localFileWatchSource, globalFileWatchSource and closes
descriptors so callers can perform cleanup if the lifecycle ever changes; update
any docs/comments to reference CmuxConfigStore.shared and the new cleanup method
if added.

226-238: Consider surfacing parse errors to users.

Currently, JSON parse errors are only logged via NSLog (line 235). Users editing cmux.json won't see feedback if their configuration is malformed—commands simply won't appear in the palette.

Consider adding a mechanism to surface configuration errors (e.g., a notification, status indicator, or diagnostic command) so users can troubleshoot issues with their cmux.json files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 226 - 238, parseConfig currently
swallows JSON decode failures (only NSLog) so users get no visible feedback for
malformed cmux.json; change parseConfig(at:) to surface errors by emitting a
user-facing event instead of just logging: keep the NSLog, then post a
Notification (e.g. Notification.Name("CmuxConfigParseError")) or call a new
CmuxConfigDelegate method (e.g. cmuxConfig(_:didFailParsingAtPath:error:)) with
the path and the caught error so the UI/command layer can show a
notification/status/diagnostic or populate a diagnostics command; reference the
parseConfig(at:), CmuxConfigFile, and the catch error to include in the
notification/delegate payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/cmuxApp.swift`:
- Line 142: The CmuxConfigStore is shared across windows
(CmuxConfigStore.shared) but only the primary window's TabManager is wired via
wireDirectoryTracking(tabManager:), causing secondary windows to show the
primary window's loadedCommands; fix by ensuring per-window wiring or per-window
command isolation—either update createMainWindow() to call
CmuxConfigStore.shared.wireDirectoryTracking(tabManager:) for every new window's
TabManager (reference createMainWindow(), TabManager, and
wireDirectoryTracking(tabManager:)), or change CmuxConfigStore to key
loadedCommands by window/workspace ID so each window uses its own cmux.json
state (reference CmuxConfigStore, loadedCommands).

In `@Sources/ContentView.swift`:
- Around line 6314-6320: The code uses
tabManager.selectedWorkspace?.currentDirectory directly for baseCwd, but
Workspace.currentDirectory can be an empty or whitespace string so the
nil-coalescing (??) won't substitute the home path; before calling
CmuxConfigExecutor.execute ensure you treat empty/whitespace as missing by
trimming and checking isEmpty (e.g., compute baseCwd from
tabManager.selectedWorkspace?.currentDirectory?.trimmingCharacters(in:
.whitespacesAndNewlines) and if that result is empty or nil fall back to
FileManager.default.homeDirectoryForCurrentUser.path) so
CmuxConfigExecutor.execute receives a valid non-empty working directory.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 3725-3730: The loop over text.unicodeScalars currently treats CR
(0x0D) and LF (0x0A) separately causing "\r\n" to produce two Return events;
modify the scalar handling in the loop inside GhosttyTerminalView (where
bufferedText is built, flushText(&bufferedText, surface:) is called and
sendKeyEvent(surface:keycode:) is invoked) to coalesce a CR followed immediately
by an LF into a single Return: when you see 0x0D, peek the next scalar (or
advance an index) and if it is 0x0A consume it without emitting a second Return,
otherwise emit the single Return as before, ensuring flushText is only called
once for the CR+LF pair.
- Around line 3723-3724: CmuxConfigExecutor.executeSimpleCommand currently calls
GhosttyTerminalView.sendInput(_:) directly which drops input if surface is nil;
change the call to follow the Workspace.sendInputWhenReady pattern: check
panel.surface.surface != nil before calling sendInput, and if nil register/defer
the input to be sent when the .terminalSurfaceDidBecomeReady event fires (or
invoke the same queuing used by sendText). Ensure you reference
CmuxConfigExecutor.executeSimpleCommand, GhosttyTerminalView.sendInput(_:),
Workspace.sendInputWhenReady, panel.surface.surface and the
.terminalSurfaceDidBecomeReady notification so the command is queued and
delivered when the terminal surface becomes ready.

In `@Sources/Workspace.swift`:
- Around line 891-916: In sendInputWhenReady(_:, to:) remove the 3s
DispatchQueue.main.asyncAfter timeout and the NSLog that prints command text;
instead keep the input queued until panel.surface becomes ready or the panel is
closed/destroyed, and ensure the NotificationCenter observer is removed on
close; when emitting any failure log, log only non-secret identifiers (e.g.
panel.identifier or panel.surface.identifier) rather than the command text.
Locate the observer setup in sendInputWhenReady, delete the timeout block, add a
cleanup path triggered by panel/surface teardown (listen for a close/didDestroy
notification or implement deinit/close callback) that removes the observer and
drops the queued input without logging secrets.

In `@web/messages/da.json`:
- Line 352: The Danish translation for the key "surfaceDefinition" contains an
extraneous space ("Overfladedef inition"); remove the space so the value reads
"Overfladedefinition" by updating the value for the "surfaceDefinition" key in
the da.json translations so it matches the correct single-word spelling.

In `@web/messages/tr.json`:
- Around line 305-368: The Turkish translations put the customCommands object
under docs.configuration.customCommands but the page expects docs.customCommands
(see useTranslations("docs.customCommands") and getTranslations({ locale,
namespace: "docs.customCommands" })); move the entire "customCommands" object
out of the "configuration" object so it sits as a sibling of "configuration"
under "docs" (matching the structure used in en.json) so lookup by
docs.customCommands resolves correctly.

---

Nitpick comments:
In `@Sources/CmuxConfig.swift`:
- Around line 118-148: The deinit in CmuxConfigStore is unreachable because the
singleton instance CmuxConfigStore.shared never deallocates; remove the deinit
block (the cancel calls for localFileWatchSource and globalFileWatchSource) to
avoid dead code, or alternatively replace it by a public cleanup method (e.g.,
shutdown or tearDown) that explicitly cancels localFileWatchSource,
globalFileWatchSource and closes descriptors so callers can perform cleanup if
the lifecycle ever changes; update any docs/comments to reference
CmuxConfigStore.shared and the new cleanup method if added.
- Around line 226-238: parseConfig currently swallows JSON decode failures (only
NSLog) so users get no visible feedback for malformed cmux.json; change
parseConfig(at:) to surface errors by emitting a user-facing event instead of
just logging: keep the NSLog, then post a Notification (e.g.
Notification.Name("CmuxConfigParseError")) or call a new CmuxConfigDelegate
method (e.g. cmuxConfig(_:didFailParsingAtPath:error:)) with the path and the
caught error so the UI/command layer can show a notification/status/diagnostic
or populate a diagnostics command; reference the parseConfig(at:),
CmuxConfigFile, and the catch error to include in the notification/delegate
payload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0c8508f-119e-4cec-b255-4f40f887259b

📥 Commits

Reviewing files that changed from the base of the PR and between b5fb304 and b02ad07.

📒 Files selected for processing (32)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigExecutor.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • cmuxTests/CmuxConfigTests.swift
  • web/app/[locale]/components/docs-nav-items.ts
  • web/app/[locale]/docs/custom-commands/page.tsx
  • web/messages/ar.json
  • web/messages/bs.json
  • web/messages/da.json
  • web/messages/de.json
  • web/messages/en.json
  • web/messages/es.json
  • web/messages/fr.json
  • web/messages/it.json
  • web/messages/ja.json
  • web/messages/km.json
  • web/messages/ko.json
  • web/messages/no.json
  • web/messages/pl.json
  • web/messages/pt-BR.json
  • web/messages/ru.json
  • web/messages/th.json
  • web/messages/tr.json
  • web/messages/zh-CN.json
  • web/messages/zh-TW.json

Comment thread Sources/cmuxApp.swift Outdated
Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/Workspace.swift
Comment thread web/messages/da.json Outdated
Comment thread web/messages/tr.json
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

♻️ Duplicate comments (6)
Sources/cmuxApp.swift (1)

142-142: ⚠️ Potential issue | 🟠 Major

Make cmux.json state window-scoped, not app-scoped.

Line 142 shares one CmuxConfigStore across the app, but Lines 351-352 wire and load it only from the primary window’s tabManager. In a multi-window session, secondary windows can only see whichever repo the primary window last loaded, so their command palette can surface the wrong cmux.json commands. Please give each main window its own store, or key the store’s loaded commands/watch state by window/workspace and wire every window’s TabManager.

Also applies to: 351-352

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` at line 142, The app currently uses a single shared
CmuxConfigStore (cmuxConfigStore = CmuxConfigStore.shared) which makes cmux.json
state app-scoped; change it so each main window gets its own store instance or a
store keyed by window/workspace and ensure each window's TabManager wires/loads
its own store (instead of only the primary window calling TabManager.load on the
shared store). Concretely, replace the app-scoped `@StateObject` cmuxConfigStore
with a window-local StateObject (or a factory that returns
CmuxConfigStore(windowID)) and update where TabManager.load/TabManager wires the
store (the code around the TabManager wiring at the previous load calls) so
every window calls the load/watch methods for its own CmuxConfigStore keyed to
that window/workspace.
web/messages/da.json (1)

352-352: ⚠️ Potential issue | 🟡 Minor

Fix typo in Danish translation string.

Line 352 still contains a split word: "Overfladedef inition" should be "Overfladedefinition".

✏️ Suggested fix
-      "surfaceDefinition": "Overfladedef inition",
+      "surfaceDefinition": "Overfladedefinition",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/messages/da.json` at line 352, The translation value for the key
"surfaceDefinition" contains a split word "Overfladedef inition"; update the
Danish JSON entry for "surfaceDefinition" to the correct single word
"Overfladedefinition" so the string reads "surfaceDefinition":
"Overfladedefinition".
Sources/ContentView.swift (1)

6314-6320: ⚠️ Potential issue | 🟡 Minor

Treat blank working directories as missing before falling back.

Workspace.currentDirectory is already normalized elsewhere in this file. The ?? only handles nil, so "" or whitespace can still flow into CmuxConfigExecutor.execute(...) and break relative-path resolution.

🛠️ Suggested fix
-                let baseCwd = tabManager.selectedWorkspace?.currentDirectory
-                    ?? FileManager.default.homeDirectoryForCurrentUser.path
+                let baseCwd: String = {
+                    let candidate = tabManager.selectedWorkspace?.currentDirectory
+                        .trimmingCharacters(in: .whitespacesAndNewlines)
+                    guard let candidate, !candidate.isEmpty else {
+                        return FileManager.default.homeDirectoryForCurrentUser.path
+                    }
+                    return candidate
+                }()
                 CmuxConfigExecutor.execute(
                     command: captured,
                     tabManager: tabManager,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 6314 - 6320, The selected workspace's
currentDirectory can be an empty or whitespace string and should be treated as
missing before falling back; when building baseCwd (from
tabManager.selectedWorkspace?.currentDirectory) trim whitespace and check for
emptiness and if empty use FileManager.default.homeDirectoryForCurrentUser.path,
then pass that sanitized baseCwd into CmuxConfigExecutor.execute to avoid
propagating blank paths into relative-path resolution.
Sources/GhosttyTerminalView.swift (2)

3723-3724: ⚠️ Potential issue | 🔴 Critical

sendInput currently drops commands when the surface is not ready

At Line 3724, returning immediately on surface == nil can lose config-driven/auto-run input during terminal startup. This path should queue/defer like sendText(_:) instead of dropping.

💡 Suggested fix
 func sendInput(_ text: String) {
-    guard let surface = surface else { return }
+    guard let surface = surface else {
+        enqueuePendingInput(text)
+        return
+    }
     var bufferedText = ""
     for scalar in text.unicodeScalars {
         switch scalar.value {
// Add a pending input queue (parallel to pendingTextQueue) and flush it
// after surface creation (near flushPendingTextIfNeeded()) so control-key
// semantics are preserved once the runtime surface is ready.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 3723 - 3724, sendInput
currently returns when surface is nil and drops commands; instead create a
pendingInputQueue (parallel to pendingTextQueue) and push incoming text into it
when surface == nil inside sendInput, then flush that queue from the same place
you flush pendingText (e.g. inside flushPendingTextIfNeeded() or immediately
after surface is created) to preserve control-key semantics; update sendInput,
where you reference surface and sendText, to dequeue and send pending inputs
once flushPendingTextIfNeeded() runs and ensure ordering with pendingTextQueue
is maintained.

3726-3730: ⚠️ Potential issue | 🟡 Minor

Coalesce CRLF into a single Return key event

Line 3728 treats \r and \n independently, so "\r\n" emits two Return presses.

💡 Suggested fix
-        for scalar in text.unicodeScalars {
+        let scalars = Array(text.unicodeScalars)
+        var index = 0
+        while index < scalars.count {
+            let scalar = scalars[index]
             switch scalar.value {
-            case 0x0A, 0x0D:
+            case 0x0D:
                 flushText(&bufferedText, surface: surface)
                 sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return
+                if index + 1 < scalars.count, scalars[index + 1].value == 0x0A {
+                    index += 1 // consume LF in CRLF
+                }
+            case 0x0A:
+                flushText(&bufferedText, surface: surface)
+                sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return
             case 0x09:
                 flushText(&bufferedText, surface: surface)
                 sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab
             case 0x1B:
                 flushText(&bufferedText, surface: surface)
                 sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape
             default:
                 bufferedText.unicodeScalars.append(scalar)
             }
+            index += 1
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 3726 - 3730, The loop over
text.unicodeScalars in GhosttyTerminalView is treating 0x0D (CR) and 0x0A (LF)
as separate Return key presses; change the logic in the loop that calls
flushText(&bufferedText, surface: surface) and sendKeyEvent(surface: surface,
keycode: 0x24) so that a CR immediately followed by an LF only emits one Return
event. Implement a small state flag (e.g., lastWasCR) or remember the previous
scalar in the loop that, when you see LF (0x0A), checks if lastWasCR is true and
if so only performs the flush without calling sendKeyEvent again; ensure you
still flush once and reset the flag appropriately; update references in the loop
that use text.unicodeScalars, bufferedText, flushText, and sendKeyEvent.
Sources/Workspace.swift (1)

891-916: ⚠️ Potential issue | 🟠 Major

Don't time out and log queued startup commands.

The 3s timeout on Line 911 still makes auto-run nondeterministic for focus: false terminals that stay cold until selected, and Line 915 still writes user-provided command text to Console. Keep the input queued until the terminal becomes ready or the panel is torn down, trigger a background surface start for hidden terminals, and log only opaque identifiers if cleanup fails.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 891 - 916, sendInputWhenReady currently
gives up after 3s and logs the user command text; instead keep the input queued
until the surface becomes ready or the panel is torn down, trigger a background
start for hidden surfaces, and avoid logging sensitive command text. Update
sendInputWhenReady to remove the DispatchQueue.main.asyncAfter timeout, add an
observer for panel/ surface teardown (so queued input is discarded when the
panel is removed), and proactively request the surface to start in background if
hidden (call the surface's background-start API on panel.surface). When cleanup
fails, log only an opaque identifier (e.g. panel.identifier or
panel.surface.identifier) rather than the command text, and ensure observer
removal still happens in both success and teardown paths (references:
sendInputWhenReady, TerminalPanel, panel.surface,
.terminalSurfaceDidBecomeReady, panel.sendInput).
🧹 Nitpick comments (1)
web/app/[locale]/docs/custom-commands/page.tsx (1)

38-290: Consider extracting large inline JSON examples into constants.

The page is readable now, but moving long literals out of JSX will make future edits and reviews easier.

♻️ Minimal refactor pattern
+const SCHEMA_EXAMPLE = `{
+  "commands": [
+    {
+      "name": "Start Dev",
+      "keywords": ["dev", "start"],
+      "workspace": { ... }
+    }
+  ]
+}`;
+
 export default function CustomCommandsPage() {
   const t = useTranslations("docs.customCommands");
@@
-      <CodeBlock title="cmux.json" lang="json">{`{
-  "commands": [
-    {
-      "name": "Start Dev",
-      "keywords": ["dev", "start"],
-      "workspace": { ... }
-    },
-    {
-      "name": "Run Tests",
-      "command": "npm test",
-      "confirm": true
-    }
-  ]
-}`}</CodeBlock>
+      <CodeBlock title="cmux.json" lang="json">{SCHEMA_EXAMPLE}</CodeBlock>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/`[locale]/docs/custom-commands/page.tsx around lines 38 - 290, The
JSX contains multiple large inline JSON examples inside CodeBlock elements
(e.g., the long cmux.json literals rendered by <CodeBlock title="cmux.json"
lang="json">); extract each large JSON string into named top-level constants
(for example FULL_EXAMPLE_JSON, WORKSPACE_EXAMPLE_JSON, SIMPLE_COMMANDS_JSON,
etc.) defined near the top of the module and replace the inline template strings
with those constant references while keeping the existing <CodeBlock ...> props
(title/lang) and translations (t(...)) unchanged so editors/reviewers can edit
examples more easily.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Resources/Localizable.xcstrings`:
- Around line 15256-15368: The localized key command.cmuxConfig.customTitle
contains a % @ placeholder but the usage in ContentView (where the command title
is composed) currently interpolates the localized string directly, causing the
literal placeholder to appear; wrap the localized lookup with
String.localizedStringWithFormat(String(localized:
"command.cmuxConfig.customTitle", defaultValue: "Custom: %@"), commandName) so
the placeholder is substituted — update the code that builds the custom title in
ContentView to use String.localizedStringWithFormat with String(localized:) and
pass the command name (e.g., commandName) as the formatting argument.

In `@Sources/CmuxConfig.swift`:
- Around line 118-123: CmuxConfigStore currently uses a global singleton
(CmuxConfigStore.shared) holding localConfigPath, loadedCommands and
subscriptions which causes per-window configs to collide; change it to be a
per-window instance instead: remove/stop using the static shared, add an init
that accepts the window-scoped owner (e.g., FileBrowserDrawerState or
TabManager) or a localConfigPath, move wiring logic in
wireDirectoryTracking(tabManager:) to operate on instance properties
(localConfigPath, loadedCommands, subscription set) and ensure each window
creates and owns its own CmuxConfigStore (ContentView.onAppear /
AppDelegate.createMainWindow should instantiate and pass the store into views)
so subscriptions are not torn down by other windows and each window shows its
own command set.
- Around line 9-16: CmuxCommandDefinition currently allows ambiguous configs
because workspace and command are both optional; update its Decodable
implementation (implement init(from decoder:) for CmuxCommandDefinition) to
validate that exactly one of workspace or command is present and throw a
decoding error otherwise, rejecting entries where both are nil or both are
non-nil; keep the type conformances (Codable/Sendable/Identifiable) but perform
the cross-field check during decode and surface a clear decoding failure message
referencing the name (or index) to fail fast on hybrid/empty command
definitions.
- Around line 155-169: The code currently reconstructs local config from
workspace.$currentDirectory (in the tabManager.$selectedTabId pipeline and
similarly at 177-191), causing config to follow transient cd changes; change
updateLocalConfigPath usage to resolve a stable project root instead: when
reacting to workspace selection (tabManager.$selectedTabId -> workspace), call a
helper that walks the directory ancestors starting from
workspace.currentDirectory (or a stable workspace.initialDirectory/projectRoot
if available) up to filesystem root looking for "cmux.json" and set
localConfigPath to the nearest ancestor containing that file; update the logic
in updateLocalConfigPath (or replace its caller) to perform this ancestor search
(referencing workspace.$currentDirectory, updateLocalConfigPath(_:), and the
tabManager.$selectedTabId pipeline) so transient shell cd does not drop the
project-local config.

In `@Sources/ContentView.swift`:
- Around line 5967-5974: The code currently uses user-controlled command.id from
cmuxConfigStore.loadedCommands directly when creating
CommandPaletteCommandContribution, which can collide with built-in commands or
other custom IDs and later crash in
ContentView.refreshCommandPaletteSearchCorpus(force:query:); change the logic to
either prefix/namespace the stored IDs (e.g., "cmuxConfig.\(command.id)") before
passing them into CommandPaletteCommandContribution or validate/reject IDs that
collide with existing command IDs at the point where commands are
appended/registered, and ensure the same namespacing/validation is applied when
rebuilding the corpus in
ContentView.refreshCommandPaletteSearchCorpus(force:query:).

In `@web/messages/no.json`:
- Line 309: The "intro" message value contains a Norwegian typo: change the word
"rotmapp" to "rotmappe" in the JSON entry with key "intro" (the string
mentioning "cmux.json" and "~/.config/cmux/") so the sentence reads "...ved å
legge til en cmux.json-fil i prosjektets rotmappe eller ~/.config/cmux/....".

---

Duplicate comments:
In `@Sources/cmuxApp.swift`:
- Line 142: The app currently uses a single shared CmuxConfigStore
(cmuxConfigStore = CmuxConfigStore.shared) which makes cmux.json state
app-scoped; change it so each main window gets its own store instance or a store
keyed by window/workspace and ensure each window's TabManager wires/loads its
own store (instead of only the primary window calling TabManager.load on the
shared store). Concretely, replace the app-scoped `@StateObject` cmuxConfigStore
with a window-local StateObject (or a factory that returns
CmuxConfigStore(windowID)) and update where TabManager.load/TabManager wires the
store (the code around the TabManager wiring at the previous load calls) so
every window calls the load/watch methods for its own CmuxConfigStore keyed to
that window/workspace.

In `@Sources/ContentView.swift`:
- Around line 6314-6320: The selected workspace's currentDirectory can be an
empty or whitespace string and should be treated as missing before falling back;
when building baseCwd (from tabManager.selectedWorkspace?.currentDirectory) trim
whitespace and check for emptiness and if empty use
FileManager.default.homeDirectoryForCurrentUser.path, then pass that sanitized
baseCwd into CmuxConfigExecutor.execute to avoid propagating blank paths into
relative-path resolution.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 3723-3724: sendInput currently returns when surface is nil and
drops commands; instead create a pendingInputQueue (parallel to
pendingTextQueue) and push incoming text into it when surface == nil inside
sendInput, then flush that queue from the same place you flush pendingText (e.g.
inside flushPendingTextIfNeeded() or immediately after surface is created) to
preserve control-key semantics; update sendInput, where you reference surface
and sendText, to dequeue and send pending inputs once flushPendingTextIfNeeded()
runs and ensure ordering with pendingTextQueue is maintained.
- Around line 3726-3730: The loop over text.unicodeScalars in
GhosttyTerminalView is treating 0x0D (CR) and 0x0A (LF) as separate Return key
presses; change the logic in the loop that calls flushText(&bufferedText,
surface: surface) and sendKeyEvent(surface: surface, keycode: 0x24) so that a CR
immediately followed by an LF only emits one Return event. Implement a small
state flag (e.g., lastWasCR) or remember the previous scalar in the loop that,
when you see LF (0x0A), checks if lastWasCR is true and if so only performs the
flush without calling sendKeyEvent again; ensure you still flush once and reset
the flag appropriately; update references in the loop that use
text.unicodeScalars, bufferedText, flushText, and sendKeyEvent.

In `@Sources/Workspace.swift`:
- Around line 891-916: sendInputWhenReady currently gives up after 3s and logs
the user command text; instead keep the input queued until the surface becomes
ready or the panel is torn down, trigger a background start for hidden surfaces,
and avoid logging sensitive command text. Update sendInputWhenReady to remove
the DispatchQueue.main.asyncAfter timeout, add an observer for panel/ surface
teardown (so queued input is discarded when the panel is removed), and
proactively request the surface to start in background if hidden (call the
surface's background-start API on panel.surface). When cleanup fails, log only
an opaque identifier (e.g. panel.identifier or panel.surface.identifier) rather
than the command text, and ensure observer removal still happens in both success
and teardown paths (references: sendInputWhenReady, TerminalPanel,
panel.surface, .terminalSurfaceDidBecomeReady, panel.sendInput).

In `@web/messages/da.json`:
- Line 352: The translation value for the key "surfaceDefinition" contains a
split word "Overfladedef inition"; update the Danish JSON entry for
"surfaceDefinition" to the correct single word "Overfladedefinition" so the
string reads "surfaceDefinition": "Overfladedefinition".

---

Nitpick comments:
In `@web/app/`[locale]/docs/custom-commands/page.tsx:
- Around line 38-290: The JSX contains multiple large inline JSON examples
inside CodeBlock elements (e.g., the long cmux.json literals rendered by
<CodeBlock title="cmux.json" lang="json">); extract each large JSON string into
named top-level constants (for example FULL_EXAMPLE_JSON,
WORKSPACE_EXAMPLE_JSON, SIMPLE_COMMANDS_JSON, etc.) defined near the top of the
module and replace the inline template strings with those constant references
while keeping the existing <CodeBlock ...> props (title/lang) and translations
(t(...)) unchanged so editors/reviewers can edit examples more easily.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b583a47e-d56f-48b5-8782-d2f645513dc6

📥 Commits

Reviewing files that changed from the base of the PR and between b5fb304 and b02ad07.

📒 Files selected for processing (32)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigExecutor.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • cmuxTests/CmuxConfigTests.swift
  • web/app/[locale]/components/docs-nav-items.ts
  • web/app/[locale]/docs/custom-commands/page.tsx
  • web/messages/ar.json
  • web/messages/bs.json
  • web/messages/da.json
  • web/messages/de.json
  • web/messages/en.json
  • web/messages/es.json
  • web/messages/fr.json
  • web/messages/it.json
  • web/messages/ja.json
  • web/messages/km.json
  • web/messages/ko.json
  • web/messages/no.json
  • web/messages/pl.json
  • web/messages/pt-BR.json
  • web/messages/ru.json
  • web/messages/th.json
  • web/messages/tr.json
  • web/messages/zh-CN.json
  • web/messages/zh-TW.json

Comment thread Resources/Localizable.xcstrings
Comment thread Sources/CmuxConfig.swift
Comment thread Sources/CmuxConfig.swift
Comment thread Sources/CmuxConfig.swift
Comment thread Sources/CmuxConfig.swift
Comment thread web/messages/no.json Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
Sources/ContentView.swift (1)

6314-6317: ⚠️ Potential issue | 🟡 Minor

Trim whitespace-only working directories before falling back.

Line 6315 still only checks isEmpty, so " " bypasses the fallback and gets forwarded into CmuxConfigExecutor.execute. That leaves relative custom commands resolving against an invalid base path even though other currentDirectory reads in this file trim before checking emptiness.

Suggested fix
-                let rawCwd = tabManager.selectedWorkspace?.currentDirectory
-                let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd!
-                    : FileManager.default.homeDirectoryForCurrentUser.path
+                let baseCwd: String = {
+                    let candidate = tabManager.selectedWorkspace?.currentDirectory?
+                        .trimmingCharacters(in: .whitespacesAndNewlines)
+                    guard let candidate, !candidate.isEmpty else {
+                        return FileManager.default.homeDirectoryForCurrentUser.path
+                    }
+                    return candidate
+                }()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 6314 - 6317, The code registers a
command and sets baseCwd from rawCwd but only checks rawCwd.isEmpty, so
whitespace-only values like "   " bypass the fallback; update the
registry.register closure that reads let rawCwd =
tabManager.selectedWorkspace?.currentDirectory to trim the value (e.g., use
rawCwd?.trimmingCharacters(in: .whitespacesAndNewlines)) and then check the
trimmed string's isEmpty; set baseCwd to the trimmed path when non-empty,
otherwise fall back to FileManager.default.homeDirectoryForCurrentUser.path so
CmuxConfigExecutor.execute receives a valid base path.
Sources/CmuxConfig.swift (2)

216-232: ⚠️ Potential issue | 🔴 Critical

CmuxConfigStore.shared is still cross-window state.

shared owns one localConfigPath, one loadedCommands, and one subscription set. Every wireDirectoryTracking(tabManager:) call replaces the previous window’s tracking, so multi-window sessions can show project-A commands in project-B and execute them in the wrong workspace. Based on learnings: AppDelegate.registerMainWindow ownership is window-scoped; primary and secondary windows each own their passed state to avoid re-registration mismatches.

Also applies to: 248-266

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 216 - 232, CmuxConfigStore.shared
currently holds window-scoped state (localConfigPath, loadedCommands,
cancellables, file watch descriptors, and subscriptions) which leads to
cross-window leaks when wireDirectoryTracking(tabManager:) is called; change
ownership so each window gets its own CmuxConfigStore instance instead of using
the global static shared: remove or stop using the static let shared singleton
for window-specific flows, make the API accept/produce a per-window
CmuxConfigStore, ensure wireDirectoryTracking(tabManager:) is an instance method
on CmuxConfigStore (or accept a store instance) so each window maintains its own
localConfigPath, loadedCommands, cancellables,
localFileWatchSource/localFileDescriptor and
globalFileWatchSource/globalFileDescriptor, and update registration code (where
AppDelegate.registerMainWindow or callers use CmuxConfigStore.shared) to create
and retain a new CmuxConfigStore per window.

251-265: ⚠️ Potential issue | 🟠 Major

Project-local config still follows shell cd.

This pipeline recomputes localConfigPath from workspace.currentDirectory on every directory change, so a workspace that starts at /repo drops /repo/cmux.json as soon as the shell moves into /repo/subdir. Resolve from a stable project root, or walk ancestors to the nearest cmux.json, instead of rebinding to transient cwd.

Also applies to: 273-287

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 251 - 265, The pipeline currently
recomputes localConfigPath directly from workspace.$currentDirectory (in the
tabManager.$selectedTabId subscription and the similar block at lines 273-287),
which follows transient shell cd; change the logic so you resolve a stable
project root instead of rebinding to cwd: when a tab becomes selected (the
tabManager.$selectedTabId subscription) use the tab's workspace to locate the
repository/project root by walking ancestors from workspace.currentDirectory
until you find the nearest "cmux.json" (or a VCS root), and set localConfigPath
to that discovered path (falling back to the original workspace path if nothing
found). Update the code that calls updateLocalConfigPath to accept the stable
project-root result (or add a new helper like findNearestProjectRoot(from:) used
by updateLocalConfigPath) and apply the same change to the other similar
subscription block so both use ancestor-walking resolution rather than the
transient cwd.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/CmuxConfig.swift`:
- Around line 40-65: The decoder allows blank strings for required name and
optional command; update init(from decoder: Decoder) to validate that
name.trimmingCharacters(in: .whitespacesAndNewlines) is not empty and reject it
by throwing DecodingError.dataCorrupted (with appropriate Context) if it is, and
likewise treat command as invalid when decoded if
command?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty — either set it
to nil or throw (prefer throwing) with a clear debugDescription; adjust the
checks around workspace/command after trimming so the existing mutual-exclusion
and presence validation uses the trimmed values.

In `@Sources/ContentView.swift`:
- Around line 5967-5975: Sanitize command.name and command.description from
cmuxConfigStore.loadedCommands using the same dangerousScalars/scalar-filtering
utility used in TerminalController.swift and BrowserPanel.swift before creating
CommandPaletteCommandContribution; replace usages of command.name and
command.description with their sanitized equivalents and if the sanitized
description is empty, use cmuxConfigDefaultSubtitle as the subtitle. Ensure the
title still includes the sanitized commandName in the localized
"command.cmuxConfig.customTitle" call and preserve keywords and command.id
unchanged.

---

Duplicate comments:
In `@Sources/CmuxConfig.swift`:
- Around line 216-232: CmuxConfigStore.shared currently holds window-scoped
state (localConfigPath, loadedCommands, cancellables, file watch descriptors,
and subscriptions) which leads to cross-window leaks when
wireDirectoryTracking(tabManager:) is called; change ownership so each window
gets its own CmuxConfigStore instance instead of using the global static shared:
remove or stop using the static let shared singleton for window-specific flows,
make the API accept/produce a per-window CmuxConfigStore, ensure
wireDirectoryTracking(tabManager:) is an instance method on CmuxConfigStore (or
accept a store instance) so each window maintains its own localConfigPath,
loadedCommands, cancellables, localFileWatchSource/localFileDescriptor and
globalFileWatchSource/globalFileDescriptor, and update registration code (where
AppDelegate.registerMainWindow or callers use CmuxConfigStore.shared) to create
and retain a new CmuxConfigStore per window.
- Around line 251-265: The pipeline currently recomputes localConfigPath
directly from workspace.$currentDirectory (in the tabManager.$selectedTabId
subscription and the similar block at lines 273-287), which follows transient
shell cd; change the logic so you resolve a stable project root instead of
rebinding to cwd: when a tab becomes selected (the tabManager.$selectedTabId
subscription) use the tab's workspace to locate the repository/project root by
walking ancestors from workspace.currentDirectory until you find the nearest
"cmux.json" (or a VCS root), and set localConfigPath to that discovered path
(falling back to the original workspace path if nothing found). Update the code
that calls updateLocalConfigPath to accept the stable project-root result (or
add a new helper like findNearestProjectRoot(from:) used by
updateLocalConfigPath) and apply the same change to the other similar
subscription block so both use ancestor-walking resolution rather than the
transient cwd.

In `@Sources/ContentView.swift`:
- Around line 6314-6317: The code registers a command and sets baseCwd from
rawCwd but only checks rawCwd.isEmpty, so whitespace-only values like "   "
bypass the fallback; update the registry.register closure that reads let rawCwd
= tabManager.selectedWorkspace?.currentDirectory to trim the value (e.g., use
rawCwd?.trimmingCharacters(in: .whitespacesAndNewlines)) and then check the
trimmed string's isEmpty; set baseCwd to the trimmed path when non-empty,
otherwise fall back to FileManager.default.homeDirectoryForCurrentUser.path so
CmuxConfigExecutor.execute receives a valid base path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bb6dc663-ffb6-4ea3-b340-c343f620d313

📥 Commits

Reviewing files that changed from the base of the PR and between b02ad07 and 4fd4e2e.

📒 Files selected for processing (7)
  • Sources/CmuxConfig.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Workspace.swift
  • cmuxTests/CmuxConfigTests.swift
  • web/messages/da.json
  • web/messages/no.json
✅ Files skipped from review due to trivial changes (4)
  • web/messages/da.json
  • web/messages/no.json
  • Sources/Workspace.swift
  • cmuxTests/CmuxConfigTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/GhosttyTerminalView.swift

Comment thread Sources/CmuxConfig.swift
Comment thread Sources/ContentView.swift
@pratikpakhale
Copy link
Copy Markdown
Contributor Author

@coderabbitai review further

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

@pratikpakhale Sure! I'll conduct a deeper review of PR #2011 now, covering all the new files and changes in detail.


🧠 Learnings used
Learnt from: homanp
Repo: manaflow-ai/cmux PR: 883
File: CLI/cmux.swift:1569-1586
Timestamp: 2026-03-04T22:05:07.913Z
Learning: In manaflow-ai/cmux CLI (CLI/cmux.swift), for parity with existing browser commands, markdown.open intentionally normalizes --surface before resolving --workspace/--window, relying on server-side resolution to disambiguate final routing. Avoid one-off reordering in markdown; consider any change only as a coordinated, cross-command refactor.

Learnt from: tayl0r
Repo: manaflow-ai/cmux PR: 1909
File: Sources/ContentView.swift:2156-2171
Timestamp: 2026-03-23T06:08:14.740Z
Learning: Repo: manaflow-ai/cmux — In Sources/ContentView.swift, openFileInTextEditor(_:) must attempt workspace.newTextEditorSplit(from:orientation:filePath:focus:) and, if that returns nil, fall back to workspace.newTextEditorSurface(inPane:filePath:focus:) using bonsplitController.focusedPaneId. Rationale: avoid dropping file-open requests when the focused panel is not pane-backed.

Learnt from: MaTriXy
Repo: manaflow-ai/cmux PR: 1460
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-16T08:02:13.558Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, socket v2 methods that accept surface_id/panel_id must handle cross-window routing. For panel background state mutations, v2PanelMarkBackground(params:) and v2PanelMarkForeground(params:) first try v2ResolveTabManager(params:), but only accept it if that manager actually owns the panelId; otherwise they fall back to AppDelegate.shared?.locateSurface(surfaceId:) to find the correct TabManager across windows. This pattern should be used for any panel_id-only routes to avoid active-window bias.

Learnt from: SuperManfred
Repo: manaflow-ai/cmux PR: 1150
File: Sources/AppDelegate.swift:7394-7401
Timestamp: 2026-03-10T10:24:14.017Z
Learning: Repo: manaflow-ai/cmux — For Cmd+W behavior with browser popups: BrowserPopupPanel.performKeyEquivalent is the primary handler to close popups. AppDelegate.handleCustomShortcut(_:), in its Cmd+W fallback, must check both NSApp.keyWindow and event.window for identifier "cmux.browser-popup" and close it if found, before routing to workspace/settings close logic.

Learnt from: jt-hsiao
Repo: manaflow-ai/cmux PR: 1423
File: Sources/AppDelegate.swift:11220-11226
Timestamp: 2026-03-14T07:06:01.466Z
Learning: Repo: manaflow-ai/cmux — In Sources/Panels/CmuxWebView.swift, performKeyEquivalent(with:) handles Command-key routing end-to-end: (1) if allowed, route to NSApp.mainMenu.performKeyEquivalent; (2) fall back to AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) for non–menu-backed app shortcuts; (3) fall back to super.performKeyEquivalent. For non-Command keys it calls super directly. Therefore, in NSWindow.cmux_performKeyEquivalent (Sources/AppDelegate.swift), it is correct to call firstResponderWebView.performKeyEquivalent and return its Bool unconditionally to avoid re-entering SwiftUI’s performKeyEquivalent path that can swallow keys after WKWebView focus.

Learnt from: outoftime
Repo: manaflow-ai/cmux PR: 1528
File: Resources/shell-integration/fish/vendor_conf.d/cmux-fish-integration.fish:541-546
Timestamp: 2026-03-17T13:59:10.665Z
Learning: Repo: manaflow-ai/cmux — The socket command `report_git_branch` (parsed in Sources/TerminalController.swift) expects the branch name as an **unquoted, bare token**. Wrapping the branch name in double quotes causes it to be silently discarded by the parser. This matches the bash/zsh shell integration convention. Do not suggest quoting the branch argument for this command; the fix was attempted in commit 829437c7 and immediately reverted because it broke branch reporting.

Learnt from: BillionClaw
Repo: manaflow-ai/cmux PR: 1755
File: Sources/GhosttyTerminalView.swift:4889-4903
Timestamp: 2026-03-18T16:50:14.396Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, GhosttyNSView.performKeyEquivalent(with:) must only route Command key equivalents to NSApp.mainMenu when shouldRouteCommandEquivalentDirectlyToMainMenu(event) returns true; otherwise, handle the key directly via keyDown to forward unbound Cmd+Shift combinations to the terminal and preserve the Cmd+` exclusion.

Learnt from: apollow
Repo: manaflow-ai/cmux PR: 1089
File: CLI/cmux.swift:462-499
Timestamp: 2026-03-09T02:08:54.956Z
Learning: Repo: manaflow-ai/cmux
PR: `#1089`
File: CLI/cmux.swift
Component: ClaudeHookTagExtractor.extractTags(subtitle:body:)
Learning: For Claude Code session tag extraction, pre-redact sensitive spans (UUIDs, emails, access tokens, filesystem paths, ENV_VAR=..., long numerics) across the combined body+subtitle using unanchored sensitiveSpanPatterns before tokenization. Then tokenize and still filter each token with anchored sensitivePatterns. Rationale: prevents PII/path fragments from slipping into searchable tags after delimiter splitting.

Learnt from: atani
Repo: manaflow-ai/cmux PR: 819
File: Sources/AppDelegate.swift:3491-3493
Timestamp: 2026-03-04T14:06:16.241Z
Learning: For manaflow-ai/cmux PR `#819` (Japanese i18n), keep scope limited to localization changes; UX enhancements like preferring workspace.customTitle in workspaceDisplayName() or altering move-target labels should be handled in a separate follow-up issue.

Learnt from: arieltobiana
Repo: manaflow-ai/cmux PR: 1873
File: Sources/TerminalController.swift:4071-4087
Timestamp: 2026-03-20T17:18:30.333Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, v2WorkspaceAction(params:) -> case "set_color": palette names are resolved via WorkspaceTabColorSettings.defaultPaletteWithOverrides(), whose entries are always valid hex (validated by the UI). Therefore, additional normalization of entry.hex is unnecessary.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 318
File: Sources/Panels/BrowserPanel.swift:0-0
Timestamp: 2026-03-17T05:34:44.905Z
Learning: In manaflow-ai/cmux (PR `#318`), the browser import wizard in Sources/Panels/BrowserPanel.swift now exposes an Additional data checkbox and passes its state into BrowserImportScope.fromSelection so users can select .everything (including when only additional-data is chosen). Unit and UI tests cover the additional‑data‑only mapping.

Learnt from: gaelic-ghost
Repo: manaflow-ai/cmux PR: 1771
File: Sources/Workspace.swift:8516-8558
Timestamp: 2026-03-18T16:46:47.403Z
Learning: Repo manaflow-ai/cmux — Preference: keep lower-confidence geometry/focus/portal behavior changes out of stabilization checkpoints; evaluate them in a separate, narrowly scoped follow-up PR with DEBUG-gated toggles and diagnostics.
✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
Sources/ContentView.swift (1)

6329-6331: ⚠️ Potential issue | 🟡 Minor

Trim currentDirectory before using it as baseCwd.

This only checks isEmpty, so a whitespace-only currentDirectory still reaches CmuxConfigExecutor and breaks relative-path resolution.

🛠️ Suggested fix
-                let rawCwd = tabManager.selectedWorkspace?.currentDirectory
-                let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd!
-                    : FileManager.default.homeDirectoryForCurrentUser.path
+                let baseCwd: String = {
+                    let candidate = tabManager.selectedWorkspace?.currentDirectory?
+                        .trimmingCharacters(in: .whitespacesAndNewlines)
+                    guard let candidate, !candidate.isEmpty else {
+                        return FileManager.default.homeDirectoryForCurrentUser.path
+                    }
+                    return candidate
+                }()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 6329 - 6331, Trim the workspace
currentDirectory before using it: obtain
tabManager.selectedWorkspace?.currentDirectory, call trimmingCharacters(in:
.whitespacesAndNewlines) and check the trimmed string's isEmpty; if non-empty
use the trimmed value for baseCwd, otherwise fall back to
FileManager.default.homeDirectoryForCurrentUser.path so CmuxConfigExecutor never
receives a whitespace-only path.
Sources/CmuxConfig.swift (1)

111-138: ⚠️ Potential issue | 🟠 Major

Reject split-specific keys on pane nodes too.

The current guard only catches pane + direction. A node like { "pane": ..., "children": [...] } or { "pane": ..., "split": 0.3 } still decodes as .pane and silently drops the split fields, so malformed layouts load with a different shape than the JSON describes.

Possible guard
     let container = try decoder.container(keyedBy: CodingKeys.self)
     let hasPane = container.contains(.pane)
     let hasDirection = container.contains(.direction)
+    let hasSplit = container.contains(.split)
+    let hasChildren = container.contains(.children)

-    if hasPane && hasDirection {
+    if hasPane && (hasDirection || hasSplit || hasChildren) {
         throw DecodingError.dataCorrupted(
             DecodingError.Context(
                 codingPath: decoder.codingPath,
-                debugDescription: "CmuxLayoutNode must not contain both 'pane' and 'direction' keys"
+                debugDescription: "Pane nodes must not contain split-specific keys"
             )
         )
     }
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/CmuxConfig.swift`:
- Around line 289-304: The early-return in updateLocalConfigPath compares
newPath to localConfigPath and skips starting a watcher when both are nil, so
creating a first-time cmux.json (or a nearer one) under the watched subtree is
never observed; modify updateLocalConfigPath to treat the "both nil" case
specially: if newPath == localConfigPath then only return early when the
incoming directory parameter is nil (i.e., no new subtree to watch); otherwise,
when directory is provided and localConfigPath is nil, do not
return—stopLocalFileWatcher(), set localConfigPath (still nil),
startLocalFileWatcher() for the provided directory so creation of a new
cmux.json will be detected, then call loadAll(); apply the same logic to the
analogous block around the 409-440 region (the other updateLocalConfigPath-like
watcher function) using the same symbols (findCmuxConfig, localConfigPath,
stopLocalFileWatcher, startLocalFileWatcher, loadAll).

---

Duplicate comments:
In `@Sources/ContentView.swift`:
- Around line 6329-6331: Trim the workspace currentDirectory before using it:
obtain tabManager.selectedWorkspace?.currentDirectory, call
trimmingCharacters(in: .whitespacesAndNewlines) and check the trimmed string's
isEmpty; if non-empty use the trimmed value for baseCwd, otherwise fall back to
FileManager.default.homeDirectoryForCurrentUser.path so CmuxConfigExecutor never
receives a whitespace-only path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ab11b2c7-8d1a-449c-9c58-a9ca482825f6

📥 Commits

Reviewing files that changed from the base of the PR and between 4fd4e2e and a7d641d.

📒 Files selected for processing (5)
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/ContentView.swift
  • Sources/cmuxApp.swift
  • cmuxTests/CmuxConfigTests.swift
✅ Files skipped from review due to trivial changes (1)
  • Sources/AppDelegate.swift

Comment thread Sources/CmuxConfig.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (5)
Sources/ContentView.swift (2)

6326-6337: ⚠️ Potential issue | 🟡 Minor

Treat whitespace-only working directories as missing.

Line 6330 only guards isEmpty, so " " still passes through as baseCwd. That leaves relative cwd resolution broken for fresh or not-yet-synced workspaces.

🛠️ Suggested fix
         for command in cmuxConfigStore.loadedCommands {
             let captured = command
             registry.register(commandId: command.id) {
-                let rawCwd = tabManager.selectedWorkspace?.currentDirectory
-                let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd!
-                    : FileManager.default.homeDirectoryForCurrentUser.path
+                let baseCwd: String = {
+                    let candidate = tabManager.selectedWorkspace?.currentDirectory?
+                        .trimmingCharacters(in: .whitespacesAndNewlines)
+                    guard let candidate, !candidate.isEmpty else {
+                        return FileManager.default.homeDirectoryForCurrentUser.path
+                    }
+                    return candidate
+                }()
                 CmuxConfigExecutor.execute(
                     command: captured,
                     tabManager: tabManager,
                     baseCwd: baseCwd
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 6326 - 6337, The code treats a
workspace currentDirectory that contains only whitespace as valid; update the
baseCwd determination inside the registry.register closure (where rawCwd is
taken from tabManager.selectedWorkspace?.currentDirectory and baseCwd is
computed) to consider whitespace-only strings as empty by trimming
whitespace/newlines before the isEmpty check and use the trimmed value when
non-empty; if the trimmed string is empty or nil, fall back to
FileManager.default.homeDirectoryForCurrentUser.path so relative cwd resolution
works for fresh or unsynced workspaces.

5966-5981: ⚠️ Potential issue | 🟠 Major

Strip control characters from config-sourced palette text too.

Line 5993 only removes the BiDi/zero-width set. Embedded \r, \n, and other control scalars still survive into a one-line palette row, so a repo-controlled cmux.json can still hide or reshape the visible label/subtitle.

🛡️ Possible hardening
     private func sanitizeCmuxConfigPaletteText(_ text: String) -> String {
         let dangerous: Set<Unicode.Scalar> = [
             "\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}",
             "\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}",
             "\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}",
             "\u{FEFF}",
         ]
-        let filtered = String(text.unicodeScalars.filter { !dangerous.contains($0) })
+        let filtered = String(
+            text.unicodeScalars.filter { scalar in
+                !dangerous.contains(scalar)
+                    && !CharacterSet.controlCharacters.contains(scalar)
+            }
+        )
         return filtered.trimmingCharacters(in: .whitespacesAndNewlines)
     }
Based on learnings, in `Sources/TerminalController.swift` and `Sources/Panels/BrowserPanel.swift`, untrusted-text sanitizers strip control characters together with the dangerous BiDi/zero-width scalar set.

Also applies to: 5986-5995

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 5966 - 5981, The cmux config text
sanitizer currently only strips BiDi/zero-width scalars; update the
implementation of sanitizeCmuxConfigPaletteText so it also removes control
characters (e.g., Cc like \r, \n and other control scalars) the same way the
untrusted-text sanitizers in TerminalController.swift and BrowserPanel.swift do,
ensuring both commandName and subtitle produced in the loop are cleaned of line
breaks and control characters before being used in
CommandPaletteCommandContribution.
Sources/Workspace.swift (1)

891-916: ⚠️ Potential issue | 🟠 Major

Keep startup commands queued until the terminal is ready or closed.

This still drops layout command execution after an arbitrary 3s window. focus: false terminals created during layout population can stay cold longer than that, so hidden-pane auto-run becomes race-dependent and silently breaks. Please remove the timeout/drop path and clean the observer up on panel teardown instead.

Suggested direction
 private func sendInputWhenReady(_ text: String, to panel: TerminalPanel) {
     if panel.surface.surface != nil {
         panel.sendInput(text)
         return
     }

-    var resolved = false
-    var observer: NSObjectProtocol?
-
-    observer = NotificationCenter.default.addObserver(
+    if let existing = pendingStartupInputObserverByPanelId[panel.id] {
+        NotificationCenter.default.removeObserver(existing)
+        pendingStartupInputObserverByPanelId.removeValue(forKey: panel.id)
+    }
+
+    let observer = NotificationCenter.default.addObserver(
         forName: .terminalSurfaceDidBecomeReady,
         object: panel.surface,
         queue: .main
-    ) { [weak panel] _ in
-        guard !resolved, let panel else { return }
-        resolved = true
-        if let observer { NotificationCenter.default.removeObserver(observer) }
+    ) { [weak self, weak panel] _ in
+        guard let self, let panel else { return }
+        if let observer = self.pendingStartupInputObserverByPanelId.removeValue(forKey: panel.id) {
+            NotificationCenter.default.removeObserver(observer)
+        }
         panel.sendInput(text)
     }
-
-    DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
-        guard !resolved else { return }
-        resolved = true
-        if let observer { NotificationCenter.default.removeObserver(observer) }
-        NSLog("[CmuxConfig] surface not ready after 3s, dropping command (%d chars)", text.count)
-    }
+    pendingStartupInputObserverByPanelId[panel.id] = observer
 }
// Workspace private state
private var pendingStartupInputObserverByPanelId: [UUID: NSObjectProtocol] = [:]

// splitTabBar(_:didCloseTab:fromPane:)
if let observer = pendingStartupInputObserverByPanelId.removeValue(forKey: panelId) {
    NotificationCenter.default.removeObserver(observer)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 891 - 916, The current
sendInputWhenReady(_:to:) uses a 3s DispatchQueue timeout and drops commands;
instead remove the timeout and keep the observer until the panel is closed or
the surface becomes ready. Change sendInputWhenReady to create the
NotificationCenter observer for .terminalSurfaceDidBecomeReady, store that
observer in a workspace-level dictionary (e.g.
pendingStartupInputObserverByPanelId: [UUID: NSObjectProtocol]) keyed by
panel.id, and on notification call panel.sendInput(text), remove the observer
from NotificationCenter and remove the entry from
pendingStartupInputObserverByPanelId; remove the DispatchQueue.main.asyncAfter
block and the "resolved" drop path. In the tab/pane teardown path
(splitTabBar(_:didCloseTab:fromPane:) or equivalent panel-close handler) check
pendingStartupInputObserverByPanelId for the panelId and, if present,
removeObserver and remove the dictionary entry to ensure cleanup when the panel
is closed.
Sources/CmuxConfig.swift (2)

232-248: ⚠️ Potential issue | 🟠 Major

Per-window config isolation is still missing.

The store uses instance properties (localConfigPath, loadedCommands, cancellables) but wireDirectoryTracking calls cancellables.removeAll(), tearing down any previous window's subscriptions. In a multi-window session, the last window to call wireDirectoryTracking dictates which project's commands appear in all windows' palettes.

This was flagged in a previous review and remains unresolved. Consider either:

  1. Making CmuxConfigStore a per-window instance (owned by each MainWindowContext)
  2. Keying subscriptions and loaded commands by window/tabManager ID
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 232 - 248, CmuxConfigStore currently
holds shared instance properties (localConfigPath, loadedCommands, cancellables)
while wireDirectoryTracking mutates cancellables.removeAll(), causing different
windows to clobber each other's subscriptions; fix by making CmuxConfigStore
per-window (owned by MainWindowContext) or by namespacing state and
subscriptions by window/tab ID: move localConfigPath/loadedCommands/cancellables
into a window-scoped struct keyed by MainWindowContext or tabManager ID, update
wireDirectoryTracking to operate on that keyed entry (attach/detach only that
window's DispatchSource/FileDescriptor) and remove global shared mutations so
each window manages its own subscriptions and loaded commands.

289-304: ⚠️ Potential issue | 🟠 Major

First-time local cmux.json creation still not observed.

When findCmuxConfig returns nil and localConfigPath is already nil, Line 297 returns early. When they differ (e.g., localConfigPath was non-nil and now newPath is nil), the code proceeds but Line 300 only starts a watcher if newPath != nil. Neither path starts a directory watcher to detect first-time file creation.

A user who opens a workspace without cmux.json and later creates one won't see their commands until they trigger a directory change or restart.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 289 - 304, updateLocalConfigPath
currently skips starting a watcher when both newPath and localConfigPath are
nil, so the extension never notices a first-time cmux.json creation; change
updateLocalConfigPath to start a directory-level file watcher when
findCmuxConfig returns nil but you still need to observe the directory for
future cmux.json creation (i.e., when newPath == nil and previous
localConfigPath == nil or when transitioning to nil), by calling
startLocalFileWatcher (or a new startDirectoryWatcher helper) on the
workspace/parent directory before returning, ensure stopLocalFileWatcher is
still called when switching paths, and keep calling loadAll() only after watcher
state and localConfigPath are updated (references: updateLocalConfigPath,
findCmuxConfig, localConfigPath, stopLocalFileWatcher, startLocalFileWatcher,
loadAll).
🧹 Nitpick comments (5)
Sources/AppDelegate.swift (1)

5789-5798: Avoid duplicating global config watchers per window.

Line 5789 creates a fresh CmuxConfigStore for every main window. Since CmuxConfigStore.init() starts a global watcher (Sources/CmuxConfig.swift:233-260), N windows means N watchers on the same ~/.config/cmux/cmux.json, which can cause redundant reload work and extra FD usage. Consider sharing a single global watcher and fan-out updates to per-window stores (while keeping per-window local-directory tracking/state).

Possible direction
- let cmuxConfigStore = CmuxConfigStore()
+ let cmuxConfigStore = CmuxConfigStore(globalWatcher: CmuxGlobalConfigWatcher.shared)
  cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager)
  cmuxConfigStore.loadAll()

Then keep local watchers in each store, but centralize only the global-file observer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 5789 - 5798, The code instantiates a
new CmuxConfigStore per window which starts a global file watcher in
CmuxConfigStore.init(), causing duplicate watchers; instead create or reuse a
single shared/global CmuxConfigStore instance and inject that into each window’s
view while keeping per-window local directory tracking via
wireDirectoryTracking(tabManager:). Concretely, stop calling CmuxConfigStore()
inside the window creation block and obtain a shared instance (e.g.
CmuxConfigStore.shared or an AppDelegate-held cmuxConfigStoreGlobal) to
.environmentObject into ContentView, and call wireDirectoryTracking(...) on a
per-window store wrapper or on the tabManager-level per-window store so only the
global watcher in CmuxConfigStore is created once while per-window
state/tracking remains local.
Sources/cmuxApp.swift (1)

351-352: Guard cmux config bootstrap so it only runs once.

Line 351 and Line 352 are inside onAppear; re-appearance can rewire subscriptions and reload configs again, causing unnecessary configRevision bumps and avoidable palette churn.

♻️ Proposed change
 `@StateObject` private var cmuxConfigStore = CmuxConfigStore()
+@State private var didInitializeCmuxConfigStore = false
 ...
                 .onAppear {
 ...
-                    cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager)
-                    cmuxConfigStore.loadAll()
+                    if !didInitializeCmuxConfigStore {
+                        didInitializeCmuxConfigStore = true
+                        cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager)
+                        cmuxConfigStore.loadAll()
+                    }
                     applyAppearance()
 ...
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 351 - 352, The cmux config bootstrap in
onAppear (calls to cmuxConfigStore.wireDirectoryTracking(tabManager:) and
cmuxConfigStore.loadAll()) must be guarded so it only runs once; add a stored
Bool flag (e.g. hasBootstrappedCmuxConfig) at the view or app state level, check
the flag in onAppear and only call wireDirectoryTracking(...) and loadAll() when
the flag is false, then set the flag to true immediately after running to
prevent re-wiring subscriptions and repeated configRevision bumps on subsequent
appearances.
web/app/[locale]/docs/custom-commands/page.tsx (1)

38-51: Consider using raw strings or escaping for JSON ellipsis.

The JSON example uses { ... } which isn't valid JSON syntax. While this is intentional for documentation purposes to indicate "more content here," consider using a comment-style placeholder or a note to clarify this is illustrative.

💡 Alternative: Use a more explicit placeholder
 <CodeBlock title="cmux.json" lang="json">{`{
   "commands": [
     {
       "name": "Start Dev",
       "keywords": ["dev", "start"],
-      "workspace": { ... }
+      "workspace": { "..." : "see workspace commands below" }
     },

Or simply add a note that the ellipsis represents omitted content.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/`[locale]/docs/custom-commands/page.tsx around lines 38 - 51, The
JSON example in the CodeBlock using `{ ... }` is invalid; update the snippet
inside the CodeBlock (title "cmux.json") to show valid JSON or a clear
placeholder (e.g., "workspace": { "/* fields omitted */": true } or "workspace":
{ "placeholder": "…" }), or add an inline comment/note above the CodeBlock
clarifying the ellipsis is illustrative so readers won’t copy invalid JSON;
modify the CodeBlock content and/or nearby explanatory text accordingly.
Sources/GhosttyTerminalView.swift (1)

3755-3781: Factor shared key-event initialization to reduce drift risk.

Line 3757–3763 and Line 3772–3779 duplicate ghostty_input_key_s setup. A tiny shared initializer/helper would keep future field changes consistent.

♻️ Suggested refactor
+    private func makeBaseKeyEvent(keycode: UInt32) -> ghostty_input_key_s {
+        var keyEvent = ghostty_input_key_s()
+        keyEvent.action = GHOSTTY_ACTION_PRESS
+        keyEvent.keycode = keycode
+        keyEvent.mods = GHOSTTY_MODS_NONE
+        keyEvent.consumed_mods = GHOSTTY_MODS_NONE
+        keyEvent.unshifted_codepoint = 0
+        keyEvent.composing = false
+        return keyEvent
+    }
+
     private func flushText(_ buffer: inout String, surface: ghostty_surface_t) {
         guard !buffer.isEmpty else { return }
-        var keyEvent = ghostty_input_key_s()
-        keyEvent.action = GHOSTTY_ACTION_PRESS
-        keyEvent.keycode = 0
-        keyEvent.mods = GHOSTTY_MODS_NONE
-        keyEvent.consumed_mods = GHOSTTY_MODS_NONE
-        keyEvent.unshifted_codepoint = 0
-        keyEvent.composing = false
+        var keyEvent = makeBaseKeyEvent(keycode: 0)
         buffer.withCString { ptr in
             keyEvent.text = ptr
             _ = ghostty_surface_key(surface, keyEvent)
         }
         buffer.removeAll(keepingCapacity: true)
     }

     private func sendKeyEvent(surface: ghostty_surface_t, keycode: UInt32) {
-        var keyEvent = ghostty_input_key_s()
-        keyEvent.action = GHOSTTY_ACTION_PRESS
-        keyEvent.keycode = keycode
-        keyEvent.mods = GHOSTTY_MODS_NONE
-        keyEvent.consumed_mods = GHOSTTY_MODS_NONE
-        keyEvent.unshifted_codepoint = 0
-        keyEvent.composing = false
+        var keyEvent = makeBaseKeyEvent(keycode: keycode)
         keyEvent.text = nil
         _ = ghostty_surface_key(surface, keyEvent)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 3755 - 3781, Both flushText
and sendKeyEvent duplicate initialization of ghostty_input_key_s; extract a
small helper (e.g., makeKeyEvent or defaultKeyEvent) that returns a prefilled
ghostty_input_key_s with action, keycode, mods, consumed_mods,
unshifted_codepoint and composing set to the same defaults, then call that
helper from flushText and sendKeyEvent and only override text or other per-call
fields; reference the existing functions flushText(_:surface:) and
sendKeyEvent(surface:keycode:) and the ghostty_input_key_s struct when adding
the helper to ensure future field changes stay consistent.
Sources/CmuxConfigExecutor.swift (1)

37-38: Silent no-op when no terminal is focused.

When focusedTerminalPanel returns nil (e.g., focused panel is a browser), the simple command silently does nothing. This is probably acceptable for a palette-invoked command, but consider whether a brief notification or sound would improve discoverability of the failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfigExecutor.swift` around lines 37 - 38, The guard that reads
tabManager.selectedWorkspace?.focusedTerminalPanel silently returns when nil;
update the else branch in CmuxConfigExecutor (the guard's failure path) to
provide user feedback instead of a silent no-op — e.g., play a short sound with
NSBeep() or present a brief UI notification/alert (NSUserNotification/NSAlert or
your app's notification helper) informing the user that no terminal is focused
before returning; keep the existing early-return behavior after notifying.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/CmuxConfig.swift`:
- Around line 232-248: CmuxConfigStore currently holds shared instance
properties (localConfigPath, loadedCommands, cancellables) while
wireDirectoryTracking mutates cancellables.removeAll(), causing different
windows to clobber each other's subscriptions; fix by making CmuxConfigStore
per-window (owned by MainWindowContext) or by namespacing state and
subscriptions by window/tab ID: move localConfigPath/loadedCommands/cancellables
into a window-scoped struct keyed by MainWindowContext or tabManager ID, update
wireDirectoryTracking to operate on that keyed entry (attach/detach only that
window's DispatchSource/FileDescriptor) and remove global shared mutations so
each window manages its own subscriptions and loaded commands.
- Around line 289-304: updateLocalConfigPath currently skips starting a watcher
when both newPath and localConfigPath are nil, so the extension never notices a
first-time cmux.json creation; change updateLocalConfigPath to start a
directory-level file watcher when findCmuxConfig returns nil but you still need
to observe the directory for future cmux.json creation (i.e., when newPath ==
nil and previous localConfigPath == nil or when transitioning to nil), by
calling startLocalFileWatcher (or a new startDirectoryWatcher helper) on the
workspace/parent directory before returning, ensure stopLocalFileWatcher is
still called when switching paths, and keep calling loadAll() only after watcher
state and localConfigPath are updated (references: updateLocalConfigPath,
findCmuxConfig, localConfigPath, stopLocalFileWatcher, startLocalFileWatcher,
loadAll).

In `@Sources/ContentView.swift`:
- Around line 6326-6337: The code treats a workspace currentDirectory that
contains only whitespace as valid; update the baseCwd determination inside the
registry.register closure (where rawCwd is taken from
tabManager.selectedWorkspace?.currentDirectory and baseCwd is computed) to
consider whitespace-only strings as empty by trimming whitespace/newlines before
the isEmpty check and use the trimmed value when non-empty; if the trimmed
string is empty or nil, fall back to
FileManager.default.homeDirectoryForCurrentUser.path so relative cwd resolution
works for fresh or unsynced workspaces.
- Around line 5966-5981: The cmux config text sanitizer currently only strips
BiDi/zero-width scalars; update the implementation of
sanitizeCmuxConfigPaletteText so it also removes control characters (e.g., Cc
like \r, \n and other control scalars) the same way the untrusted-text
sanitizers in TerminalController.swift and BrowserPanel.swift do, ensuring both
commandName and subtitle produced in the loop are cleaned of line breaks and
control characters before being used in CommandPaletteCommandContribution.

In `@Sources/Workspace.swift`:
- Around line 891-916: The current sendInputWhenReady(_:to:) uses a 3s
DispatchQueue timeout and drops commands; instead remove the timeout and keep
the observer until the panel is closed or the surface becomes ready. Change
sendInputWhenReady to create the NotificationCenter observer for
.terminalSurfaceDidBecomeReady, store that observer in a workspace-level
dictionary (e.g. pendingStartupInputObserverByPanelId: [UUID: NSObjectProtocol])
keyed by panel.id, and on notification call panel.sendInput(text), remove the
observer from NotificationCenter and remove the entry from
pendingStartupInputObserverByPanelId; remove the DispatchQueue.main.asyncAfter
block and the "resolved" drop path. In the tab/pane teardown path
(splitTabBar(_:didCloseTab:fromPane:) or equivalent panel-close handler) check
pendingStartupInputObserverByPanelId for the panelId and, if present,
removeObserver and remove the dictionary entry to ensure cleanup when the panel
is closed.

---

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Around line 5789-5798: The code instantiates a new CmuxConfigStore per window
which starts a global file watcher in CmuxConfigStore.init(), causing duplicate
watchers; instead create or reuse a single shared/global CmuxConfigStore
instance and inject that into each window’s view while keeping per-window local
directory tracking via wireDirectoryTracking(tabManager:). Concretely, stop
calling CmuxConfigStore() inside the window creation block and obtain a shared
instance (e.g. CmuxConfigStore.shared or an AppDelegate-held
cmuxConfigStoreGlobal) to .environmentObject into ContentView, and call
wireDirectoryTracking(...) on a per-window store wrapper or on the
tabManager-level per-window store so only the global watcher in CmuxConfigStore
is created once while per-window state/tracking remains local.

In `@Sources/cmuxApp.swift`:
- Around line 351-352: The cmux config bootstrap in onAppear (calls to
cmuxConfigStore.wireDirectoryTracking(tabManager:) and
cmuxConfigStore.loadAll()) must be guarded so it only runs once; add a stored
Bool flag (e.g. hasBootstrappedCmuxConfig) at the view or app state level, check
the flag in onAppear and only call wireDirectoryTracking(...) and loadAll() when
the flag is false, then set the flag to true immediately after running to
prevent re-wiring subscriptions and repeated configRevision bumps on subsequent
appearances.

In `@Sources/CmuxConfigExecutor.swift`:
- Around line 37-38: The guard that reads
tabManager.selectedWorkspace?.focusedTerminalPanel silently returns when nil;
update the else branch in CmuxConfigExecutor (the guard's failure path) to
provide user feedback instead of a silent no-op — e.g., play a short sound with
NSBeep() or present a brief UI notification/alert (NSUserNotification/NSAlert or
your app's notification helper) informing the user that no terminal is focused
before returning; keep the existing early-return behavior after notifying.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 3755-3781: Both flushText and sendKeyEvent duplicate
initialization of ghostty_input_key_s; extract a small helper (e.g.,
makeKeyEvent or defaultKeyEvent) that returns a prefilled ghostty_input_key_s
with action, keycode, mods, consumed_mods, unshifted_codepoint and composing set
to the same defaults, then call that helper from flushText and sendKeyEvent and
only override text or other per-call fields; reference the existing functions
flushText(_:surface:) and sendKeyEvent(surface:keycode:) and the
ghostty_input_key_s struct when adding the helper to ensure future field changes
stay consistent.

In `@web/app/`[locale]/docs/custom-commands/page.tsx:
- Around line 38-51: The JSON example in the CodeBlock using `{ ... }` is
invalid; update the snippet inside the CodeBlock (title "cmux.json") to show
valid JSON or a clear placeholder (e.g., "workspace": { "/* fields omitted */":
true } or "workspace": { "placeholder": "…" }), or add an inline comment/note
above the CodeBlock clarifying the ellipsis is illustrative so readers won’t
copy invalid JSON; modify the CodeBlock content and/or nearby explanatory text
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 133b6e65-17b3-4548-bc01-19e002c803e4

📥 Commits

Reviewing files that changed from the base of the PR and between b5fb304 and a7d641d.

📒 Files selected for processing (32)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigExecutor.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • cmuxTests/CmuxConfigTests.swift
  • web/app/[locale]/components/docs-nav-items.ts
  • web/app/[locale]/docs/custom-commands/page.tsx
  • web/messages/ar.json
  • web/messages/bs.json
  • web/messages/da.json
  • web/messages/de.json
  • web/messages/en.json
  • web/messages/es.json
  • web/messages/fr.json
  • web/messages/it.json
  • web/messages/ja.json
  • web/messages/km.json
  • web/messages/ko.json
  • web/messages/no.json
  • web/messages/pl.json
  • web/messages/pt-BR.json
  • web/messages/ru.json
  • web/messages/th.json
  • web/messages/tr.json
  • web/messages/zh-CN.json
  • web/messages/zh-TW.json

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
Sources/CmuxConfig.swift (1)

217-230: Consider validating surface type constraints.

CmuxSurfaceDefinition accepts all optional fields regardless of type. This could lead to confusing configurations:

  • Browser surface without url
  • Terminal surface with url set (ignored?)
  • Browser surface with command set (ignored?)

Validating type-specific constraints at decode time would fail fast on misconfigured surfaces rather than silently ignoring fields.

♻️ Example validation in a custom decoder
struct CmuxSurfaceDefinition: Codable, Sendable {
    // ... existing fields ...

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        type = try container.decode(CmuxSurfaceType.self, forKey: .type)
        name = try container.decodeIfPresent(String.self, forKey: .name)
        command = try container.decodeIfPresent(String.self, forKey: .command)
        cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
        env = try container.decodeIfPresent([String: String].self, forKey: .env)
        url = try container.decodeIfPresent(String.self, forKey: .url)
        focus = try container.decodeIfPresent(Bool.self, forKey: .focus)

        switch type {
        case .browser:
            if url == nil || url?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
                throw DecodingError.dataCorrupted(
                    DecodingError.Context(
                        codingPath: decoder.codingPath,
                        debugDescription: "Browser surface requires a non-empty 'url'"
                    )
                )
            }
        case .terminal:
            break // terminal surfaces are valid without url
        }
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 217 - 230, CmuxSurfaceDefinition
currently decodes all fields as optional regardless of CmuxSurfaceType;
implement a custom init(from decoder: Decoder) on CmuxSurfaceDefinition to
decode type and all fields then validate type-specific constraints (e.g., for
CmuxSurfaceType.browser require a non-empty url, and for
CmuxSurfaceType.terminal reject or warn if url is present or ensure ignored
fields are explicit) and throw a DecodingError.dataCorrupted with a clear
message when constraints fail; update CodingKeys and ensure the struct still
conforms to Codable and Sendable so misconfigured surfaces fail fast at decode
time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/CmuxConfig.swift`:
- Around line 217-230: CmuxSurfaceDefinition currently decodes all fields as
optional regardless of CmuxSurfaceType; implement a custom init(from decoder:
Decoder) on CmuxSurfaceDefinition to decode type and all fields then validate
type-specific constraints (e.g., for CmuxSurfaceType.browser require a non-empty
url, and for CmuxSurfaceType.terminal reject or warn if url is present or ensure
ignored fields are explicit) and throw a DecodingError.dataCorrupted with a
clear message when constraints fail; update CodingKeys and ensure the struct
still conforms to Codable and Sendable so misconfigured surfaces fail fast at
decode time.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c04371f4-13e5-4bd1-a57a-df3d24b82cdf

📥 Commits

Reviewing files that changed from the base of the PR and between a7d641d and 74b539a.

📒 Files selected for processing (1)
  • Sources/CmuxConfig.swift

austinywang and others added 8 commits March 24, 2026 21:08
XCUIApplication.launch() blocks ~60s then fails on headless WarpBuild
runners because foreground activation requires a GUI login session.

Apply the same pre-launch strategy used for the display resolution test:
- CI shell launches the app with env vars before running xcodebuild
- Test detects pre-launched app via manifest, uses activate() instead of
  launch() to avoid killing and relaunching the app
- Falls back to clicking the window for focus via accessibility framework

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d palette improvements

  - Fix Danish ("Overfladedef inition") and Norwegian ("rotmapp") translation typos
  - Add empty-string check for baseCwd fallback in command palette handlers
  - Coalesce \r\n into single Return keypress in sendInput
  - Redact command text from timeout log to prevent secret leakage
  - Add decode-time validation: reject hybrid/empty commands, ambiguous layout
    nodes, wrong split children count, and empty pane surfaces
  - Namespace custom command IDs with "cmux.config.command." prefix
  - Forward command description to palette subtitle when available
  - Update tests for new validation rules and ID prefix
…validation, ancestor walk,

  palette sanitization
The confirm dialog now shows the actual command text and has an "Always
trust commands from this folder" checkbox. When checked, future confirm
commands from that directory skip the dialog.

Trust is scoped to the git repo root if the cmux.json is inside a repo,
so trusting once covers all subdirectories. Non-git directories are
trusted by exact path. Global config is always trusted.

Trusted directories are persisted in ~/Library/Application Support/cmux/
trusted-directories.json.
Shows all trusted directories with per-directory revoke buttons and a
Clear All option. Placed in a "Custom Commands" section between
Automation and Browser in Settings.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/cmuxApp.swift">

<violation number="1" location="Sources/cmuxApp.swift:3885">
P2: Trusted directories in Settings are backed by one-time local @State and are not synchronized with external trust-store updates, so the security list can become stale.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/cmuxApp.swift Outdated
One path per line, with a Save button that activates on changes.
Users can add, remove, or edit paths directly.
Matches the behavior of other textarea settings (browser host
whitelist, external URL patterns) which auto-save via @AppStorage.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/cmuxApp.swift">

<violation number="1" location="Sources/cmuxApp.swift:5085">
P2: Per-keystroke `replaceAll` from a stale draft can silently drop trusted directories added elsewhere in the app.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/cmuxApp.swift
.padding(.horizontal, 16)
.padding(.bottom, 12)
.onChange(of: trustedDirectoriesDraft) { _ in
saveTrustedDirectories()
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026

Choose a reason for hiding this comment

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

P2: Per-keystroke replaceAll from a stale draft can silently drop trusted directories added elsewhere in the app.

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

<comment>Per-keystroke `replaceAll` from a stale draft can silently drop trusted directories added elsewhere in the app.</comment>

<file context>
@@ -5052,44 +5060,34 @@ struct SettingsView: View {
+                                .padding(.horizontal, 16)
+                                .padding(.bottom, 12)
+                                .onChange(of: trustedDirectoriesDraft) { _ in
+                                    saveTrustedDirectories()
                                 }
-                                .padding(.horizontal, 14)
</file context>
Fix with Cubic

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
Sources/ContentView.swift (2)

6330-6346: ⚠️ Potential issue | 🟠 Major

Treat whitespace-only currentDirectory as missing.

Line 6336 only checks isEmpty, so " " still gets passed to CmuxConfigExecutor as baseCwd. That breaks relative path resolution for config-defined commands/workspaces on blank-but-not-nil workspaces.

🛠️ Suggested fix
         registry.register(commandId: command.id) {
-            let rawCwd = tabManager.selectedWorkspace?.currentDirectory
-            let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd!
-                : FileManager.default.homeDirectoryForCurrentUser.path
+            let baseCwd: String = {
+                let candidate = tabManager.selectedWorkspace?.currentDirectory
+                    .trimmingCharacters(in: .whitespacesAndNewlines)
+                guard let candidate, !candidate.isEmpty else {
+                    return FileManager.default.homeDirectoryForCurrentUser.path
+                }
+                return candidate
+            }()
             CmuxConfigExecutor.execute(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 6330 - 6346, Replace the
currentDirectory emptiness check with a trimmed check so whitespace-only paths
are treated as missing: when building baseCwd inside the registry closure (the
block that registers commandId for each item from cmuxConfigStore.loadedCommands
and calls CmuxConfigExecutor.execute), obtain rawCwd via
tabManager.selectedWorkspace?.currentDirectory, trim it with
.trimmingCharacters(in: .whitespacesAndNewlines), and if the trimmed string is
empty use FileManager.default.homeDirectoryForCurrentUser.path; otherwise use
the trimmed value as baseCwd before calling CmuxConfigExecutor.execute with
configSourcePath and globalConfigPath.

5970-5999: ⚠️ Potential issue | 🟠 Major

Strip control characters here too.

Line 5997 only drops the BiDi/zero-width set. \r, \n, and other control characters can still survive into command.name, which means a repo-sourced command can render a benign first line in the palette while hiding the destructive suffix after a line break. Please mirror the full untrusted-text filter before these strings reach the title/search corpus.

🛡️ Possible hardening
     private func sanitizeCmuxConfigPaletteText(_ text: String) -> String {
         let dangerous: Set<Unicode.Scalar> = [
             "\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}",
             "\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}",
             "\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}",
             "\u{FEFF}",
         ]
-        let filtered = String(text.unicodeScalars.filter { !dangerous.contains($0) })
+        let filteredScalars = text.unicodeScalars.filter { scalar in
+            switch scalar.value {
+            case 0x00...0x1F, 0x7F...0x9F:
+                return false
+            default:
+                return !dangerous.contains(scalar)
+            }
+        }
+        let filtered = String(String.UnicodeScalarView(filteredScalars))
         return filtered.trimmingCharacters(in: .whitespacesAndNewlines)
     }

Based on learnings, the repo’s browser/terminal sanitizers strip both control characters and the dangerous-scalar set before trimming.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 5970 - 5999,
sanitizeCmuxConfigPaletteText currently only removes BiDi/zero-width scalars but
leaves C0 control characters (e.g. \r, \n, tab) which can hide malicious
suffixes; update sanitizeCmuxConfigPaletteText (and callers that build
CommandPaletteCommandContribution titles/subtitles) to also strip control
characters – e.g. filter out scalars where scalar.properties.isControl or remove
characters in CharacterSet.controlCharacters/newlines before trimming so the
full untrusted-text filter used elsewhere (browser/terminal sanitizers) is
mirrored here.
Sources/CmuxConfig.swift (1)

292-307: ⚠️ Potential issue | 🟠 Major

Recompute the nearest cmux.json instead of reloading the stale path.

After Line 301 sets localConfigPath, the local watcher callbacks only loadAll() or reattach that same path. Two cases stay wrong until the cwd changes: deleting /repo/sub/cmux.json never falls back to /repo/cmux.json, and creating a new nearer /repo/sub/cmux.json while /repo/cmux.json is active is never discovered. Keep the active cwd and rerun findCmuxConfig(startingFrom:) on local create/delete/rename events before reattaching.

Also applies to: 393-400, 430-438, 450-463

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 292 - 307, The file-watcher currently
reattaches to the previously stored localConfigPath and calls loadAll(), which
misses cases where a nearer cmux.json is created or the nearest one is deleted;
update the watcher event handlers (the code paths that call
stopLocalFileWatcher(), set localConfigPath, startLocalFileWatcher(), and
loadAll(), e.g., in updateLocalConfigPath and the analogous blocks around lines
393-400, 430-438, 450-463) to recompute the nearest config by calling
findCmuxConfig(startingFrom: currentWorkingDirectory) on create/delete/rename
events before setting localConfigPath — if the recomputed path differs, stop the
watcher, set localConfigPath to the recomputed value (or nil), restart the
watcher only if non-nil, and then call loadAll() so the active config always
reflects the nearest cmux.json.
🧹 Nitpick comments (1)
web/messages/bs.json (1)

305-368: Minor spelling inconsistency in Bosnian translation.

Line 349 uses "Točno" which is the Croatian spelling. For consistency with the rest of the file (which uses Bosnian conventions like "funkcioniše" on line 75), consider using "Tačno":

-      "splitChildren": "Točno dva dječja čvora (podjela ili panel)",
+      "splitChildren": "Tačno dva dječija čvora (podjela ili panel)",

Otherwise, the Custom Commands documentation strings are complete and well-structured.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/messages/bs.json` around lines 305 - 368, The string value for the
"splitChildren" key uses Croatian spelling "Točno dva dječja čvora (podjela ili
panel)"; update it to Bosnian spelling "Tačno dva dječja čvora (podjela ili
panel)" so it matches the rest of the Bosnian localization; locate the
"splitChildren" entry in the provided diff and replace "Točno" with "Tačno".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/CmuxConfig.swift`:
- Around line 93-98: The CmuxWorkspaceDefinition decoder currently allows any
string for the color field, so update decoding/validation for
CmuxWorkspaceDefinition.color to reject values that are not accepted by
WorkspaceTabColorSettings.normalizedHex(...) (only allow 6-digit hex "#RRGGBB"
with no alpha); during init(from:) or a custom decoding step, call
WorkspaceTabColorSettings.normalizedHex(colorString) and throw a DecodingError
if it returns nil, so downstream code (e.g., CmuxConfigExecutor ->
newWorkspace.setCustomColor(...)) never receives invalid colors.

In `@Sources/CmuxConfigExecutor.swift`:
- Around line 40-44: The confirm dialog currently interpolates raw command text
into CmuxConfigExecutor (alert.informativeText = String(format: messageFormat,
command)); update this to first sanitize the command by removing BiDi override
and zero-width characters using the same dangerousScalars filter used in
TerminalController.swift and BrowserPanel.swift (refer to their dangerousScalars
Set<UInt32> and removal logic), then use the sanitized string in the
String(format:) call so the preview shown matches the actual run-time command.

---

Duplicate comments:
In `@Sources/CmuxConfig.swift`:
- Around line 292-307: The file-watcher currently reattaches to the previously
stored localConfigPath and calls loadAll(), which misses cases where a nearer
cmux.json is created or the nearest one is deleted; update the watcher event
handlers (the code paths that call stopLocalFileWatcher(), set localConfigPath,
startLocalFileWatcher(), and loadAll(), e.g., in updateLocalConfigPath and the
analogous blocks around lines 393-400, 430-438, 450-463) to recompute the
nearest config by calling findCmuxConfig(startingFrom: currentWorkingDirectory)
on create/delete/rename events before setting localConfigPath — if the
recomputed path differs, stop the watcher, set localConfigPath to the recomputed
value (or nil), restart the watcher only if non-nil, and then call loadAll() so
the active config always reflects the nearest cmux.json.

In `@Sources/ContentView.swift`:
- Around line 6330-6346: Replace the currentDirectory emptiness check with a
trimmed check so whitespace-only paths are treated as missing: when building
baseCwd inside the registry closure (the block that registers commandId for each
item from cmuxConfigStore.loadedCommands and calls CmuxConfigExecutor.execute),
obtain rawCwd via tabManager.selectedWorkspace?.currentDirectory, trim it with
.trimmingCharacters(in: .whitespacesAndNewlines), and if the trimmed string is
empty use FileManager.default.homeDirectoryForCurrentUser.path; otherwise use
the trimmed value as baseCwd before calling CmuxConfigExecutor.execute with
configSourcePath and globalConfigPath.
- Around line 5970-5999: sanitizeCmuxConfigPaletteText currently only removes
BiDi/zero-width scalars but leaves C0 control characters (e.g. \r, \n, tab)
which can hide malicious suffixes; update sanitizeCmuxConfigPaletteText (and
callers that build CommandPaletteCommandContribution titles/subtitles) to also
strip control characters – e.g. filter out scalars where
scalar.properties.isControl or remove characters in
CharacterSet.controlCharacters/newlines before trimming so the full
untrusted-text filter used elsewhere (browser/terminal sanitizers) is mirrored
here.

---

Nitpick comments:
In `@web/messages/bs.json`:
- Around line 305-368: The string value for the "splitChildren" key uses
Croatian spelling "Točno dva dječja čvora (podjela ili panel)"; update it to
Bosnian spelling "Tačno dva dječja čvora (podjela ili panel)" so it matches the
rest of the Bosnian localization; locate the "splitChildren" entry in the
provided diff and replace "Točno" with "Tačno".

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 240094da-1a26-4f3d-a863-5af4a2c6448f

📥 Commits

Reviewing files that changed from the base of the PR and between 74b539a and eef345b.

📒 Files selected for processing (33)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/CmuxConfigExecutor.swift
  • Sources/CmuxDirectoryTrust.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • cmuxTests/CmuxConfigTests.swift
  • web/app/[locale]/components/docs-nav-items.ts
  • web/app/[locale]/docs/custom-commands/page.tsx
  • web/messages/ar.json
  • web/messages/bs.json
  • web/messages/da.json
  • web/messages/de.json
  • web/messages/en.json
  • web/messages/es.json
  • web/messages/fr.json
  • web/messages/it.json
  • web/messages/ja.json
  • web/messages/km.json
  • web/messages/ko.json
  • web/messages/no.json
  • web/messages/pl.json
  • web/messages/pt-BR.json
  • web/messages/ru.json
  • web/messages/th.json
  • web/messages/tr.json
  • web/messages/zh-CN.json
  • web/messages/zh-TW.json
✅ Files skipped from review due to trivial changes (14)
  • web/app/[locale]/components/docs-nav-items.ts
  • web/messages/it.json
  • web/messages/fr.json
  • web/messages/ko.json
  • web/messages/th.json
  • web/messages/pt-BR.json
  • web/messages/ar.json
  • web/messages/ru.json
  • Resources/Localizable.xcstrings
  • web/messages/no.json
  • web/messages/da.json
  • web/messages/es.json
  • cmuxTests/CmuxConfigTests.swift
  • web/messages/ja.json
🚧 Files skipped from review as they are similar to previous changes (11)
  • Sources/Panels/TerminalPanel.swift
  • web/app/[locale]/docs/custom-commands/page.tsx
  • web/messages/zh-CN.json
  • web/messages/zh-TW.json
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/cmuxApp.swift
  • Sources/AppDelegate.swift
  • web/messages/tr.json
  • Sources/GhosttyTerminalView.swift
  • Sources/Workspace.swift
  • web/messages/en.json

Comment thread Sources/CmuxConfig.swift
Comment on lines +93 to +98
struct CmuxWorkspaceDefinition: Codable, Sendable {
var name: String?
var cwd: String?
var color: String?
var layout: CmuxLayoutNode?
}
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 | 🟠 Major

Reject invalid workspace.color values during decode.

CmuxWorkspaceDefinition.color accepts any string, and Sources/CmuxConfigExecutor.swift Lines 112-113 later pass it straight to newWorkspace.setCustomColor(...). That creates a config-only path that is looser than the rest of the app’s workspace-color validation. Fail fast here unless WorkspaceTabColorSettings.normalizedHex(...) accepts the value.

Based on learnings: When validating or reporting workspace/tab colors in this repo, only accept and use 6-digit hex colors in the form #RRGGBB (no alpha, i.e., do not allow #RRGGBBAA).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 93 - 98, The CmuxWorkspaceDefinition
decoder currently allows any string for the color field, so update
decoding/validation for CmuxWorkspaceDefinition.color to reject values that are
not accepted by WorkspaceTabColorSettings.normalizedHex(...) (only allow 6-digit
hex "#RRGGBB" with no alpha); during init(from:) or a custom decoding step, call
WorkspaceTabColorSettings.normalizedHex(colorString) and throw a DecodingError
if it returns nil, so downstream code (e.g., CmuxConfigExecutor ->
newWorkspace.setCustomColor(...)) never receives invalid colors.

Comment thread Sources/CmuxConfigExecutor.swift Outdated
Strip zero-width and BiDi override characters from the command preview
so the dialog shows exactly what will be executed.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/CmuxConfigExecutor.swift">

<violation number="1" location="Sources/CmuxConfigExecutor.swift:44">
P2: Confirmation dialog shows a sanitized command, but execution runs the raw command, creating a display/consent mismatch for untrusted config commands.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

localized: "dialog.cmuxConfig.confirmCommand.messageWithCommand",
defaultValue: "This will run the following command:\n\n%@"
)
alert.informativeText = String(format: messageFormat, sanitizeForDisplay(command))
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026

Choose a reason for hiding this comment

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

P2: Confirmation dialog shows a sanitized command, but execution runs the raw command, creating a display/consent mismatch for untrusted config commands.

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

<comment>Confirmation dialog shows a sanitized command, but execution runs the raw command, creating a display/consent mismatch for untrusted config commands.</comment>

<file context>
@@ -41,7 +41,7 @@ struct CmuxConfigExecutor {
             defaultValue: "This will run the following command:\n\n%@"
         )
-        alert.informativeText = String(format: messageFormat, command)
+        alert.informativeText = String(format: messageFormat, sanitizeForDisplay(command))
         alert.alertStyle = .warning
         alert.addButton(withTitle: String(
</file context>
Fix with Cubic

@lawrencecchen lawrencecchen merged commit b9c656b into manaflow-ai:main Mar 25, 2026
12 of 13 checks passed
@lawrencecchen
Copy link
Copy Markdown
Contributor

Thank you for the contribution!

Jesssullivan added a commit to Jesssullivan/cmux that referenced this pull request Mar 26, 2026
Ingests all upstream fixes since 2026-03-22 including:
- Fix Cmd+N crash: retain snapshot workspaces (manaflow-ai#2183, manaflow-ai#2181, manaflow-ai#2178, manaflow-ai#2173)
- Fix browser pane restore after reopen (manaflow-ai#2141)
- Fix Ghostty resize_split keybind (manaflow-ai#1899)
- Reduce shell integration prompt latency (manaflow-ai#2109)
- Fix command palette focus after terminal find (manaflow-ai#2089)
- Add Codex CLI hooks (manaflow-ai#2103)
- Add cmux.json custom commands (manaflow-ai#2011)
- Fix window position restore on relaunch (manaflow-ai#2129)

Conflict resolution:
- BrowserPanel.swift: accepted upstream configureWebViewConfiguration()
  refactor (already includes our forMainFrameOnly:true CAPTCHA fix from PR manaflow-ai#1877)

Fork-specific files preserved:
- Sources/Panels/WebAuthn{Coordinator,BridgeJavaScript}.swift
- Sources/FIDO2/module.modulemap
- vendor/ctap2 submodule
- cmux.entitlements (with camera/audio-input removed)
- cmux.embedded.entitlements
- .github/workflows/fork-{ci,release}.yml
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
* Pre-launch app for browser UI test on headless CI runners

XCUIApplication.launch() blocks ~60s then fails on headless WarpBuild
runners because foreground activation requires a GUI login session.

Apply the same pre-launch strategy used for the display resolution test:
- CI shell launches the app with env vars before running xcodebuild
- Test detects pre-launched app via manifest, uses activate() instead of
  launch() to avoid killing and relaunching the app
- Falls back to clicking the window for focus via accessibility framework

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert "Pre-launch app for browser UI test on headless CI runners"

This reverts commit a540e2f.

* feat: cmux.json for custom commands

* tests: add cmux  json tests

* fix: pr review feedback: validation, translations, input handling, and palette improvements

  - Fix Danish ("Overfladedef inition") and Norwegian ("rotmapp") translation typos
  - Add empty-string check for baseCwd fallback in command palette handlers
  - Coalesce \r\n into single Return keypress in sendInput
  - Redact command text from timeout log to prevent secret leakage
  - Add decode-time validation: reject hybrid/empty commands, ambiguous layout
    nodes, wrong split children count, and empty pane surfaces
  - Namespace custom command IDs with "cmux.config.command." prefix
  - Forward command description to palette subtitle when available
  - Update tests for new validation rules and ID prefix

* fix: address PR review feedback — per-window config isolation, blank validation, ancestor walk,
  palette sanitization

* fix: fallback to current dir cmux.json watching if no any cmux.json found in full acesor walk

* ci: trigger CI for fork PR

* Add directory trust for cmux.json command confirmation

The confirm dialog now shows the actual command text and has an "Always
trust commands from this folder" checkbox. When checked, future confirm
commands from that directory skip the dialog.

Trust is scoped to the git repo root if the cmux.json is inside a repo,
so trusting once covers all subdirectories. Non-git directories are
trusted by exact path. Global config is always trusted.

Trusted directories are persisted in ~/Library/Application Support/cmux/
trusted-directories.json.

* Add trusted directories section to Settings

Shows all trusted directories with per-directory revoke buttons and a
Clear All option. Placed in a "Custom Commands" section between
Automation and Browser in Settings.

* Replace trusted directories list with editable textarea

One path per line, with a Save button that activates on changes.
Users can add, remove, or edit paths directly.

* Auto-save trusted directories on edit, remove Save button

Matches the behavior of other textarea settings (browser host
whitelist, external URL patterns) which auto-save via @AppStorage.

* Sanitize command text in confirm dialog against BiDi attacks

Strip zero-width and BiDi override characters from the command preview
so the dialog shows exactly what will be executed.

---------

Co-authored-by: austinpower1258 <austinwang115@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants