diff --git a/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings b/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings
index c73a8eff..de564664 100644
--- a/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings
+++ b/desktop/macos/AgentDockApp/Resources/en.lproj/Localizable.strings
@@ -346,3 +346,14 @@
"Change interface language?" = "Change interface language?";
"Changing the interface language restarts the AgentDock interface. Any unsaved changes in this window will be lost." = "Changing the interface language restarts the AgentDock interface. Any unsaved changes in this window will be lost.";
"Continue" = "Continue";
+"Add" = "Add";
+"Add custom ACP" = "Add custom ACP";
+"Agent name" = "ACP name";
+"Agent name cannot be empty." = "ACP name cannot be empty.";
+"Default ACP" = "Default ACP";
+"Delete" = "Delete";
+"Edit custom ACP" = "Edit custom ACP";
+"Name" = "Name";
+"Command" = "Command";
+"Args JSON" = "Args JSON";
+"Save" = "Save";
diff --git a/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings b/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings
index ff5f71d1..1b509eb0 100644
--- a/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings
+++ b/desktop/macos/AgentDockApp/Resources/zh-Hans.lproj/Localizable.strings
@@ -346,3 +346,14 @@
"Change interface language?" = "切换界面语言?";
"Changing the interface language restarts the AgentDock interface. Any unsaved changes in this window will be lost." = "切换界面语言会重启 AgentDock 界面,此窗口中未保存的更改将会丢失。";
"Continue" = "继续";
+"Add" = "添加";
+"Add custom ACP" = "添加自定义ACP";
+"Agent name" = "ACP 名称";
+"Agent name cannot be empty." = "ACP 名称不能为空。";
+"Default ACP" = "默认 ACP";
+"Delete" = "删除";
+"Edit custom ACP" = "编辑自定义ACP";
+"Name" = "名称";
+"Command" = "命令";
+"Args JSON" = "参数 JSON";
+"Save" = "保存";
diff --git a/desktop/macos/AgentDockApp/Sources/ACPConfiguration.swift b/desktop/macos/AgentDockApp/Sources/ACPConfiguration.swift
index 05e474e9..b3a16f3e 100644
--- a/desktop/macos/AgentDockApp/Sources/ACPConfiguration.swift
+++ b/desktop/macos/AgentDockApp/Sources/ACPConfiguration.swift
@@ -352,6 +352,7 @@ enum ACPAgentPreset: String, CaseIterable, Codable {
struct ACPProfileConfiguration: Codable, Equatable {
var id: String
+ var displayName: String? = nil
var kind: ACPAgentPreset
var command: String
var args: [String]
@@ -360,6 +361,7 @@ struct ACPProfileConfiguration: Codable, Equatable {
enum CodingKeys: String, CodingKey {
case id
+ case displayName = "display_name"
case kind
case command
case args
diff --git a/desktop/macos/AgentDockApp/Sources/AdvancedSettingsWindowController.swift b/desktop/macos/AgentDockApp/Sources/AdvancedSettingsWindowController.swift
index 22ceb802..2183ad2c 100644
--- a/desktop/macos/AgentDockApp/Sources/AdvancedSettingsWindowController.swift
+++ b/desktop/macos/AgentDockApp/Sources/AdvancedSettingsWindowController.swift
@@ -27,7 +27,7 @@ private enum BrowserConnectionMode: CaseIterable {
}
@MainActor
-final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDelegate {
+final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDelegate, NSWindowDelegate {
private let service: ServiceController
private let configurationController: ServiceConfigurationController
private let menuLoginAgent: MenuLoginAgentController
@@ -44,16 +44,10 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
private let browserCDPURL = NSTextField(string: "")
private let browserStatus = NSTextField(wrappingLabelWithString: "")
private let acpEnabled = NSButton(checkboxWithTitle: L10n.text("Enable Coding Agent"), target: nil, action: nil)
- private let acpProfile = NSPopUpButton(frame: .zero, pullsDown: false)
- private let acpProfileID = NSTextField(string: "")
- private let acpProfileEnabled = NSButton(checkboxWithTitle: L10n.text("Enable this profile"), target: nil, action: nil)
- private let acpProfileDefault = NSButton(checkboxWithTitle: L10n.text("Use as default"), target: nil, action: nil)
- private let acpAddProfile = NSPopUpButton(frame: .zero, pullsDown: true)
- private let acpRemoveProfile = NSButton(title: L10n.text("Remove profile"), target: nil, action: nil)
- private let acpAgent = NSPopUpButton(frame: .zero, pullsDown: false)
- private let acpCommand = NSTextField(string: "")
- private let acpArgsJSON = NSTextField(string: "[]")
- private let acpStatus = NSTextField(wrappingLabelWithString: "")
+ private let acpProfileList = NSStackView()
+ private let acpOverviewContainer = NSStackView()
+ private let acpDefaultProfileMenu = NSPopUpButton(frame: .zero, pullsDown: false)
+ private let acpAddCustomProfile = NSButton(title: "+ " + L10n.text("Add custom ACP"), target: nil, action: nil)
private let nexusEndpoint = NSTextField(string: "")
private let nexusPairingCode = NSSecureTextField(string: "")
private let nexusPairButton = NSButton(title: L10n.text("Pair and restart"), target: nil, action: nil)
@@ -62,6 +56,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let applyButton = NSButton(title: L10n.text("Apply and restart"), target: nil, action: nil)
private let cancelButton = NSButton(title: L10n.text("Cancel"), target: nil, action: nil)
+ private weak var activeCustomACPDialog: NSPanel?
private var currentConfiguration: ServiceConfiguration?
private var initialServiceAutostart = true
@@ -77,12 +72,16 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
private var initialACPDefaultProfile = ""
private var acpProfiles: [ACPProfileConfiguration] = []
private var acpDefaultProfile = ""
- private var activeACPProfileID = ""
private var isBusy = false
private var isUpdateInProgress = false
private var browserCDPRow: NSView?
- private var acpCommandRow: NSView?
- private var acpArgsRow: NSView?
+
+ private struct CustomACPProfileDialogResult {
+ let name: String
+ let command: String
+ let arguments: [String]
+ let deleteRequested: Bool
+ }
private var controlsLocked: Bool {
isBusy || isUpdateInProgress
@@ -102,14 +101,14 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
self.menuLoginAgent = menuLoginAgent
self.onChanged = onChanged
let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 680, height: 760),
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 760),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
window.title = L10n.text("AgentDock Advanced Settings")
window.isReleasedWhenClosed = false
- window.minSize = NSSize(width: 620, height: 520)
+ window.minSize = NSSize(width: 700, height: 560)
window.center()
super.init(window: window)
configureUI()
@@ -138,7 +137,6 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
initialACPDefaultProfile = configuration.acpDefaultProfile
acpProfiles = configuration.acpProfiles
acpDefaultProfile = configuration.acpDefaultProfile
- activeACPProfileID = configuration.acpDefaultProfile
serviceAutostart.state = status.autostartEnabled ? .on : .off
menuAutostart.state = initialMenuAutostart ? .on : .off
@@ -149,12 +147,10 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
browserCDPURL.stringValue = initialBrowserCDPURL
selectBrowserConnectionMode(initialBrowserConnectionMode)
acpEnabled.state = initialACPEnabled ? .on : .off
- refreshACPProfileMenu(selecting: activeACPProfileID)
- loadACPProfileIntoControls(activeACPProfileID)
+ refreshACPProfileOverview()
nexusPairingCode.stringValue = ""
refreshNexusStatus()
refreshBrowserStatus()
- refreshACPStatus()
showStatus("", isError: false)
setBusy(false)
refreshApplyState()
@@ -177,6 +173,12 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
private func configureUI() {
guard let contentView = window?.contentView else { return }
+ // 同类下拉框使用统一宽度;连接方式文案更长,单独保留一档较宽尺寸。
+ let compactPopUpWidth: CGFloat = 110
+ let widePopUpWidth: CGFloat = 220
+ let acpChildIndent: CGFloat = 18
+ let acpListWidth: CGFloat = 250
+
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
@@ -193,7 +195,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
languagePreference.lastItem?.representedObject = preference.rawValue
}
selectLanguagePreference(L10n.languagePreference())
- languagePreference.widthAnchor.constraint(equalToConstant: 220).isActive = true
+ languagePreference.widthAnchor.constraint(equalToConstant: compactPopUpWidth).isActive = true
languagePreference.target = self
languagePreference.action = #selector(languageChanged)
@@ -217,7 +219,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
portField.delegate = self
logLevel.addItems(withTitles: ["debug", "info", "warn", "error"])
- logLevel.widthAnchor.constraint(equalToConstant: 120).isActive = true
+ logLevel.widthAnchor.constraint(equalToConstant: compactPopUpWidth).isActive = true
logLevel.target = self
logLevel.action = #selector(markChanged)
@@ -227,7 +229,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
browserEnabled.target = self
browserEnabled.action = #selector(browserToggled)
browserConnectionMode.addItems(withTitles: BrowserConnectionMode.allCases.map(\.title))
- browserConnectionMode.widthAnchor.constraint(equalToConstant: 360).isActive = true
+ browserConnectionMode.widthAnchor.constraint(equalToConstant: widePopUpWidth).isActive = true
browserConnectionMode.target = self
browserConnectionMode.action = #selector(browserConnectionChanged)
browserCDPURL.placeholderString = L10n.text("For example: http://127.0.0.1:9222")
@@ -240,41 +242,15 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
acpEnabled.target = self
acpEnabled.action = #selector(acpChanged)
- acpProfile.widthAnchor.constraint(equalToConstant: 220).isActive = true
- acpProfile.target = self
- acpProfile.action = #selector(acpProfileChanged)
- acpProfileID.placeholderString = "zcode"
- acpProfileID.target = self
- acpProfileID.action = #selector(markChanged)
- acpProfileID.delegate = self
- acpProfileEnabled.target = self
- acpProfileEnabled.action = #selector(acpProfileFlagsChanged)
- acpProfileDefault.target = self
- acpProfileDefault.action = #selector(acpProfileFlagsChanged)
- acpAddProfile.addItem(withTitle: L10n.text("Add profile…"))
- for preset in ACPAgentPreset.allCases {
- acpAddProfile.addItem(withTitle: preset.title)
- acpAddProfile.lastItem?.representedObject = preset.rawValue
- }
- acpAddProfile.target = self
- acpAddProfile.action = #selector(addACPProfile)
- acpRemoveProfile.target = self
- acpRemoveProfile.action = #selector(removeACPProfile)
-
- acpAgent.addItems(withTitles: ACPAgentPreset.allCases.map(\.title))
- acpAgent.widthAnchor.constraint(equalToConstant: 220).isActive = true
- acpAgent.target = self
- acpAgent.action = #selector(acpChanged)
- acpCommand.placeholderString = "/absolute/path/to/acp-adapter"
- acpCommand.target = self
- acpCommand.action = #selector(markChanged)
- acpCommand.delegate = self
- acpArgsJSON.placeholderString = "[]"
- acpArgsJSON.target = self
- acpArgsJSON.action = #selector(markChanged)
- acpArgsJSON.delegate = self
- acpStatus.textColor = .secondaryLabelColor
- acpStatus.font = .systemFont(ofSize: 12)
+ acpProfileList.orientation = .vertical
+ acpProfileList.alignment = .leading
+ acpProfileList.spacing = 0
+ acpAddCustomProfile.bezelStyle = .inline
+ acpAddCustomProfile.target = self
+ acpAddCustomProfile.action = #selector(addCustomACPProfile)
+ acpDefaultProfileMenu.target = self
+ acpDefaultProfileMenu.action = #selector(defaultACPProfileChanged)
+ acpDefaultProfileMenu.widthAnchor.constraint(equalToConstant: compactPopUpWidth).isActive = true
nexusEndpoint.placeholderString = "https://nexus.example.com"
nexusEndpoint.target = self
@@ -292,7 +268,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
nexusDeviceTokenStatus.maximumNumberOfLines = 2
nexusDeviceTokenStatus.heightAnchor.constraint(equalToConstant: 34).isActive = true
- for flexibleView in [browserCDPURL, browserStatus, acpProfileID, acpCommand, acpArgsJSON, acpStatus, nexusEndpoint, nexusPairingCode, nexusDeviceTokenStatus] {
+ for flexibleView in [browserCDPURL, browserStatus, nexusEndpoint, nexusPairingCode, nexusDeviceTokenStatus] {
flexibleView.setContentHuggingPriority(.defaultLow, for: .horizontal)
flexibleView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
@@ -319,7 +295,6 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
let startupStack = NSStackView(views: [
serviceAutostart,
menuAutostart,
- formRow(title: L10n.text("Interface language"), control: languagePreference),
])
startupStack.orientation = .vertical
startupStack.alignment = .leading
@@ -329,6 +304,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
mcpAppsEnabled,
formRow(title: L10n.text("Service port"), control: portField),
formRow(title: L10n.text("Log level"), control: logLevel),
+ formRow(title: L10n.text("Interface language"), control: languagePreference),
])
serviceForm.orientation = .vertical
serviceForm.alignment = .leading
@@ -348,31 +324,21 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
cdpRow.widthAnchor.constraint(equalTo: browserStack.widthAnchor).isActive = true
browserStatus.widthAnchor.constraint(equalTo: browserStack.widthAnchor).isActive = true
- let commandRow = formRow(title: "Command", control: acpCommand, fillsAvailableWidth: true)
- let argsRow = formRow(title: "Args JSON", control: acpArgsJSON, fillsAvailableWidth: true)
- let profileIDRow = formRow(title: "Profile ID", control: acpProfileID, fillsAvailableWidth: true)
- let profileActions = NSStackView(views: [acpProfileEnabled, acpProfileDefault, acpAddProfile, acpRemoveProfile])
- profileActions.orientation = .horizontal
- profileActions.spacing = 8
- acpCommandRow = commandRow
- acpArgsRow = argsRow
- let acpStack = NSStackView(views: [
- acpEnabled,
- formRow(title: L10n.text("Profile"), control: acpProfile),
- profileIDRow,
- profileActions,
- formRow(title: L10n.text("Type"), control: acpAgent),
- commandRow,
- argsRow,
- acpStatus,
- ])
+ let defaultProfileRow = formRow(title: L10n.text("Default ACP"), control: acpDefaultProfileMenu)
+ acpOverviewContainer.setViews([defaultProfileRow, acpProfileList, acpAddCustomProfile], in: .top)
+ acpOverviewContainer.orientation = .vertical
+ acpOverviewContainer.alignment = .leading
+ acpOverviewContainer.spacing = 8
+ // 总开关保持一级;默认项、Agent 列表和新增入口整体缩进,形成清晰的父子层级。
+ acpOverviewContainer.edgeInsets = NSEdgeInsets(top: 0, left: acpChildIndent, bottom: 0, right: 0)
+ // 列表与默认 ACP 表单保持接近的内容宽度,让复选框落在下拉框右缘附近。
+ acpProfileList.widthAnchor.constraint(equalToConstant: acpListWidth).isActive = true
+
+ let acpStack = NSStackView(views: [acpEnabled, acpOverviewContainer])
acpStack.orientation = .vertical
acpStack.alignment = .leading
- acpStack.spacing = 8
- commandRow.widthAnchor.constraint(equalTo: acpStack.widthAnchor).isActive = true
- argsRow.widthAnchor.constraint(equalTo: acpStack.widthAnchor).isActive = true
- profileIDRow.widthAnchor.constraint(equalTo: acpStack.widthAnchor).isActive = true
- acpStatus.widthAnchor.constraint(equalTo: acpStack.widthAnchor).isActive = true
+ acpStack.spacing = 10
+ acpOverviewContainer.widthAnchor.constraint(equalTo: acpStack.widthAnchor).isActive = true
let nexusEndpointRow = formRow(title: "Endpoint", control: nexusEndpoint, fillsAvailableWidth: true)
let nexusPairingCodeRow = formRow(title: L10n.text("Pairing code"), control: nexusPairingCode, fillsAvailableWidth: true)
@@ -552,107 +518,241 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
if obj.object as? NSTextField === browserCDPURL {
refreshBrowserStatus()
}
- if obj.object as? NSTextField === acpProfileID
- || obj.object as? NSTextField === acpCommand
- || obj.object as? NSTextField === acpArgsJSON {
- refreshACPStatus()
- }
refreshApplyState()
}
@objc private func acpChanged() {
- refreshACPStatus()
refreshApplyState()
}
- @objc private func acpProfileChanged() {
- guard let selectedID = acpProfile.selectedItem?.representedObject as? String,
- selectedID != activeACPProfileID else { return }
- guard saveActiveACPProfileFromControls(showErrors: true) else {
- refreshACPProfileMenu(selecting: activeACPProfileID)
- return
+ @objc private func acpOverviewToggleChanged(_ sender: NSButton) {
+ guard !controlsLocked, let key = sender.identifier?.rawValue else { return }
+ if key.hasPrefix("builtin:") {
+ let rawKind = String(key.dropFirst("builtin:".count))
+ guard let preset = ACPAgentPreset(rawValue: rawKind), preset != .custom else { return }
+ if let index = acpProfiles.firstIndex(where: { $0.kind == preset }) {
+ updateACPProfileEnabled(at: index, enabled: sender.state == .on)
+ } else if sender.state == .on {
+ let resolution = preset.resolveAdapter()
+ acpProfiles.append(ACPProfileConfiguration(
+ id: preset.rawValue,
+ displayName: nil,
+ kind: preset,
+ command: resolution.command,
+ args: resolution.arguments,
+ envFromEnv: nil,
+ enabled: true
+ ))
+ if acpDefaultProfile.isEmpty {
+ acpDefaultProfile = preset.rawValue
+ }
+ }
+ } else if key.hasPrefix("profile:") {
+ let profileID = String(key.dropFirst("profile:".count))
+ guard let index = acpProfiles.firstIndex(where: { $0.id == profileID }) else { return }
+ updateACPProfileEnabled(at: index, enabled: sender.state == .on)
}
- refreshACPProfileMenu(selecting: selectedID)
- activeACPProfileID = selectedID
- loadACPProfileIntoControls(selectedID)
- refreshACPStatus()
+ refreshACPProfileOverview()
refreshApplyState()
}
- @objc private func acpProfileFlagsChanged() {
- guard !activeACPProfileID.isEmpty else { return }
- if acpProfileDefault.state == .off, acpDefaultProfile == activeACPProfileID {
- acpProfileDefault.state = .on
- } else if acpProfileDefault.state == .on {
- acpDefaultProfile = activeACPProfileID
+ private func updateACPProfileEnabled(at index: Int, enabled: Bool) {
+ let profileID = acpProfiles[index].id
+ acpProfiles[index].enabled = enabled
+ if !enabled, acpDefaultProfile == profileID {
+ acpDefaultProfile = acpProfiles.first(where: { $0.id != profileID && $0.enabled })?.id ?? ""
}
- if acpProfileDefault.state == .on, acpProfileEnabled.state == .off {
- acpProfileEnabled.state = .on
+ if enabled, acpDefaultProfile.isEmpty {
+ acpDefaultProfile = profileID
}
- _ = saveActiveACPProfileFromControls(showErrors: false)
- refreshACPProfileMenu(selecting: activeACPProfileID)
- refreshACPStatus()
+ }
+
+ @objc private func defaultACPProfileChanged() {
+ guard !controlsLocked,
+ let profileID = acpDefaultProfileMenu.selectedItem?.representedObject as? String,
+ acpProfiles.contains(where: { $0.id == profileID && $0.enabled }) else { return }
+ acpDefaultProfile = profileID
refreshApplyState()
}
- @objc private func addACPProfile() {
- defer { acpAddProfile.selectItem(at: 0) }
- guard let raw = acpAddProfile.selectedItem?.representedObject as? String,
- let preset = ACPAgentPreset(rawValue: raw) else { return }
- guard saveActiveACPProfileFromControls(showErrors: true) else { return }
-
- let id: String
- if preset == .custom {
- var candidate = "custom"
- var suffix = 2
- let existing = Set(acpProfiles.map(\.id))
- while existing.contains(candidate) {
- candidate = "custom-\(suffix)"
- suffix += 1
+ @objc private func editCustomACPProfile(_ sender: NSClickGestureRecognizer) {
+ guard !controlsLocked,
+ let key = sender.view?.identifier?.rawValue,
+ key.hasPrefix("profile:") else { return }
+ let profileID = String(key.dropFirst("profile:".count))
+ guard let index = acpProfiles.firstIndex(where: { $0.id == profileID && $0.kind == .custom }),
+ let result = showCustomACPProfileDialog(existing: acpProfiles[index]) else { return }
+ if result.deleteRequested {
+ let removedID = acpProfiles[index].id
+ acpProfiles.remove(at: index)
+ if acpDefaultProfile == removedID {
+ acpDefaultProfile = acpProfiles.first(where: \.enabled)?.id ?? ""
}
- id = candidate
} else {
- id = preset.rawValue
- if acpProfiles.contains(where: { $0.id == id }) {
- activeACPProfileID = id
- refreshACPProfileMenu(selecting: id)
- loadACPProfileIntoControls(id)
- return
- }
+ // profile.id 是已有 Session 的稳定身份;重命名或修改命令时只更新可见配置。
+ acpProfiles[index].displayName = result.name
+ acpProfiles[index].command = result.command
+ acpProfiles[index].args = result.arguments
}
+ refreshACPProfileOverview()
+ refreshApplyState()
+ }
- let resolution = preset == .custom ? nil : preset.resolveAdapter()
+ @objc private func addCustomACPProfile() {
+ guard !controlsLocked, let result = showCustomACPProfileDialog(existing: nil) else { return }
+ let id = uniqueCustomACPProfileID(for: result.name)
acpProfiles.append(ACPProfileConfiguration(
id: id,
- kind: preset,
- command: resolution?.command ?? "",
- args: resolution?.arguments ?? [],
+ displayName: result.name,
+ kind: .custom,
+ command: result.command,
+ args: result.arguments,
envFromEnv: nil,
- enabled: true
+ enabled: false
))
- if acpDefaultProfile.isEmpty {
- acpDefaultProfile = id
- }
- activeACPProfileID = id
- refreshACPProfileMenu(selecting: id)
- loadACPProfileIntoControls(id)
- refreshACPStatus()
+ refreshACPProfileOverview()
refreshApplyState()
}
- @objc private func removeACPProfile() {
- guard acpProfiles.count > 1,
- let index = acpProfiles.firstIndex(where: { $0.id == activeACPProfileID }) else { return }
- let removedID = acpProfiles[index].id
- acpProfiles.remove(at: index)
- if acpDefaultProfile == removedID {
- acpDefaultProfile = acpProfiles.first(where: \.enabled)?.id ?? acpProfiles[0].id
+ private func showCustomACPProfileDialog(existing: ACPProfileConfiguration?) -> CustomACPProfileDialogResult? {
+ let editing = existing != nil
+ let panel = NSPanel(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 252),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ panel.title = L10n.text(editing ? "Edit custom ACP" : "Add custom ACP")
+ panel.isReleasedWhenClosed = false
+
+ let nameField = NSTextField(string: existing.map(acpDisplayName) ?? "")
+ nameField.placeholderString = L10n.text("Agent name")
+ let commandField = NSTextField(string: existing?.command ?? "")
+ commandField.placeholderString = "/absolute/path/to/acp-adapter"
+ let argsField = NSTextField(string: (try? ACPDesktopConfiguration.encodeArguments(existing?.args ?? [])) ?? "[]")
+ argsField.placeholderString = "[]"
+
+ let form = NSGridView(views: [
+ [NSTextField(labelWithString: L10n.text("Name")), nameField],
+ [NSTextField(labelWithString: L10n.text("Command")), commandField],
+ [NSTextField(labelWithString: L10n.text("Args JSON")), argsField],
+ ])
+ form.rowSpacing = 10
+ form.columnSpacing = 12
+ form.column(at: 0).xPlacement = .leading
+ form.column(at: 0).width = 72
+ form.column(at: 1).xPlacement = .fill
+ for field in [nameField, commandField, argsField] {
+ field.widthAnchor.constraint(equalToConstant: 360).isActive = true
+ }
+
+ let confirmButton = NSButton(title: L10n.text(editing ? "Save" : "Add"), target: nil, action: nil)
+ confirmButton.bezelStyle = .rounded
+ confirmButton.keyEquivalent = "\r"
+ let dialogCancelButton = NSButton(title: L10n.text("Cancel"), target: nil, action: nil)
+ dialogCancelButton.bezelStyle = .rounded
+ dialogCancelButton.keyEquivalent = "\u{1b}"
+ let deleteButton = editing ? NSButton(title: L10n.text("Delete"), target: nil, action: nil) : nil
+ deleteButton?.bezelStyle = .rounded
+
+ let spacer = NSView()
+ spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ let buttons = NSStackView()
+ buttons.orientation = .horizontal
+ buttons.alignment = .centerY
+ buttons.spacing = 8
+ if let deleteButton {
+ buttons.addArrangedSubview(deleteButton)
}
- activeACPProfileID = acpProfiles[min(index, acpProfiles.count - 1)].id
- refreshACPProfileMenu(selecting: activeACPProfileID)
- loadACPProfileIntoControls(activeACPProfileID)
- refreshACPStatus()
- refreshApplyState()
+ buttons.addArrangedSubview(spacer)
+ buttons.addArrangedSubview(dialogCancelButton)
+ buttons.addArrangedSubview(confirmButton)
+
+ form.translatesAutoresizingMaskIntoConstraints = false
+ buttons.translatesAutoresizingMaskIntoConstraints = false
+ guard let contentView = panel.contentView else { return nil }
+ contentView.addSubview(form)
+ contentView.addSubview(buttons)
+ NSLayoutConstraint.activate([
+ form.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
+ form.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24),
+ form.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -24),
+ buttons.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
+ buttons.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
+ buttons.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18),
+ ])
+
+ let deleteResponse = NSApplication.ModalResponse(rawValue: 1001)
+ confirmButton.target = self
+ confirmButton.action = #selector(confirmCustomACPDialog(_:))
+ dialogCancelButton.target = self
+ dialogCancelButton.action = #selector(cancelCustomACPDialog(_:))
+ if let deleteButton {
+ deleteButton.target = self
+ deleteButton.action = #selector(deleteCustomACPDialog(_:))
+ }
+ activeCustomACPDialog = panel
+ panel.delegate = self
+
+ if let parentWindow = window {
+ let parentFrame = parentWindow.frame
+ let panelFrame = panel.frame
+ panel.setFrameOrigin(NSPoint(
+ x: parentFrame.midX - panelFrame.width / 2,
+ y: parentFrame.midY - panelFrame.height / 2
+ ))
+ } else {
+ panel.center()
+ }
+ panel.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ let response = NSApp.runModal(for: panel)
+ activeCustomACPDialog = nil
+ panel.delegate = nil
+ panel.orderOut(nil)
+
+ if editing, response == deleteResponse {
+ return CustomACPProfileDialogResult(name: "", command: "", arguments: [], deleteRequested: true)
+ }
+ guard response == .OK else { return nil }
+
+ let name = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty else {
+ showStatus(L10n.text("Agent name cannot be empty."), isError: true)
+ return nil
+ }
+ let arguments: [String]
+ do {
+ arguments = try ACPDesktopConfiguration.decodeArguments(argsField.stringValue)
+ } catch {
+ showStatus(error.localizedDescription, isError: true)
+ return nil
+ }
+ return CustomACPProfileDialogResult(
+ name: name,
+ command: commandField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+ arguments: arguments,
+ deleteRequested: false
+ )
+ }
+
+ @objc private func confirmCustomACPDialog(_ sender: Any?) {
+ NSApp.stopModal(withCode: .OK)
+ }
+
+ @objc private func cancelCustomACPDialog(_ sender: Any?) {
+ NSApp.stopModal(withCode: .cancel)
+ }
+
+ @objc private func deleteCustomACPDialog(_ sender: Any?) {
+ NSApp.stopModal(withCode: NSApplication.ModalResponse(rawValue: 1001))
+ }
+
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
+ guard sender === activeCustomACPDialog else { return true }
+ NSApp.stopModal(withCode: .cancel)
+ return false
}
@objc private func browserConnectionChanged() {
@@ -674,8 +774,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
@objc private func applyPressed() {
guard !isUpdateInProgress else { return }
guard currentConfiguration != nil else { return }
- guard saveActiveACPProfileFromControls(showErrors: true) else { return }
- guard acpProfiles.contains(where: { $0.id == acpDefaultProfile }) else {
+ if acpEnabled.state == .on, !acpProfiles.contains(where: { $0.id == acpDefaultProfile && $0.enabled }) {
showStatus(L10n.text("Choose a default Coding Agent profile."), isError: true)
return
}
@@ -726,11 +825,7 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
initialACPDefaultProfile = validatedSettings.acpDefaultProfile
acpProfiles = validatedSettings.acpProfiles
acpDefaultProfile = validatedSettings.acpDefaultProfile
- if !acpProfiles.contains(where: { $0.id == activeACPProfileID }) {
- activeACPProfileID = acpDefaultProfile
- }
- refreshACPProfileMenu(selecting: activeACPProfileID)
- loadACPProfileIntoControls(activeACPProfileID)
+ refreshACPProfileOverview()
portField.integerValue = initialPort
logLevel.selectItem(withTitle: initialLogLevel)
mcpAppsEnabled.state = initialMCPAppsEnabled ? .on : .off
@@ -740,7 +835,6 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
currentConfiguration = updatedConfiguration
}
refreshBrowserStatus()
- refreshACPStatus()
showStatus(L10n.text("Settings saved."), isError: false)
setBusy(false)
refreshApplyState()
@@ -798,94 +892,104 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
languagePreference.selectItem(at: 0)
}
- private func refreshACPProfileMenu(selecting profileID: String) {
- acpProfile.removeAllItems()
- for profile in acpProfiles {
- let suffix = profile.id == acpDefaultProfile ? " · Default" : ""
- acpProfile.addItem(withTitle: "\(profile.id) · \(profile.kind.title)\(suffix)")
- acpProfile.lastItem?.representedObject = profile.id
- }
- if let item = acpProfile.itemArray.first(where: { ($0.representedObject as? String) == profileID }) {
- acpProfile.select(item)
- } else if !acpProfiles.isEmpty {
- acpProfile.selectItem(at: 0)
+ private func refreshACPProfileOverview() {
+ for view in acpProfileList.arrangedSubviews {
+ acpProfileList.removeArrangedSubview(view)
+ view.removeFromSuperview()
}
- }
- private func loadACPProfileIntoControls(_ profileID: String) {
- guard let profile = acpProfiles.first(where: { $0.id == profileID }) else {
- acpProfileID.stringValue = ""
- acpProfileEnabled.state = .off
- acpProfileDefault.state = .off
- acpAgent.selectItem(withTitle: ACPAgentPreset.custom.title)
- acpCommand.stringValue = ""
- acpArgsJSON.stringValue = "[]"
- return
+ for preset in [ACPAgentPreset.codex, .claude, .grok] {
+ let profile = acpProfiles.first(where: { $0.kind == preset })
+ addACPOverviewRow(acpOverviewRow(preset: preset, profile: profile))
+ }
+ for profile in acpProfiles where profile.kind == .custom {
+ addACPOverviewRow(acpOverviewRow(preset: .custom, profile: profile))
}
- activeACPProfileID = profile.id
- acpProfileID.stringValue = profile.id
- acpProfileEnabled.state = profile.enabled ? .on : .off
- acpProfileDefault.state = profile.id == acpDefaultProfile ? .on : .off
- acpAgent.selectItem(withTitle: profile.kind.title)
- acpCommand.stringValue = profile.kind == .custom ? profile.command : ""
- acpArgsJSON.stringValue = (try? ACPDesktopConfiguration.encodeArguments(
- profile.kind == .custom ? profile.args : []
- )) ?? "[]"
+ refreshACPDefaultProfileMenu()
+ acpAddCustomProfile.isEnabled = !controlsLocked
}
- @discardableResult
- private func saveActiveACPProfileFromControls(showErrors: Bool) -> Bool {
- guard let index = acpProfiles.firstIndex(where: { $0.id == activeACPProfileID }) else {
- return acpProfiles.isEmpty
- }
- var profile = acpProfiles[index]
- let oldID = profile.id
- let newID = profile.kind == .custom
- ? acpProfileID.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- : profile.kind.rawValue
- if newID.isEmpty || acpProfiles.enumerated().contains(where: { $0.offset != index && $0.element.id == newID }) {
- if showErrors {
- showStatus(L10n.text("Coding Agent profile IDs must be non-empty and unique."), isError: true)
- }
- return false
+ private func refreshACPDefaultProfileMenu() {
+ let enabledProfiles = acpProfiles.filter(\.enabled)
+ if !enabledProfiles.contains(where: { $0.id == acpDefaultProfile }) {
+ acpDefaultProfile = enabledProfiles.first?.id ?? ""
}
-
- if profile.kind == .custom {
- do {
- profile.args = try ACPDesktopConfiguration.decodeArguments(acpArgsJSON.stringValue)
- } catch {
- if showErrors {
- showStatus(error.localizedDescription, isError: true)
- }
- return false
- }
- profile.command = acpCommand.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ acpDefaultProfileMenu.removeAllItems()
+ for profile in enabledProfiles {
+ acpDefaultProfileMenu.addItem(withTitle: acpDisplayName(profile))
+ acpDefaultProfileMenu.lastItem?.representedObject = profile.id
}
- profile.id = newID
- profile.enabled = acpProfileEnabled.state == .on
- acpProfiles[index] = profile
- if acpDefaultProfile == oldID {
- acpDefaultProfile = newID
+ if let index = enabledProfiles.firstIndex(where: { $0.id == acpDefaultProfile }) {
+ acpDefaultProfileMenu.selectItem(at: index)
}
- activeACPProfileID = newID
- return true
+ acpDefaultProfileMenu.isEnabled = !controlsLocked && !enabledProfiles.isEmpty
}
- private func acpControlDiffersFromModel() -> Bool {
- guard let profile = acpProfiles.first(where: { $0.id == activeACPProfileID }) else { return false }
- let displayedID = acpProfileID.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- if displayedID != profile.id || (acpProfileEnabled.state == .on) != profile.enabled {
- return true
- }
- if (acpProfileDefault.state == .on) != (profile.id == acpDefaultProfile) {
- return true
+ private func addACPOverviewRow(_ row: NSView) {
+ // 必须先把行加入 StackView 层级,再激活跨视图宽度约束;
+ // 否则 AppKit 会因为两边尚无共同祖先直接抛出 NSGenericException。
+ acpProfileList.addArrangedSubview(row)
+ row.widthAnchor.constraint(equalTo: acpProfileList.widthAnchor).isActive = true
+ }
+
+ private func acpOverviewRow(preset: ACPAgentPreset, profile: ACPProfileConfiguration?) -> NSView {
+ let title = profile.map(acpDisplayName) ?? preset.title
+ let key = profile.map { "profile:\($0.id)" } ?? "builtin:\(preset.rawValue)"
+ let nameView = NSTextField(labelWithString: title)
+ nameView.font = .systemFont(ofSize: 13, weight: .medium)
+ if profile?.kind == .custom {
+ nameView.identifier = NSUserInterfaceItemIdentifier(key)
+ let editGesture = NSClickGestureRecognizer(target: self, action: #selector(editCustomACPProfile(_:)))
+ nameView.addGestureRecognizer(editGesture)
}
- guard profile.kind == .custom else { return false }
- if acpCommand.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) != profile.command {
- return true
+
+ let toggle = NSButton(checkboxWithTitle: "", target: self, action: #selector(acpOverviewToggleChanged(_:)))
+ toggle.identifier = NSUserInterfaceItemIdentifier(key)
+ toggle.state = profile?.enabled == true ? .on : .off
+ toggle.isEnabled = !controlsLocked
+
+ let row = NSView()
+ for view in [nameView, toggle] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ row.addSubview(view)
}
- let encoded = (try? ACPDesktopConfiguration.encodeArguments(profile.args)) ?? "[]"
- return acpArgsJSON.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) != encoded
+ NSLayoutConstraint.activate([
+ row.heightAnchor.constraint(equalToConstant: 28),
+ nameView.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
+ nameView.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+ nameView.trailingAnchor.constraint(lessThanOrEqualTo: toggle.leadingAnchor, constant: -12),
+ toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -6),
+ toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+ ])
+ return row
+ }
+
+ private func acpDisplayName(_ profile: ACPProfileConfiguration) -> String {
+ guard profile.kind == .custom else { return profile.kind.title }
+ let name = profile.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return name.isEmpty ? profile.id : name
+ }
+
+ private func uniqueCustomACPProfileID(for name: String) -> String {
+ let latin = name.applyingTransform(.toLatin, reverse: false) ?? name
+ var base = latin.lowercased().unicodeScalars.map { scalar -> Character in
+ let value = scalar.value
+ if (48...57).contains(value) || (97...122).contains(value) {
+ return Character(String(scalar))
+ }
+ return "-"
+ }.reduce(into: "") { $0.append($1) }
+ while base.contains("--") { base = base.replacingOccurrences(of: "--", with: "-") }
+ base = base.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
+ if base.isEmpty { base = "custom" }
+ if ["codex", "claude", "grok"].contains(base) { base += "-custom" }
+ if base.count > 48 { base = String(base.prefix(48)).trimmingCharacters(in: CharacterSet(charactersIn: "-")) }
+
+ let existing = Set(acpProfiles.map(\.id))
+ if !existing.contains(base) { return base }
+ var suffix = 2
+ while existing.contains("\(base)-\(suffix)") { suffix += 1 }
+ return "\(base)-\(suffix)"
}
private func selectedBrowserConnectionMode() -> BrowserConnectionMode {
@@ -897,64 +1001,6 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
browserConnectionMode.selectItem(withTitle: mode.title)
}
- private func refreshACPStatus() {
- guard let profile = acpProfiles.first(where: { $0.id == activeACPProfileID }) else {
- acpStatus.stringValue = L10n.text("Add a Coding Agent profile to continue.")
- acpStatus.textColor = .secondaryLabelColor
- for control in [acpProfile, acpProfileID, acpProfileEnabled, acpProfileDefault, acpAgent, acpCommand, acpArgsJSON, acpRemoveProfile] {
- control.isEnabled = false
- }
- acpAddProfile.isEnabled = !controlsLocked
- return
- }
-
- let preset = profile.kind
- let isCustom = preset == .custom
- let globallyEnabled = acpEnabled.state == .on
- let profileEnabled = acpProfileEnabled.state == .on
- let effectiveEnabled = globallyEnabled && profileEnabled
- acpCommandRow?.isHidden = !isCustom
- acpArgsRow?.isHidden = !isCustom
-
- acpProfile.isEnabled = !controlsLocked
- acpProfileID.isEnabled = !controlsLocked && isCustom
- acpProfileEnabled.isEnabled = !controlsLocked
- acpProfileDefault.isEnabled = !controlsLocked
- acpAddProfile.isEnabled = !controlsLocked
- acpRemoveProfile.isEnabled = !controlsLocked && acpProfiles.count > 1
- acpAgent.isEnabled = false
- acpCommand.isEnabled = !controlsLocked && isCustom
- acpArgsJSON.isEnabled = !controlsLocked && isCustom
-
- let configuredCommand = isCustom
- ? acpCommand.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- : profile.command
- let configuredArguments: [String]
- if isCustom {
- guard let arguments = try? ACPDesktopConfiguration.decodeArguments(acpArgsJSON.stringValue) else {
- acpStatus.stringValue = L10n.text("Args JSON must be a JSON string array.")
- acpStatus.textColor = effectiveEnabled ? .systemRed : .secondaryLabelColor
- return
- }
- configuredArguments = arguments
- } else {
- configuredArguments = profile.args
- }
- let resolution = preset.resolveAdapter(
- configuredCommand: configuredCommand,
- configuredArguments: configuredArguments
- )
- if resolution.available {
- acpStatus.stringValue = effectiveEnabled
- ? resolution.message
- : L10n.format("Configured %@ · takes effect when enabled", profile.id)
- acpStatus.textColor = .secondaryLabelColor
- } else {
- acpStatus.stringValue = resolution.message
- acpStatus.textColor = effectiveEnabled ? .systemRed : .secondaryLabelColor
- }
- }
-
private func refreshBrowserStatus() {
let enabled = browserEnabled.state == .on
let mode = selectedBrowserConnectionMode()
@@ -1007,7 +1053,6 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
let acpSettingsChanged = acpIsEnabled != initialACPEnabled
|| acpProfiles != initialACPProfiles
|| acpDefaultProfile != initialACPDefaultProfile
- || acpControlDiffersFromModel()
let browserMode = selectedBrowserConnectionMode()
let browserCDP = browserMode == .specifiedCDP
? browserCDPURL.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1030,11 +1075,11 @@ final class AdvancedSettingsWindowController: NSWindowController, NSTextFieldDel
private func setBusy(_ busy: Bool) {
isBusy = busy
let locked = controlsLocked
- for control in [languagePreference, serviceAutostart, menuAutostart, portField, logLevel, mcpAppsEnabled, browserEnabled, browserConnectionMode, browserCDPURL, acpEnabled, acpProfile, acpProfileID, acpProfileEnabled, acpProfileDefault, acpAddProfile, acpRemoveProfile, acpAgent, acpCommand, acpArgsJSON, nexusEndpoint, nexusPairingCode, nexusPairButton] {
+ for control in [languagePreference, serviceAutostart, menuAutostart, portField, logLevel, mcpAppsEnabled, browserEnabled, browserConnectionMode, browserCDPURL, acpEnabled, acpDefaultProfileMenu, acpAddCustomProfile, nexusEndpoint, nexusPairingCode, nexusPairButton] {
control.isEnabled = !locked
}
+ refreshACPProfileOverview()
refreshBrowserStatus()
- refreshACPStatus()
cancelButton.isEnabled = !busy
if busy {
applyButton.isEnabled = false
diff --git a/desktop/macos/AgentDockApp/Sources/ServiceConfigurationController.swift b/desktop/macos/AgentDockApp/Sources/ServiceConfigurationController.swift
index 31e62aed..48a0c2be 100644
--- a/desktop/macos/AgentDockApp/Sources/ServiceConfigurationController.swift
+++ b/desktop/macos/AgentDockApp/Sources/ServiceConfigurationController.swift
@@ -63,6 +63,12 @@ struct EditableServiceSettings {
guard seen.insert(profile.id).inserted else {
throw ValidationError(L10n.format("Duplicate Coding Agent profile ID: %@", profile.id))
}
+ if profile.kind == .custom {
+ let displayName = profile.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ profile.displayName = displayName.isEmpty ? profile.id : displayName
+ } else {
+ profile.displayName = nil
+ }
switch profile.kind {
case .codex, .claude, .grok:
guard profile.id == profile.kind.rawValue else {
diff --git a/desktop/windows/control-panel/MainWindow.xaml b/desktop/windows/control-panel/MainWindow.xaml
index 297b26e9..65af50fd 100644
--- a/desktop/windows/control-panel/MainWindow.xaml
+++ b/desktop/windows/control-panel/MainWindow.xaml
@@ -167,22 +167,22 @@
-
-
-
-
+
+
+
+
+
-
-
+
+
-
@@ -233,59 +233,27 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/windows/control-panel/MainWindow.xaml.cs b/desktop/windows/control-panel/MainWindow.xaml.cs
index 8d591811..9a098f21 100644
--- a/desktop/windows/control-panel/MainWindow.xaml.cs
+++ b/desktop/windows/control-panel/MainWindow.xaml.cs
@@ -6,9 +6,15 @@
using System.Windows.Input;
using System.Windows.Media;
using Application = System.Windows.Application;
+using Brushes = System.Windows.Media.Brushes;
+using Button = System.Windows.Controls.Button;
+using CheckBox = System.Windows.Controls.CheckBox;
using Clipboard = System.Windows.Clipboard;
using Color = System.Windows.Media.Color;
+using HorizontalAlignment = System.Windows.HorizontalAlignment;
using MessageBox = System.Windows.MessageBox;
+using Orientation = System.Windows.Controls.Orientation;
+using TextBox = System.Windows.Controls.TextBox;
using Forms = System.Windows.Forms;
namespace AgentDock.ControlPanel;
@@ -31,7 +37,6 @@ public partial class MainWindow : Window
private bool _settingsLoaded;
private List _acpProfiles = [];
private string _acpDefaultProfile = "";
- private string _activeAcpProfileId = "";
private string _lastAutoTestOrigin = "";
private DateTimeOffset _lastAutoTestAt = DateTimeOffset.MinValue;
@@ -144,18 +149,12 @@ private void ApplySnapshot(RuntimeSnapshot snapshot)
AcpEnabledCheckBox.IsChecked = snapshot.Settings.AcpEnabled;
_acpProfiles = snapshot.Settings.AcpProfiles.Select(CloneAcpProfile).ToList();
_acpDefaultProfile = snapshot.Settings.AcpDefaultProfile;
- _activeAcpProfileId = _acpProfiles.Any(profile => profile.Id == _acpDefaultProfile)
- ? _acpDefaultProfile
- : _acpProfiles.FirstOrDefault()?.Id ?? "";
- RefreshAcpProfileSelector(_activeAcpProfileId);
- LoadAcpProfileControls(_activeAcpProfileId);
- AcpAddProfileComboBox.SelectedIndex = 0;
+ RefreshAcpProfileOverview();
_settingsLoaded = true;
}
UpdateTunnelModeUi();
RefreshBrowserConnectionUi();
- RefreshAcpUi();
}
finally
{
@@ -319,145 +318,255 @@ await ExecuteActionAsync(
TunnelActionStatusText);
}
- private void AcpSetting_Changed(object sender, RoutedEventArgs e)
+ private void AcpOverviewToggle_Changed(object sender, RoutedEventArgs e)
{
- if (!IsInitialized || _updatingUi)
+ if (_updatingUi || sender is not CheckBox toggle || toggle.Tag is not string key)
{
return;
}
- RefreshAcpUi();
- }
- private void AcpProfile_Changed(object sender, SelectionChangedEventArgs e)
- {
- if (!IsInitialized || _updatingUi)
+ if (key.StartsWith("builtin:", StringComparison.Ordinal))
{
- return;
+ var kind = key["builtin:".Length..];
+ var index = _acpProfiles.FindIndex(profile => profile.Kind == kind);
+ if (index >= 0)
+ {
+ UpdateAcpProfileEnabled(index, toggle.IsChecked == true);
+ }
+ else if (toggle.IsChecked == true)
+ {
+ var resolution = _runtime.ResolveAcpAdapter(kind);
+ _acpProfiles.Add(new AcpProfileSettings
+ {
+ Id = kind,
+ Kind = kind,
+ Command = resolution.Command,
+ Args = resolution.Arguments.ToList(),
+ Enabled = true
+ });
+ if (_acpDefaultProfile.Length == 0)
+ {
+ _acpDefaultProfile = kind;
+ }
+ }
}
- var selectedId = (AcpProfileComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
- if (selectedId.Length == 0 || selectedId == _activeAcpProfileId)
+ else if (key.StartsWith("profile:", StringComparison.Ordinal))
{
- return;
+ var profileId = key["profile:".Length..];
+ var index = _acpProfiles.FindIndex(profile => profile.Id == profileId);
+ if (index >= 0)
+ {
+ UpdateAcpProfileEnabled(index, toggle.IsChecked == true);
+ }
}
- if (!SaveActiveAcpProfile(showErrors: true))
+ RefreshAcpProfileOverview();
+ }
+
+ private void UpdateAcpProfileEnabled(int index, bool enabled)
+ {
+ var profileId = _acpProfiles[index].Id;
+ _acpProfiles[index].Enabled = enabled;
+ if (!enabled && _acpDefaultProfile == profileId)
{
- RefreshAcpProfileSelector(_activeAcpProfileId);
- return;
+ var replacement = _acpProfiles.FirstOrDefault(profile => profile.Id != profileId && profile.Enabled);
+ _acpDefaultProfile = replacement?.Id ?? "";
+ }
+ if (enabled && _acpDefaultProfile.Length == 0)
+ {
+ _acpDefaultProfile = profileId;
}
- RefreshAcpProfileSelector(selectedId);
- _activeAcpProfileId = selectedId;
- LoadAcpProfileControls(selectedId);
- RefreshAcpUi();
}
- private void AcpProfileFlag_Changed(object sender, RoutedEventArgs e)
+ private void AcpDefaultProfile_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
- if (!IsInitialized || _updatingUi || _activeAcpProfileId.Length == 0)
+ if (!IsInitialized || _updatingUi || AcpDefaultProfileComboBox.SelectedItem is not ComboBoxItem item || item.Tag is not string profileId)
{
return;
}
- if (AcpProfileDefaultCheckBox.IsChecked != true && _acpDefaultProfile == _activeAcpProfileId)
+ if (_acpProfiles.Any(profile => profile.Id == profileId && profile.Enabled))
{
- _updatingUi = true;
- AcpProfileDefaultCheckBox.IsChecked = true;
- _updatingUi = false;
+ _acpDefaultProfile = profileId;
}
- else if (AcpProfileDefaultCheckBox.IsChecked == true)
+ }
+
+ private void AcpAddCustomProfile_Click(object sender, RoutedEventArgs e)
+ {
+ if (_updatingUi)
{
- _acpDefaultProfile = _activeAcpProfileId;
+ return;
}
- if (AcpProfileDefaultCheckBox.IsChecked == true && AcpProfileEnabledCheckBox.IsChecked != true)
+ var result = ShowCustomAcpProfileDialog(null);
+ if (result is null)
{
- _updatingUi = true;
- AcpProfileEnabledCheckBox.IsChecked = true;
- _updatingUi = false;
+ return;
}
- _ = SaveActiveAcpProfile(showErrors: false);
- RefreshAcpProfileSelector(_activeAcpProfileId);
- RefreshAcpUi();
+ var id = UniqueCustomAcpProfileId(result.Name);
+ _acpProfiles.Add(new AcpProfileSettings
+ {
+ Id = id,
+ DisplayName = result.Name,
+ Kind = "custom",
+ Command = result.Command,
+ Args = result.Arguments,
+ Enabled = false
+ });
+ RefreshAcpProfileOverview();
}
- private void AcpAddProfile_Changed(object sender, SelectionChangedEventArgs e)
+ private void AcpCustomProfileEdit_Click(object sender, MouseButtonEventArgs e)
{
- if (!IsInitialized || _updatingUi)
+ if (_updatingUi || sender is not TextBlock label || label.Tag is not string profileId)
{
return;
}
- var kind = (AcpAddProfileComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
- if (kind.Length == 0)
+ var index = _acpProfiles.FindIndex(profile => profile.Id == profileId);
+ if (index < 0 || _acpProfiles[index].Kind != "custom")
{
return;
}
- if (!SaveActiveAcpProfile(showErrors: true))
+ var result = ShowCustomAcpProfileDialog(_acpProfiles[index]);
+ if (result is null)
{
- ResetAcpAddProfileSelector();
return;
}
-
- string id;
- if (kind == "custom")
+ if (result.DeleteRequested)
{
- id = "custom";
- var suffix = 2;
- while (_acpProfiles.Any(profile => string.Equals(profile.Id, id, StringComparison.Ordinal)))
+ var removedId = _acpProfiles[index].Id;
+ _acpProfiles.RemoveAt(index);
+ if (_acpDefaultProfile == removedId)
{
- id = $"custom-{suffix++}";
+ _acpDefaultProfile = _acpProfiles.FirstOrDefault(profile => profile.Enabled)?.Id ?? "";
}
}
else
{
- id = kind;
- if (_acpProfiles.Any(profile => string.Equals(profile.Id, id, StringComparison.Ordinal)))
- {
- _activeAcpProfileId = id;
- RefreshAcpProfileSelector(id);
- LoadAcpProfileControls(id);
- ResetAcpAddProfileSelector();
- return;
- }
+ // 自定义 Agent 的内部 ID 是已有 Session 身份的一部分;编辑只更新用户可见配置,绝不重生成 ID。
+ var profile = _acpProfiles[index];
+ profile.DisplayName = result.Name;
+ profile.Command = result.Command;
+ profile.Args = result.Arguments;
+ _acpProfiles[index] = profile;
}
+ RefreshAcpProfileOverview();
+ }
- var resolution = kind == "custom" ? null : _runtime.ResolveAcpAdapter(kind);
- _acpProfiles.Add(new AcpProfileSettings
+ private CustomAcpProfileDialogResult? ShowCustomAcpProfileDialog(AcpProfileSettings? existing)
+ {
+ var editing = existing is not null;
+ var dialog = new Window
+ {
+ Title = UiText.Get(editing ? "EditCustomAcpTitle" : "AddCustomAcpTitle"),
+ Owner = this,
+ Width = 520,
+ SizeToContent = SizeToContent.Height,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ ResizeMode = ResizeMode.NoResize,
+ ShowInTaskbar = false
+ };
+ var nameInput = new TextBox { Text = existing is null ? "" : AcpDisplayName(existing), Margin = new Thickness(0, 5, 0, 10) };
+ var commandInput = new TextBox { Text = existing?.Command ?? "", Margin = new Thickness(0, 5, 0, 10) };
+ var argsInput = new TextBox
{
- Id = id,
- Kind = kind,
- Command = resolution?.Command ?? "",
- Args = resolution?.Arguments?.ToList() ?? [],
- Enabled = true
- });
- if (_acpDefaultProfile.Length == 0)
+ Text = JsonSerializer.Serialize(existing?.Args ?? []),
+ Margin = new Thickness(0, 5, 0, 14)
+ };
+ var save = new Button { Content = UiText.Get(editing ? "Save" : "Add"), IsDefault = true, MinWidth = 88, Height = 32, Margin = new Thickness(8, 0, 0, 0) };
+ var cancel = new Button { Content = UiText.Get("Cancel"), IsCancel = true, MinWidth = 88, Height = 32, Margin = new Thickness(8, 0, 0, 0) };
+ var delete = new Button { Content = UiText.Get("Delete"), MinWidth = 88, Height = 32 };
+ var deleteRequested = false;
+ save.Click += (_, _) =>
{
- _acpDefaultProfile = id;
- }
- _activeAcpProfileId = id;
- RefreshAcpProfileSelector(id);
- LoadAcpProfileControls(id);
- ResetAcpAddProfileSelector();
- RefreshAcpUi();
+ if (nameInput.Text.Trim().Length == 0)
+ {
+ nameInput.Focus();
+ return;
+ }
+ if (!TryReadAcpArguments(argsInput.Text, out _))
+ {
+ MessageBox.Show(dialog, UiText.Get("ArgsJsonInvalid"), "AgentDock", MessageBoxButton.OK, MessageBoxImage.Warning);
+ argsInput.Focus();
+ return;
+ }
+ dialog.DialogResult = true;
+ };
+ delete.Click += (_, _) =>
+ {
+ deleteRequested = true;
+ dialog.DialogResult = true;
+ };
+ var buttons = new Grid { Margin = new Thickness(0, 4, 0, 0) };
+ buttons.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ buttons.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ buttons.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ var rightButtons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
+ if (editing)
+ {
+ Grid.SetColumn(delete, 0);
+ buttons.Children.Add(delete);
+ }
+ rightButtons.Children.Add(save);
+ rightButtons.Children.Add(cancel);
+ Grid.SetColumn(rightButtons, 2);
+ buttons.Children.Add(rightButtons);
+ var content = new StackPanel { Margin = new Thickness(18) };
+ content.Children.Add(new TextBlock { Text = UiText.Get("Name"), FontWeight = FontWeights.SemiBold });
+ content.Children.Add(nameInput);
+ content.Children.Add(new TextBlock { Text = UiText.Get("Command"), FontWeight = FontWeights.SemiBold });
+ content.Children.Add(commandInput);
+ content.Children.Add(new TextBlock { Text = UiText.Get("ArgsJson"), FontWeight = FontWeights.SemiBold });
+ content.Children.Add(argsInput);
+ content.Children.Add(buttons);
+ dialog.Content = content;
+ dialog.Loaded += (_, _) => nameInput.Focus();
+ if (dialog.ShowDialog() != true)
+ {
+ return null;
+ }
+ if (deleteRequested)
+ {
+ return new CustomAcpProfileDialogResult("", "", [], true);
+ }
+ _ = TryReadAcpArguments(argsInput.Text, out var arguments);
+ return new CustomAcpProfileDialogResult(
+ nameInput.Text.Trim(),
+ commandInput.Text.Trim(),
+ arguments,
+ false);
}
- private void AcpRemoveProfile_Click(object sender, RoutedEventArgs e)
+ private string UniqueCustomAcpProfileId(string name)
{
- if (_updatingUi || _acpProfiles.Count <= 1)
+ var chars = name.ToLowerInvariant().Select(character =>
+ character is >= 'a' and <= 'z' or >= '0' and <= '9' ? character : '-').ToArray();
+ var baseId = new string(chars);
+ while (baseId.Contains("--", StringComparison.Ordinal))
{
- return;
+ baseId = baseId.Replace("--", "-", StringComparison.Ordinal);
}
- var index = _acpProfiles.FindIndex(profile => profile.Id == _activeAcpProfileId);
- if (index < 0)
+ baseId = baseId.Trim('-');
+ if (baseId.Length == 0)
{
- return;
+ baseId = "custom";
+ }
+ if (baseId is "codex" or "claude" or "grok")
+ {
+ baseId += "-custom";
+ }
+ if (baseId.Length > 48)
+ {
+ baseId = baseId[..48].TrimEnd('-');
+ }
+ var existing = _acpProfiles.Select(profile => profile.Id).ToHashSet(StringComparer.Ordinal);
+ if (!existing.Contains(baseId))
+ {
+ return baseId;
}
- var removedId = _acpProfiles[index].Id;
- _acpProfiles.RemoveAt(index);
- if (_acpDefaultProfile == removedId)
+ var suffix = 2;
+ while (existing.Contains($"{baseId}-{suffix}"))
{
- _acpDefaultProfile = _acpProfiles.FirstOrDefault(profile => profile.Enabled)?.Id ?? _acpProfiles[0].Id;
+ suffix++;
}
- _activeAcpProfileId = _acpProfiles[Math.Min(index, _acpProfiles.Count - 1)].Id;
- RefreshAcpProfileSelector(_activeAcpProfileId);
- LoadAcpProfileControls(_activeAcpProfileId);
- RefreshAcpUi();
+ return $"{baseId}-{suffix}";
}
private void BrowserConnection_Changed(object sender, RoutedEventArgs e)
@@ -510,6 +619,7 @@ private void SelectBrowserConnectionMode(ControlPanelSettings settings)
private static AcpProfileSettings CloneAcpProfile(AcpProfileSettings profile) => new()
{
Id = profile.Id,
+ DisplayName = profile.DisplayName,
Kind = profile.Kind,
Command = profile.Command,
Args = [.. profile.Args],
@@ -517,32 +627,23 @@ private void SelectBrowserConnectionMode(ControlPanelSettings settings)
Enabled = profile.Enabled
};
- private void RefreshAcpProfileSelector(string selectedId)
+ private void RefreshAcpProfileOverview()
{
var previous = _updatingUi;
_updatingUi = true;
try
{
- AcpProfileComboBox.Items.Clear();
- foreach (var profile in _acpProfiles)
+ AcpProfileListPanel.Children.Clear();
+ foreach (var kind in new[] { "codex", "claude", "grok" })
{
- var suffix = profile.Id == _acpDefaultProfile ? " · Default" : "";
- AcpProfileComboBox.Items.Add(new ComboBoxItem
- {
- Content = $"{profile.Id} · {AgentDisplayName(profile.Kind)}{suffix}",
- Tag = profile.Id
- });
+ var profile = _acpProfiles.FirstOrDefault(item => item.Kind == kind);
+ AcpProfileListPanel.Children.Add(BuildAcpOverviewRow(kind, profile));
}
- var selected = AcpProfileComboBox.Items.OfType()
- .FirstOrDefault(item => string.Equals(item.Tag?.ToString(), selectedId, StringComparison.Ordinal));
- if (selected is not null)
+ foreach (var profile in _acpProfiles.Where(profile => profile.Kind == "custom"))
{
- AcpProfileComboBox.SelectedItem = selected;
- }
- else if (AcpProfileComboBox.Items.Count > 0)
- {
- AcpProfileComboBox.SelectedIndex = 0;
+ AcpProfileListPanel.Children.Add(BuildAcpOverviewRow("custom", profile));
}
+ RefreshAcpDefaultProfileOptions();
}
finally
{
@@ -550,79 +651,77 @@ private void RefreshAcpProfileSelector(string selectedId)
}
}
- private void LoadAcpProfileControls(string profileId)
+ private void RefreshAcpDefaultProfileOptions()
{
- var profile = _acpProfiles.FirstOrDefault(item => item.Id == profileId);
- if (profile is null)
+ var enabledProfiles = _acpProfiles.Where(profile => profile.Enabled).ToList();
+ if (!enabledProfiles.Any(profile => profile.Id == _acpDefaultProfile))
{
- return;
+ _acpDefaultProfile = enabledProfiles.FirstOrDefault()?.Id ?? "";
}
- var previous = _updatingUi;
- _updatingUi = true;
- try
- {
- _activeAcpProfileId = profile.Id;
- AcpProfileIdTextBox.Text = profile.Id;
- AcpProfileEnabledCheckBox.IsChecked = profile.Enabled;
- AcpProfileDefaultCheckBox.IsChecked = profile.Id == _acpDefaultProfile;
- SelectAcpAgent(profile.Kind);
- AcpCommandTextBox.Text = profile.Kind == "custom" ? profile.Command : "";
- AcpArgsTextBox.Text = profile.Kind == "custom" ? JsonSerializer.Serialize(profile.Args) : "[]";
- }
- finally
+
+ AcpDefaultProfileComboBox.Items.Clear();
+ foreach (var profile in enabledProfiles)
{
- _updatingUi = previous;
+ var item = new ComboBoxItem { Content = AcpDisplayName(profile), Tag = profile.Id };
+ AcpDefaultProfileComboBox.Items.Add(item);
+ if (profile.Id == _acpDefaultProfile)
+ {
+ AcpDefaultProfileComboBox.SelectedItem = item;
+ }
}
+ AcpDefaultProfileComboBox.IsEnabled = enabledProfiles.Count > 0;
}
- private void ResetAcpAddProfileSelector()
+ private Border BuildAcpOverviewRow(string kind, AcpProfileSettings? profile)
{
- var previous = _updatingUi;
- _updatingUi = true;
- AcpAddProfileComboBox.SelectedIndex = 0;
- _updatingUi = previous;
- }
-
- private bool SaveActiveAcpProfile(bool showErrors)
- {
- var index = _acpProfiles.FindIndex(profile => profile.Id == _activeAcpProfileId);
- if (index < 0)
+ var name = profile is not null ? AcpDisplayName(profile) : AgentDisplayName(kind);
+ var nameView = new TextBlock
{
- return _acpProfiles.Count == 0;
- }
- var profile = _acpProfiles[index];
- var oldId = profile.Id;
- var newId = profile.Kind == "custom" ? AcpProfileIdTextBox.Text.Trim() : profile.Kind;
- if (!IsValidAcpProfileId(newId) || _acpProfiles.Where((_, candidateIndex) => candidateIndex != index).Any(item => item.Id == newId))
+ Text = name,
+ FontWeight = FontWeights.Medium,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ if (profile?.Kind == "custom")
{
- if (showErrors)
- {
- MessageBox.Show(this, "Coding Agent profile IDs must be unique and use only letters, numbers, '.', '_' or '-'.", "AgentDock", MessageBoxButton.OK, MessageBoxImage.Warning);
- }
- return false;
+ nameView.Tag = profile.Id;
+ nameView.Cursor = System.Windows.Input.Cursors.Hand;
+ nameView.MouseLeftButtonUp += AcpCustomProfileEdit_Click;
}
- if (profile.Kind == "custom")
+ var toggle = new CheckBox
{
- if (!TryReadAcpArguments(AcpArgsTextBox.Text, out var arguments))
- {
- if (showErrors)
- {
- MessageBox.Show(this, UiText.Get("ArgsJsonInvalid"), "AgentDock", MessageBoxButton.OK, MessageBoxImage.Warning);
- }
- return false;
- }
- profile.Command = AcpCommandTextBox.Text.Trim();
- profile.Args = arguments;
- }
- profile.Id = newId;
- profile.Enabled = AcpProfileEnabledCheckBox.IsChecked == true;
- _acpProfiles[index] = profile;
- if (_acpDefaultProfile == oldId)
+ IsChecked = profile?.Enabled == true,
+ Tag = profile is null ? $"builtin:{kind}" : $"profile:{profile.Id}",
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ toggle.Checked += AcpOverviewToggle_Changed;
+ toggle.Unchecked += AcpOverviewToggle_Changed;
+
+ var row = new Grid();
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ Grid.SetColumn(nameView, 0);
+ Grid.SetColumn(toggle, 1);
+ row.Children.Add(nameView);
+ row.Children.Add(toggle);
+
+ return new Border
+ {
+ Child = row,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ BorderBrush = new SolidColorBrush(Color.FromRgb(234, 236, 240)),
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ Padding = new Thickness(8, 8, 8, 8)
+ };
+ }
+
+ private static string AcpDisplayName(AcpProfileSettings profile)
+ {
+ if (profile.Kind != "custom")
{
- _acpDefaultProfile = newId;
+ return AgentDisplayName(profile.Kind);
}
- _activeAcpProfileId = newId;
- return true;
+ var name = (profile.DisplayName ?? "").Trim();
+ return name.Length == 0 ? profile.Id : name;
}
private static bool IsValidAcpProfileId(string value) =>
@@ -632,63 +731,6 @@ private static bool IsValidAcpProfileId(string value) =>
or >= '0' and <= '9'
or '.' or '_' or '-');
- private void RefreshAcpUi()
- {
- var profile = _acpProfiles.FirstOrDefault(item => item.Id == _activeAcpProfileId);
- if (profile is null)
- {
- AcpStatusText.Text = "Add a Coding Agent profile to continue.";
- return;
- }
-
- var globallyEnabled = AcpEnabledCheckBox.IsChecked == true;
- var profileEnabled = AcpProfileEnabledCheckBox.IsChecked == true;
- var effectiveEnabled = globallyEnabled && profileEnabled;
- var isCustom = profile.Kind == "custom";
- var customVisibility = isCustom ? Visibility.Visible : Visibility.Collapsed;
- AcpCommandLabel.Visibility = customVisibility;
- AcpCommandTextBox.Visibility = customVisibility;
- AcpArgsLabel.Visibility = customVisibility;
- AcpArgsTextBox.Visibility = customVisibility;
- AcpAgentComboBox.IsEnabled = false;
- AcpProfileComboBox.IsEnabled = true;
- AcpProfileIdTextBox.IsEnabled = isCustom;
- AcpProfileEnabledCheckBox.IsEnabled = true;
- AcpProfileDefaultCheckBox.IsEnabled = true;
- AcpRemoveProfileButton.IsEnabled = _acpProfiles.Count > 1;
- AcpCommandTextBox.IsEnabled = isCustom;
- AcpArgsTextBox.IsEnabled = isCustom;
-
- IReadOnlyList? configuredArguments;
- string configuredCommand;
- if (isCustom)
- {
- configuredCommand = AcpCommandTextBox.Text.Trim();
- if (!TryReadAcpArguments(AcpArgsTextBox.Text, out var customArguments))
- {
- AcpStatusText.Text = UiText.Get("ArgsJsonInvalid");
- AcpStatusText.Foreground = effectiveEnabled
- ? new SolidColorBrush(Color.FromRgb(217, 45, 32))
- : new SolidColorBrush(Color.FromRgb(102, 112, 133));
- return;
- }
- configuredArguments = customArguments;
- }
- else
- {
- configuredCommand = profile.Command;
- configuredArguments = profile.Args;
- }
-
- var resolution = _runtime.ResolveAcpAdapter(profile.Kind, configuredCommand, configuredArguments);
- AcpStatusText.Text = effectiveEnabled
- ? resolution.Message
- : resolution.Available ? $"Configured {profile.Id} · takes effect when enabled" : resolution.Message;
- AcpStatusText.Foreground = effectiveEnabled && !resolution.Available
- ? new SolidColorBrush(Color.FromRgb(217, 45, 32))
- : new SolidColorBrush(Color.FromRgb(102, 112, 133));
- }
-
private async void LanguageComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_updatingUi || Application.Current is not App app)
@@ -755,10 +797,6 @@ private async void SaveSettingsButton_Click(object sender, RoutedEventArgs e)
}
var acpEnabled = AcpEnabledCheckBox.IsChecked == true;
- if (!SaveActiveAcpProfile(showErrors: true))
- {
- return;
- }
if (acpEnabled && !_acpProfiles.Any(profile => profile.Enabled))
{
MessageBox.Show(this, "Enable at least one Coding Agent profile.", "AgentDock", MessageBoxButton.OK, MessageBoxImage.Warning);
@@ -800,7 +838,7 @@ private async void SaveSettingsButton_Click(object sender, RoutedEventArgs e)
profile.Args = resolution.Arguments.ToList();
}
var defaultProfile = _acpProfiles.FirstOrDefault(profile => profile.Id == _acpDefaultProfile);
- if (defaultProfile is null || acpEnabled && !defaultProfile.Enabled)
+ if (acpEnabled && (defaultProfile is null || !defaultProfile.Enabled))
{
MessageBox.Show(this, "The default Coding Agent profile must reference an enabled profile.", "AgentDock", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
@@ -835,16 +873,10 @@ private async void SaveSettingsButton_Click(object sender, RoutedEventArgs e)
{
_acpProfiles = settings.AcpProfiles.Select(CloneAcpProfile).ToList();
_acpDefaultProfile = settings.AcpDefaultProfile;
- if (!_acpProfiles.Any(profile => profile.Id == _activeAcpProfileId))
- {
- _activeAcpProfileId = _acpDefaultProfile;
- }
- RefreshAcpProfileSelector(_activeAcpProfileId);
- LoadAcpProfileControls(_activeAcpProfileId);
+ RefreshAcpProfileOverview();
BrowserCdpUrlTextBox.Text = settings.BrowserCdpUrl;
SelectBrowserConnectionMode(settings);
RefreshBrowserConnectionUi();
- RefreshAcpUi();
}
}
@@ -918,19 +950,6 @@ await ExecuteActionAsync(
SettingsStatusText);
}
- private void SelectAcpAgent(string value)
- {
- foreach (var item in AcpAgentComboBox.Items.OfType())
- {
- if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))
- {
- AcpAgentComboBox.SelectedItem = item;
- return;
- }
- }
- AcpAgentComboBox.SelectedIndex = 0;
- }
-
private static string AgentDisplayName(string agent) => agent switch
{
"codex" => "Codex",
@@ -964,6 +983,12 @@ private static bool TryReadAcpArguments(string raw, out List arguments)
}
}
+ private sealed record CustomAcpProfileDialogResult(
+ string Name,
+ string Command,
+ List Arguments,
+ bool DeleteRequested);
+
private void SelectLogLevel(string value)
{
foreach (var item in LogLevelComboBox.Items.OfType())
diff --git a/desktop/windows/control-panel/Models/RuntimeModels.cs b/desktop/windows/control-panel/Models/RuntimeModels.cs
index a3ed8595..c8f2c043 100644
--- a/desktop/windows/control-panel/Models/RuntimeModels.cs
+++ b/desktop/windows/control-panel/Models/RuntimeModels.cs
@@ -58,6 +58,9 @@ public sealed class AcpProfileSettings
[JsonPropertyName("id")]
public string Id { get; set; } = "";
+ [JsonPropertyName("display_name")]
+ public string DisplayName { get; set; } = "";
+
[JsonPropertyName("kind")]
public string Kind { get; set; } = "custom";
diff --git a/desktop/windows/control-panel/Resources/UiStrings.resx b/desktop/windows/control-panel/Resources/UiStrings.resx
index da776807..75f4ed17 100644
--- a/desktop/windows/control-panel/Resources/UiStrings.resx
+++ b/desktop/windows/control-panel/Resources/UiStrings.resx
@@ -183,6 +183,39 @@
Enable Coding Agent
+
+ Default ACP
+
+
+ + Add custom ACP
+
+
+ Add custom ACP
+
+
+ Edit custom ACP
+
+
+ Name
+
+
+ Command
+
+
+ Args JSON
+
+
+ Add
+
+
+ Save
+
+
+ Cancel
+
+
+ Delete
+
Custom
diff --git a/desktop/windows/control-panel/Resources/UiStrings.zh-CN.resx b/desktop/windows/control-panel/Resources/UiStrings.zh-CN.resx
index f422c21f..7422b8dd 100644
--- a/desktop/windows/control-panel/Resources/UiStrings.zh-CN.resx
+++ b/desktop/windows/control-panel/Resources/UiStrings.zh-CN.resx
@@ -183,6 +183,39 @@
启用 Coding Agent
+
+ 默认 ACP
+
+
+ + 添加自定义ACP
+
+
+ 添加自定义ACP
+
+
+ 编辑自定义ACP
+
+
+ 名称
+
+
+ 命令
+
+
+ 参数 JSON
+
+
+ 添加
+
+
+ 保存
+
+
+ 取消
+
+
+ 删除
+
自定义
diff --git a/desktop/windows/control-panel/Services/RuntimeService.cs b/desktop/windows/control-panel/Services/RuntimeService.cs
index 0f8b917c..1e3f9b31 100644
--- a/desktop/windows/control-panel/Services/RuntimeService.cs
+++ b/desktop/windows/control-panel/Services/RuntimeService.cs
@@ -84,7 +84,12 @@ public async Task GetSnapshotAsync(
foreach (var profile in settings.AcpProfiles)
{
profile.Id = (profile.Id ?? "").Trim();
+ profile.DisplayName = (profile.DisplayName ?? "").Trim();
profile.Kind = NormalizeAcpAgent(profile.Kind);
+ if (profile.Kind == "custom" && profile.DisplayName.Length == 0)
+ {
+ profile.DisplayName = profile.Id;
+ }
profile.Command = (profile.Command ?? "").Trim();
profile.Args ??= [];
}
diff --git a/internal/config/acp_test.go b/internal/config/acp_test.go
index 83088293..9b524c3d 100644
--- a/internal/config/acp_test.go
+++ b/internal/config/acp_test.go
@@ -134,7 +134,7 @@ func TestFromEnvParsesMultipleACPProfiles(t *testing.T) {
}
profiles, err := json.Marshal([]ACPProfile{
{ID: "codex", Kind: "codex", Command: executable, Enabled: true},
- {ID: "zcode", Kind: "custom", Command: executable, Args: []string{"zcode.js"}, Enabled: true},
+ {ID: "zcode", DisplayName: "ZCode", Kind: "custom", Command: executable, Args: []string{"zcode.js"}, Enabled: true},
{ID: "agy", Kind: "custom", Command: executable, Enabled: false},
})
if err != nil {
@@ -156,6 +156,9 @@ func TestFromEnvParsesMultipleACPProfiles(t *testing.T) {
if cfg.ACPDefaultProfile != "zcode" {
t.Fatalf("default ACP profile = %q", cfg.ACPDefaultProfile)
}
+ if cfg.ACPProfiles[1].DisplayName != "ZCode" {
+ t.Fatalf("custom ACP display name = %q", cfg.ACPProfiles[1].DisplayName)
+ }
active := cfg.EffectiveACPProfiles()
if len(active) != 2 || active[0].ID != "codex" || active[1].ID != "zcode" {
t.Fatalf("active ACP profiles = %#v", active)
diff --git a/internal/config/config.go b/internal/config/config.go
index 46e95efb..197bda44 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -62,12 +62,13 @@ type Config struct {
// ACPProfile 表示一个可独立运行、独立持久化会话的 ACP 实例。
// 内置类型使用固定 ID(codex/claude/grok)保持单实例;custom 使用自定义 ID 支持多个实例。
type ACPProfile struct {
- ID string `json:"id"`
- Kind string `json:"kind"`
- Command string `json:"command"`
- Args []string `json:"args,omitempty"`
- EnvFromEnv map[string]string `json:"env_from_env,omitempty"`
- Enabled bool `json:"enabled"`
+ ID string `json:"id"`
+ DisplayName string `json:"display_name,omitempty"`
+ Kind string `json:"kind"`
+ Command string `json:"command"`
+ Args []string `json:"args,omitempty"`
+ EnvFromEnv map[string]string `json:"env_from_env,omitempty"`
+ Enabled bool `json:"enabled"`
}
func FromEnv() (Config, error) {
diff --git a/internal/desktopruntime/service_environment_windows_test.go b/internal/desktopruntime/service_environment_windows_test.go
index 83073317..08ef9823 100644
--- a/internal/desktopruntime/service_environment_windows_test.go
+++ b/internal/desktopruntime/service_environment_windows_test.go
@@ -92,7 +92,7 @@ func TestLoadControlPanelSettingsPrefersProfilesOverLegacyACPFields(t *testing.T
command := filepath.Join(root, "zcode.exe")
content, err := json.Marshal(map[string]any{
"port": 8765, "log_level": "info", "acp_enabled": true,
- "acp_profiles": []map[string]any{{"id": "zcode", "kind": "custom", "command": command, "enabled": true}},
+ "acp_profiles": []map[string]any{{"id": "zcode", "display_name": "ZCode", "kind": "custom", "command": command, "enabled": true}},
"acp_default_profile": "zcode", "acp_agent": "custom", "acp_command": `C:\legacy.exe`,
})
if err != nil {
@@ -109,6 +109,9 @@ func TestLoadControlPanelSettingsPrefersProfilesOverLegacyACPFields(t *testing.T
if settings.ACPDefaultProfile != "zcode" || len(settings.ACPProfiles) != 1 || settings.ACPProfiles[0].ID != "zcode" {
t.Fatalf("profile config did not win over legacy fields: %#v", settings)
}
+ if settings.ACPProfiles[0].DisplayName != "ZCode" {
+ t.Fatalf("profile display name = %q", settings.ACPProfiles[0].DisplayName)
+ }
}
func TestPlatformPrepareCoreEnvironmentRecoversCorruptGeneratedCredentialsConsistently(t *testing.T) {
diff --git a/scripts/test/macos_advanced_settings_window_test.go b/scripts/test/macos_advanced_settings_window_test.go
index 91becdb1..d2aa9706 100644
--- a/scripts/test/macos_advanced_settings_window_test.go
+++ b/scripts/test/macos_advanced_settings_window_test.go
@@ -16,21 +16,61 @@ func TestMacOSAdvancedSettingsUsesResponsiveScrollableLayout(t *testing.T) {
content := strings.ReplaceAll(string(data), "\r\n", "\n")
for _, want := range []string{
- `contentRect: NSRect(x: 0, y: 0, width: 680, height: 760)`,
+ `contentRect: NSRect(x: 0, y: 0, width: 700, height: 760)`,
`styleMask: [.titled, .closable, .resizable]`,
- `window.minSize = NSSize(width: 620, height: 520)`,
+ `window.minSize = NSSize(width: 700, height: 560)`,
+ `private let acpOverviewContainer = NSStackView()`,
+ `private let acpDefaultProfileMenu = NSPopUpButton`,
+ `formRow(title: L10n.text("Default ACP"), control: acpDefaultProfileMenu)`,
+ `#selector(defaultACPProfileChanged)`,
+ `#selector(editCustomACPProfile(_:))`,
+ `let editGesture = NSClickGestureRecognizer(target: self, action: #selector(editCustomACPProfile(_:)))`,
+ `private func addACPOverviewRow(_ row: NSView)`,
+ `acpProfileList.addArrangedSubview(row)`,
+ `row.widthAnchor.constraint(equalTo: acpProfileList.widthAnchor).isActive = true`,
+ `acpProfileList.spacing = 0`,
+ `let compactPopUpWidth: CGFloat = 110`,
+ `let widePopUpWidth: CGFloat = 220`,
+ `let acpChildIndent: CGFloat = 18`,
+ `let acpListWidth: CGFloat = 250`,
+ `languagePreference.widthAnchor.constraint(equalToConstant: compactPopUpWidth).isActive = true`,
+ `logLevel.widthAnchor.constraint(equalToConstant: compactPopUpWidth).isActive = true`,
+ `acpDefaultProfileMenu.widthAnchor.constraint(equalToConstant: compactPopUpWidth).isActive = true`,
+ `browserConnectionMode.widthAnchor.constraint(equalToConstant: widePopUpWidth).isActive = true`,
+ `acpOverviewContainer.edgeInsets = NSEdgeInsets(top: 0, left: acpChildIndent, bottom: 0, right: 0)`,
+ `acpProfileList.widthAnchor.constraint(equalToConstant: acpListWidth).isActive = true`,
+ `row.heightAnchor.constraint(equalToConstant: 28)`,
+ `let panel = NSPanel(`,
+ `confirmButton.target = self`,
+ `confirmButton.action = #selector(confirmCustomACPDialog(_:))`,
+ `dialogCancelButton.action = #selector(cancelCustomACPDialog(_:))`,
+ `deleteButton.action = #selector(deleteCustomACPDialog(_:))`,
+ `func windowShouldClose(_ sender: NSWindow) -> Bool`,
+ `panel.delegate = self`,
+ `func windowShouldClose(_ sender: NSWindow) -> Bool`,
+ `panel.delegate = self`,
+ `NSApp.stopModal(withCode: .cancel)`,
+ `let form = NSGridView(views: [`,
+ `form.rowSpacing = 10`,
+ `form.columnSpacing = 12`,
+ `form.column(at: 0).xPlacement = .leading`,
+ `form.column(at: 0).width = 72`,
+ `buttons.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18)`,
+ `field.widthAnchor.constraint(equalToConstant: 360).isActive = true`,
+ `nameView.addGestureRecognizer(editGesture)`,
+ `showCustomACPProfileDialog(existing:`,
+ `profile.id 是已有 Session 的稳定身份`,
`let scrollView = NSScrollView()`,
`scrollView.hasVerticalScroller = true`,
`scrollView.documentView = scrollDocumentView`,
`scrollDocumentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor)`,
`root.bottomAnchor.constraint(equalTo: scrollDocumentView.bottomAnchor, constant: -22)`,
`label.widthAnchor.constraint(equalToConstant: 128)`,
- `browserConnectionMode.widthAnchor.constraint(equalToConstant: 360)`,
`let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame`,
`let nexusPairRow = NSView()`,
`nexusDeviceTokenStatus.leadingAnchor.constraint(equalTo: nexusPairRow.leadingAnchor, constant: 140)`,
- "let startupStack = NSStackView(views: [\n serviceAutostart,\n menuAutostart,\n formRow(title: L10n.text(\"Interface language\"), control: languagePreference),",
- "let serviceForm = NSStackView(views: [\n mcpAppsEnabled,\n formRow(title: L10n.text(\"Service port\"), control: portField),\n formRow(title: L10n.text(\"Log level\"), control: logLevel),",
+ "let startupStack = NSStackView(views: [\n serviceAutostart,\n menuAutostart,",
+ "let serviceForm = NSStackView(views: [\n mcpAppsEnabled,\n formRow(title: L10n.text(\"Service port\"), control: portField),\n formRow(title: L10n.text(\"Log level\"), control: logLevel),\n formRow(title: L10n.text(\"Interface language\"), control: languagePreference),",
} {
if !strings.Contains(content, want) {
t.Fatalf("macOS advanced settings missing responsive layout contract %q", want)
@@ -38,16 +78,78 @@ func TestMacOSAdvancedSettingsUsesResponsiveScrollableLayout(t *testing.T) {
}
for _, forbidden := range []string{
+ `contentRect: NSRect(x: 0, y: 0, width: 900, height: 760)`,
+ `window.minSize = NSSize(width: 780, height: 560)`,
`contentRect: NSRect(x: 0, y: 0, width: 590, height: 850)`,
`label.widthAnchor.constraint(equalToConstant: 92)`,
`browserConnectionMode.widthAnchor.constraint(equalToConstant: 290)`,
+ `browserConnectionMode.widthAnchor.constraint(equalToConstant: 360)`,
+ `let compactPopUpWidth: CGFloat = 220`,
+ `let widePopUpWidth: CGFloat = 320`,
+ `let acpListWidth: CGFloat = 370`,
+ `let acpListWidth: CGFloat = 460`,
`nexusEndpoint.widthAnchor.constraint(equalToConstant: 390)`,
`nexusPairingCode.widthAnchor.constraint(equalToConstant: 390)`,
`box.widthAnchor.constraint(equalToConstant: 534)`,
`formRow(title: "Device Token", control: nexusDeviceTokenStatus`,
+ `let acpWorkspace = NSStackView`,
+ `Profile ID`,
+ `acpProfileID`,
+ `acpDetailContainer`,
+ `showACPDetail`,
+ `showACPOverview`,
+ `acpOverviewSectionLabel`,
+ `L10n.text("Built-in agents")`,
+ `L10n.text("Custom agents")`,
+ `L10n.text("No custom agents")`,
+ `L10n.text("Not configured")`,
+ `labelWithString: "›"`,
+ `★`,
+ `let editButton = NSButton(title: title`,
+ `acpProfileList.spacing = 4`,
+ `acpProfileList.widthAnchor.constraint(equalTo: acpOverviewContainer.widthAnchor).isActive = true`,
+ `logLevel.widthAnchor.constraint(equalToConstant: 120).isActive = true`,
+ `languagePreference.widthAnchor.constraint(equalToConstant: 220).isActive = true`,
+ `acpDefaultProfileMenu.widthAnchor.constraint(equalToConstant: 180).isActive = true`,
+ `acpDefaultProfileMenu.widthAnchor.constraint(equalToConstant: 260).isActive = true`,
+ `defaultProfileRow.widthAnchor.constraint(equalTo: acpOverviewContainer.widthAnchor).isActive = true`,
+ `row.heightAnchor.constraint(equalToConstant: 38)`,
+ `field.widthAnchor.constraint(equalToConstant: 380).isActive = true`,
+ `alert.informativeText = L10n.text("The internal profile ID stays hidden and stable.")`,
+ `alert.accessoryView = form`,
+ `let alert = NSAlert()`,
+ `form.column(at: 0).xPlacement = .trailing`,
+ `content.spacing = 30`,
+ `private final class ModalActionTarget`,
+ `private final class ModalPanelCloseDelegate`,
+ `#selector(ModalActionTarget.perform(_:))`,
+ "let startupStack = NSStackView(views: [\n serviceAutostart,\n menuAutostart,\n formRow(title: L10n.text(\"Interface language\"), control: languagePreference),",
+ `closeButton.target = cancelTarget`,
+ `content.spacing = 18`,
} {
if strings.Contains(content, forbidden) {
t.Fatalf("macOS advanced settings still contains fixed/truncated layout contract %q", forbidden)
}
}
}
+
+func TestMacOSACPOverviewRowDoesNotActivateCrossHierarchyConstraintBeforeInsertion(t *testing.T) {
+ path := filepath.Join("..", "..", "desktop", "macos", "AgentDockApp", "Sources", "AdvancedSettingsWindowController.swift")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read AdvancedSettingsWindowController.swift: %v", err)
+ }
+ content := strings.ReplaceAll(string(data), "\r\n", "\n")
+ start := strings.Index(content, "private func acpOverviewRow(")
+ if start < 0 {
+ t.Fatal("macOS ACP overview row builder not found")
+ }
+ end := strings.Index(content[start:], "private func acpDisplayName(")
+ if end < 0 {
+ t.Fatal("macOS ACP overview row builder terminator not found")
+ }
+ body := content[start : start+end]
+ if strings.Contains(body, "acpProfileList.widthAnchor") {
+ t.Fatal("macOS ACP overview row activates a cross-hierarchy constraint before the row is inserted into the stack")
+ }
+}
diff --git a/scripts/test/windows_ui_test.go b/scripts/test/windows_ui_test.go
index b3b6f058..c512bfd1 100644
--- a/scripts/test/windows_ui_test.go
+++ b/scripts/test/windows_ui_test.go
@@ -164,3 +164,71 @@ func TestWindowsControlPanelShowsLiveNexusStatusInsideRuntimeStatus(t *testing.T
}
}
}
+
+func TestWindowsACPSettingsUseSinglePageRowsDefaultDropdownAndCustomDialog(t *testing.T) {
+ root := filepath.Join("..", "..", "desktop", "windows", "control-panel")
+ xamlData, err := os.ReadFile(filepath.Join(root, "MainWindow.xaml"))
+ if err != nil {
+ t.Fatalf("read MainWindow.xaml: %v", err)
+ }
+ codeData, err := os.ReadFile(filepath.Join(root, "MainWindow.xaml.cs"))
+ if err != nil {
+ t.Fatalf("read MainWindow.xaml.cs: %v", err)
+ }
+ xaml := string(xamlData)
+ code := string(codeData)
+ for _, want := range []string{
+ `x:Name="AcpProfileListPanel"`,
+ `x:Name="AcpDefaultProfileComboBox"`,
+ `SelectionChanged="AcpDefaultProfile_SelectionChanged"`,
+ `Content="{local:Loc AddCustomAcp}"`,
+ } {
+ if !strings.Contains(xaml, want) {
+ t.Fatalf("Windows ACP single-page contract missing %q", want)
+ }
+ }
+ for _, want := range []string{
+ `ShowCustomAcpProfileDialog`,
+ `AcpCustomProfileEdit_Click`,
+ `nameView.MouseLeftButtonUp += AcpCustomProfileEdit_Click`,
+ `HorizontalAlignment = HorizontalAlignment.Stretch`,
+ `AcpDefaultProfile_SelectionChanged`,
+ `UniqueCustomAcpProfileId(result.Name)`,
+ `profile.DisplayName = result.Name`,
+ `profile.Command = result.Command`,
+ `profile.Args = result.Arguments`,
+ `profile.Id`,
+ } {
+ if !strings.Contains(code, want) {
+ t.Fatalf("Windows ACP behavior contract missing %q", want)
+ }
+ }
+ for _, forbidden := range []string{
+ `Text="Profile ID"`,
+ `AcpProfileIdTextBox`,
+ `AcpAgentComboBox`,
+ `AcpDetailPanel`,
+ `AcpOverviewPanel`,
+ `AcpProfileNameTextBox`,
+ `AcpProfileDefaultCheckBox`,
+ `AcpStatusText`,
+ `ShowAcpDetail`,
+ `ShowAcpOverview`,
+ `Not configured`,
+ `★ Default`,
+ `Text = "›"`,
+ `BUILT-IN AGENTS`,
+ `CUSTOM AGENTS`,
+ `var edit = new Button`,
+ } {
+ if strings.Contains(xaml, forbidden) || strings.Contains(code, forbidden) {
+ t.Fatalf("Windows ACP UI still exposes old detail/status/group contract %q", forbidden)
+ }
+ }
+
+ mcpAppsIndex := strings.Index(xaml, `x:Name="McpAppsEnabledCheckBox" Grid.Row="0"`)
+ portIndex := strings.Index(xaml, `x:Name="PortTextBox" Grid.Row="1"`)
+ if mcpAppsIndex < 0 || portIndex < 0 || mcpAppsIndex > portIndex {
+ t.Fatal("Windows basic settings must place MCP Apps UI above the service port")
+ }
+}