Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -90299,6 +90299,40 @@
}
}
},
"leftSidebar.mode.terminal": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Terminal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ターミナル"
}
}
}
},
"leftSidebar.mode.workspaces": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Workspaces"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペース"
}
}
}
},
"rightSidebar.mode.files": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -90333,6 +90367,40 @@
}
}
},
"rightSidebar.mode.terminal": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Terminal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ターミナル"
}
}
}
},
"sidebar.terminal.noWorkspace": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No workspace selected"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペースが選択されていません"
}
}
}
},
"sessionIndex.agent.claude": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -91059,4 +91127,4 @@
}
}
}
}
}
2 changes: 1 addition & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6037,7 +6037,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sourceManager: TabManager,
sourceWindowId: UUID
) {
guard sourceWorkspace.panels.isEmpty else { return }
guard !sourceWorkspace.hasBonsplitPanels else { return }
guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return }

if sourceManager.tabs.count > 1 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/AppleScriptSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ final class ScriptTerminal: NSObject {
return nil
}

if workspace.panels.count == 1 {
if workspace.bonsplitPanelCount == 1 {
if state.tabManager.tabs.count > 1 {
state.tabManager.closeWorkspace(workspace)
Comment on lines +622 to 624

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Close only the targeted terminal in AppleScript handler

This branch now uses bonsplitPanelCount for every terminal object, so closing a sidebar terminal via AppleScript can close the whole workspace/window when there is only one Bonsplit surface. Sidebar terminals are script-addressable through scriptingTerminalPanels()/terminalPanel(for:), so a script that asks to close a specific sidebar terminal can unexpectedly terminate unrelated primary surfaces.

Useful? React with 👍 / 👎.

return nil
Expand Down
102 changes: 93 additions & 9 deletions Sources/CmuxConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,87 @@ import Foundation

struct CmuxConfigFile: Codable, Sendable {
var commands: [CmuxCommandDefinition]
var sidebar: CmuxSidebarDefinition?

init(commands: [CmuxCommandDefinition] = [], sidebar: CmuxSidebarDefinition? = nil) {
self.commands = commands
self.sidebar = sidebar
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
commands = try container.decodeIfPresent([CmuxCommandDefinition].self, forKey: .commands) ?? []
sidebar = try container.decodeIfPresent(CmuxSidebarDefinition.self, forKey: .sidebar)
}
}

enum SidebarTerminalPlacement: String, Codable, Sendable {
case left
case right
}

struct CmuxSidebarTerminalDefinition: Codable, Sendable, Equatable {
var title: String?
var command: String?
var cwd: String?

init(title: String? = nil, command: String? = nil, cwd: String? = nil) {
self.title = title
self.command = command
self.cwd = cwd
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = Self.normalizedOptionalString(try container.decodeIfPresent(String.self, forKey: .title))
command = Self.normalizedOptionalString(try container.decodeIfPresent(String.self, forKey: .command))
cwd = Self.normalizedOptionalString(try container.decodeIfPresent(String.self, forKey: .cwd))
}

private static func normalizedOptionalString(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

struct CmuxSidebarSideDefinition: Codable, Sendable {
var terminal: CmuxSidebarTerminalDefinition?
}

struct CmuxSidebarDefinition: Codable, Sendable {
var left: CmuxSidebarSideDefinition?
var right: CmuxSidebarSideDefinition?

var runtimeConfiguration: CmuxSidebarConfiguration {
CmuxSidebarConfiguration(
leftTerminal: left?.terminal,
rightTerminal: right?.terminal
)
}
}

struct CmuxSidebarConfiguration: Sendable, Equatable {
var leftTerminal: CmuxSidebarTerminalDefinition? = nil
var rightTerminal: CmuxSidebarTerminalDefinition? = nil

static let empty = CmuxSidebarConfiguration()

func terminal(for placement: SidebarTerminalPlacement) -> CmuxSidebarTerminalDefinition? {
switch placement {
case .left:
return leftTerminal
case .right:
return rightTerminal
}
}

func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
CmuxSidebarConfiguration(
leftTerminal: overrides.leftTerminal ?? leftTerminal,

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Shallow merge of sidebar terminal config replaces the entire CmuxSidebarTerminalDefinition when a local override specifies any field. A local config like { "left": { "terminal": { "cwd": "app" } } } will nil-out the global command and title because overrides.leftTerminal is non-nil and replaces leftTerminal wholesale. Deep-merge individual fields (title, command, cwd) to match the documented "global config merged under local config" behavior.

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

<comment>Shallow merge of sidebar terminal config replaces the entire `CmuxSidebarTerminalDefinition` when a local override specifies any field. A local config like `{ "left": { "terminal": { "cwd": "app" } } }` will nil-out the global `command` and `title` because `overrides.leftTerminal` is non-nil and replaces `leftTerminal` wholesale. Deep-merge individual fields (`title`, `command`, `cwd`) to match the documented "global config merged under local config" behavior.</comment>

<file context>
@@ -4,6 +4,87 @@ import Foundation
+
+    func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
+        CmuxSidebarConfiguration(
+            leftTerminal: overrides.leftTerminal ?? leftTerminal,
+            rightTerminal: overrides.rightTerminal ?? rightTerminal
+        )
</file context>
Fix with Cubic

rightTerminal: overrides.rightTerminal ?? rightTerminal
)
Comment on lines +82 to +86

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

Deep-merge terminal definitions instead of replacing them.

With the current merge, a local config like terminal: { "cwd": "app" } replaces the whole global terminal and drops its command/title, so “global config merged under local config” only works at the side level.

Suggested partial-override merge
 struct CmuxSidebarTerminalDefinition: Codable, Sendable, Equatable {
@@
     private static func normalizedOptionalString(_ value: String?) -> String? {
         guard let value else { return nil }
         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
         return trimmed.isEmpty ? nil : trimmed
     }
+
+    func merging(overrides: CmuxSidebarTerminalDefinition) -> CmuxSidebarTerminalDefinition {
+        CmuxSidebarTerminalDefinition(
+            title: overrides.title ?? title,
+            command: overrides.command ?? command,
+            cwd: overrides.cwd ?? cwd
+        )
+    }
 }
@@
 struct CmuxSidebarConfiguration: Sendable, Equatable {
@@
+    private static func mergeTerminal(
+        _ base: CmuxSidebarTerminalDefinition?,
+        overrides: CmuxSidebarTerminalDefinition?
+    ) -> CmuxSidebarTerminalDefinition? {
+        guard let overrides else { return base }
+        guard let base else { return overrides }
+        return base.merging(overrides: overrides)
+    }
+
     func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
         CmuxSidebarConfiguration(
-            leftTerminal: overrides.leftTerminal ?? leftTerminal,
-            rightTerminal: overrides.rightTerminal ?? rightTerminal
+            leftTerminal: Self.mergeTerminal(leftTerminal, overrides: overrides.leftTerminal),
+            rightTerminal: Self.mergeTerminal(rightTerminal, overrides: overrides.rightTerminal)
         )
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 82 - 86, In merging(overrides:
CmuxSidebarConfiguration) update the leftTerminal/rightTerminal merge to
deep-merge terminal fields instead of replacing the whole terminal object: when
overrides.leftTerminal/rightTerminal is non-nil, produce a new CmuxTerminal
whose individual properties (e.g., command, title, cwd) take overrides' values
when present and fall back to the existing leftTerminal/rightTerminal values
otherwise; if overrides.leftTerminal/rightTerminal is nil, keep the original
terminal unchanged. Reference the merging(overrides:) function and the
leftTerminal/rightTerminal properties and merge each terminal field explicitly
rather than using the full-object nil-coalescing currently in place.

}
}

struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
Expand Down Expand Up @@ -259,6 +340,7 @@ enum CmuxSurfaceType: String, Codable, Sendable {
@MainActor
final class CmuxConfigStore: ObservableObject {
@Published private(set) var loadedCommands: [CmuxCommandDefinition] = []
@Published private(set) var sidebarConfiguration: CmuxSidebarConfiguration = .empty
@Published private(set) var configRevision: UInt64 = 0

/// Which config file each command came from, keyed by command id.
Expand Down Expand Up @@ -353,22 +435,22 @@ final class CmuxConfigStore: ObservableObject {
var commands: [CmuxCommandDefinition] = []
var seenNames = Set<String>()
var sourcePaths: [String: String] = [:]
let localConfig = localConfigPath.flatMap { parseConfig(at: $0) }
let globalConfig = parseConfig(at: globalConfigPath)

// Local config takes precedence
if let localPath = localConfigPath {
if let localConfig = parseConfig(at: localPath) {
for command in localConfig.commands {
if !seenNames.contains(command.name) {
commands.append(command)
seenNames.insert(command.name)
sourcePaths[command.id] = localPath
}
if let localPath = localConfigPath, let localConfig {
for command in localConfig.commands {
if !seenNames.contains(command.name) {
commands.append(command)
seenNames.insert(command.name)
sourcePaths[command.id] = localPath
}
}
}

// Global config fills in the rest
if let globalConfig = parseConfig(at: globalConfigPath) {
if let globalConfig {
for command in globalConfig.commands {
if !seenNames.contains(command.name) {
commands.append(command)
Expand All @@ -380,6 +462,8 @@ final class CmuxConfigStore: ObservableObject {

loadedCommands = commands
commandSourcePaths = sourcePaths
sidebarConfiguration = (globalConfig?.sidebar?.runtimeConfiguration ?? .empty)
.merging(overrides: localConfig?.sidebar?.runtimeConfiguration ?? .empty)
configRevision &+= 1
}

Expand Down
Loading
Loading