Skip to content

Commit e43cd38

Browse files
committed
feat(macos): open Settings from the registry with Cmd-,
Add a single AppKit Settings window opened from the Headless menu. The UI is built from SettingsRegistry and writes through SettingsStore, so it cannot drift from `headless config`. Linux has no Settings GUI.
1 parent bdd72b0 commit e43cd38

13 files changed

Lines changed: 543 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ Two versions travel independently, on purpose:
1818

1919
## [Unreleased]
2020

21-
No changes yet.
21+
### Added
22+
23+
- macOS Command-, opens one Settings window built from the typed registry and
24+
writing through the same backend as `headless config`. Linux has no Settings
25+
GUI.
2226

2327
## [1.1.0] — 2026-08-21
2428

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ startup-presentation foreground` or restore background startup with `headless
100100
config set startup-presentation background`; inspect it with `headless config
101101
get startup-presentation`. Use `config list` to discover settings, `config
102102
describe KEY` for type and policy metadata, and `config reset KEY` to restore a
103-
built-in default. `headless start --foreground` and `headless start
103+
built-in default. On macOS, Command-, opens Settings for the same registry;
104+
`headless config` remains the portable path, and Linux has no Settings GUI.
105+
`headless start --foreground` and `headless start
104106
--background` are one-launch overrides. Settings and overrides apply only when
105107
launching a new host and do not reorder an already-running host.
106108

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import AppKit
2+
import HeadlessProtocol
3+
4+
final class SettingsWindowController: NSWindowController, NSWindowDelegate, NSTextFieldDelegate {
5+
private static var sharedController: SettingsWindowController?
6+
7+
static func orderFrontShared(_ sender: Any?) {
8+
if sharedController == nil {
9+
do {
10+
sharedController = try SettingsWindowController()
11+
} catch {
12+
presentOpenFailure(error)
13+
return
14+
}
15+
}
16+
sharedController?.showWindow(sender)
17+
sharedController?.window?.makeKeyAndOrderFront(sender)
18+
NSApp.activate(ignoringOtherApps: true)
19+
}
20+
21+
private static func presentOpenFailure(_ error: Error) {
22+
let alert = NSAlert()
23+
alert.alertStyle = .critical
24+
alert.messageText = "Couldn’t open Settings"
25+
alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription
26+
alert.addButton(withTitle: "OK")
27+
alert.runModal()
28+
}
29+
30+
private let settings: SettingsController
31+
private let stack = NSStackView()
32+
private var isReloading = false
33+
private var firstEditor: NSView?
34+
35+
private init() throws {
36+
settings = SettingsController(store: try SettingsStore.production())
37+
let window = NSWindow(
38+
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
39+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
40+
backing: .buffered,
41+
defer: false
42+
)
43+
window.title = "Settings"
44+
window.minSize = NSSize(width: 400, height: 240)
45+
window.isReleasedWhenClosed = false
46+
window.tabbingMode = .disallowed
47+
window.setFrameAutosaveName("HeadlessSettings")
48+
window.identifier = NSUserInterfaceItemIdentifier("headless-settings")
49+
window.autorecalculatesKeyViewLoop = true
50+
super.init(window: window)
51+
window.delegate = self
52+
buildContent()
53+
try reload()
54+
let fitted = max(stack.fittingSize.height + 8, 260)
55+
window.setContentSize(NSSize(width: 520, height: min(fitted, 640)))
56+
window.center()
57+
}
58+
59+
required init?(coder: NSCoder) { fatalError("not used") }
60+
61+
func windowDidBecomeKey(_ notification: Notification) {
62+
try? reload()
63+
}
64+
65+
private func buildContent() {
66+
guard let content = window?.contentView else { return }
67+
stack.orientation = .vertical
68+
stack.alignment = .leading
69+
stack.spacing = 18
70+
stack.edgeInsets = NSEdgeInsets(top: 20, left: 22, bottom: 20, right: 22)
71+
stack.translatesAutoresizingMaskIntoConstraints = false
72+
content.addSubview(stack)
73+
NSLayoutConstraint.activate([
74+
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor),
75+
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor),
76+
stack.topAnchor.constraint(equalTo: content.topAnchor),
77+
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor),
78+
])
79+
}
80+
81+
private func reload() throws {
82+
isReloading = true
83+
defer { isReloading = false }
84+
for view in stack.arrangedSubviews {
85+
stack.removeArrangedSubview(view)
86+
view.removeFromSuperview()
87+
}
88+
firstEditor = nil
89+
let snapshots = try settings.snapshots()
90+
if snapshots.isEmpty {
91+
stack.addArrangedSubview(wrappingLabel("No settings are registered.", bold: false))
92+
return
93+
}
94+
for snapshot in snapshots {
95+
stack.addArrangedSubview(makeSection(snapshot))
96+
}
97+
window?.initialFirstResponder = firstEditor
98+
window?.recalculateKeyViewLoop()
99+
}
100+
101+
private func makeSection(_ snapshot: SettingSnapshot) -> NSView {
102+
let section = NSStackView()
103+
section.orientation = .vertical
104+
section.alignment = .leading
105+
section.spacing = 6
106+
section.translatesAutoresizingMaskIntoConstraints = false
107+
section.setHuggingPriority(.defaultLow, for: .horizontal)
108+
109+
let title = wrappingLabel(snapshot.definition.key, bold: true)
110+
title.setAccessibilityLabel(snapshot.definition.key)
111+
section.addArrangedSubview(title)
112+
113+
let summary = wrappingLabel(snapshot.definition.summary, bold: false)
114+
summary.textColor = .secondaryLabelColor
115+
summary.setAccessibilityLabel("Summary")
116+
section.addArrangedSubview(summary)
117+
118+
let editor = makeEditor(snapshot)
119+
if firstEditor == nil { firstEditor = editor }
120+
let reset = NSButton(
121+
title: "Reset to Default",
122+
target: self,
123+
action: #selector(resetClicked(_:))
124+
)
125+
reset.bezelStyle = .rounded
126+
reset.identifier = NSUserInterfaceItemIdentifier("reset.\(snapshot.definition.key)")
127+
reset.setAccessibilityLabel("Reset \(snapshot.definition.key) to default")
128+
reset.isEnabled = snapshot.configured && snapshot.supportedOnCurrentPlatform
129+
130+
let valueCaption = NSTextField(labelWithString: "Value")
131+
valueCaption.setAccessibilityHidden(true)
132+
let valueRow = NSStackView(views: [valueCaption, editor, reset])
133+
valueRow.orientation = .horizontal
134+
valueRow.alignment = .centerY
135+
valueRow.spacing = 8
136+
section.addArrangedSubview(valueRow)
137+
138+
section.addArrangedSubview(metaLabel("Default", snapshot.definition.defaultValue))
139+
section.addArrangedSubview(metaLabel("Platform", snapshot.platformSummary))
140+
section.addArrangedSubview(metaLabel("Takes effect", snapshot.definition.restartBehavior.rawValue))
141+
section.addArrangedSubview(
142+
metaLabel("Agents may modify", snapshot.agentsMayModify ? "yes" : "no")
143+
)
144+
145+
if let window, let content = window.contentView {
146+
section.widthAnchor.constraint(
147+
equalTo: content.widthAnchor, constant: -44
148+
).isActive = true
149+
}
150+
return section
151+
}
152+
153+
private func makeEditor(_ snapshot: SettingSnapshot) -> NSView {
154+
let key = snapshot.definition.key
155+
if let values = snapshot.selectableValues {
156+
let popup = NSPopUpButton(frame: .zero, pullsDown: false)
157+
popup.autoenablesItems = false
158+
popup.addItems(withTitles: values)
159+
popup.selectItem(withTitle: snapshot.value)
160+
popup.target = self
161+
popup.action = #selector(valueChanged(_:))
162+
popup.identifier = NSUserInterfaceItemIdentifier(key)
163+
popup.setAccessibilityLabel(key)
164+
popup.setAccessibilityHelp(snapshot.definition.summary)
165+
popup.isEnabled = snapshot.supportedOnCurrentPlatform
166+
return popup
167+
}
168+
169+
let field = NSTextField(string: snapshot.value)
170+
field.identifier = NSUserInterfaceItemIdentifier(key)
171+
field.delegate = self
172+
field.target = self
173+
field.action = #selector(textCommitted(_:))
174+
field.setAccessibilityLabel(key)
175+
field.setAccessibilityHelp(snapshot.definition.summary)
176+
field.isEditable = snapshot.supportedOnCurrentPlatform
177+
field.isSelectable = true
178+
field.placeholderString = snapshot.definition.defaultValue
179+
field.translatesAutoresizingMaskIntoConstraints = false
180+
field.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true
181+
return field
182+
}
183+
184+
private func wrappingLabel(_ text: String, bold: Bool) -> NSTextField {
185+
let label = NSTextField(wrappingLabelWithString: text)
186+
label.font = bold ? .boldSystemFont(ofSize: 13) : .systemFont(ofSize: 12)
187+
label.preferredMaxLayoutWidth = 476
188+
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
189+
return label
190+
}
191+
192+
private func metaLabel(_ name: String, _ value: String) -> NSTextField {
193+
let label = NSTextField(labelWithString: "\(name): \(value)")
194+
label.font = .systemFont(ofSize: 11)
195+
label.textColor = .secondaryLabelColor
196+
label.setAccessibilityLabel(name)
197+
label.setAccessibilityValue(value)
198+
return label
199+
}
200+
201+
@objc private func valueChanged(_ sender: NSPopUpButton) {
202+
guard !isReloading, let key = sender.identifier?.rawValue else { return }
203+
commit(key, sender.titleOfSelectedItem ?? "")
204+
}
205+
206+
@objc private func textCommitted(_ sender: NSTextField) {
207+
guard !isReloading, let key = sender.identifier?.rawValue else { return }
208+
commit(key, sender.stringValue)
209+
}
210+
211+
func controlTextDidEndEditing(_ obj: Notification) {
212+
guard let field = obj.object as? NSTextField else { return }
213+
textCommitted(field)
214+
}
215+
216+
@objc private func resetClicked(_ sender: NSButton) {
217+
guard let raw = sender.identifier?.rawValue, raw.hasPrefix("reset.") else { return }
218+
let key = String(raw.dropFirst("reset.".count))
219+
do {
220+
_ = try settings.reset(key)
221+
try reload()
222+
} catch {
223+
present(error)
224+
try? reload()
225+
}
226+
}
227+
228+
private func commit(_ key: String, _ rawValue: String) {
229+
do {
230+
let current = try settings.store.snapshot(key, caller: .user)
231+
guard current.value != rawValue || !current.configured else { return }
232+
_ = try settings.set(key, rawValue: rawValue)
233+
try reload()
234+
} catch {
235+
present(error)
236+
try? reload()
237+
}
238+
}
239+
240+
private func present(_ error: Error) {
241+
let alert = NSAlert()
242+
alert.alertStyle = .warning
243+
alert.messageText = "Couldn’t update settings"
244+
alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription
245+
alert.addButton(withTitle: "OK")
246+
if let window {
247+
alert.beginSheetModal(for: window) { _ in }
248+
} else {
249+
alert.runModal()
250+
}
251+
}
252+
}

apps/headless/Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ let package = Package(
8484
"LinuxHost", "Dockerfile.linux", "Headless.app", "build-linux.sh", "install.sh", "install-linux.sh", "benchmark.sh", ".dockerignore",
8585
"MCP", "CredentialBroker", "CredentialBrokerCore", "SecurePrompt", "node_modules",
8686
],
87-
sources: ["main.swift", "Host/AgentBridge.swift", "Host/QADiagnosticsBridge.swift"],
87+
sources: [
88+
"main.swift", "Host/AgentBridge.swift", "Host/QADiagnosticsBridge.swift",
89+
"Host/SettingsWindow.swift",
90+
],
8891
linkerSettings: [
8992
.linkedFramework("Cocoa", .when(platforms: [.macOS])),
9093
.linkedFramework("WebKit", .when(platforms: [.macOS])),

apps/headless/Sources/HeadlessProtocol/CLI.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,5 +865,6 @@ Global options:
865865
-- stop parsing global options; quote multi-word fill values
866866
867867
Settings:
868+
macOS Command-, opens Settings; `config` is the portable path on every platform.
868869
\(SettingsRegistry.shared.helpLines.joined(separator: "\n"))
869870
"""

apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,13 @@ public struct MenuShortcutSpec: Equatable, Sendable {
5757
}
5858

5959
public let headlessMenuShortcuts: [MenuShortcutSpec] = [
60+
.init(
61+
menu: "Headless", title: "Settings…", key: ",", selector: "showSettings:",
62+
target: .appDelegate
63+
),
6064
.init(
6165
menu: "Headless", title: "Hide Headless", key: "h", selector: "hide:",
62-
target: .application
66+
target: .application, separatorBefore: true
6367
),
6468
.init(
6569
menu: "Headless", title: "Hide Others", key: "h", option: true,

apps/headless/Sources/HeadlessProtocol/Settings.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,27 @@ public final class SettingsStore: @unchecked Sendable {
285285
return mutationDocument(definition, value: definition.defaultValue, configured: false)
286286
}
287287

288+
public func snapshots(caller: SettingsCaller = .agent) throws -> [SettingSnapshot] {
289+
try registry.definitions.compactMap { definition in
290+
guard canRead(definition, caller: caller) else { return nil }
291+
return try snapshot(of: definition)
292+
}
293+
}
294+
295+
public func snapshot(_ key: String, caller: SettingsCaller = .agent) throws -> SettingSnapshot {
296+
try snapshot(of: try visibleDefinition(key, caller: caller))
297+
}
298+
299+
private func snapshot(of definition: SettingDefinition) throws -> SettingSnapshot {
300+
let configured = try configuredRawValue(definition)
301+
return SettingSnapshot(
302+
definition: definition,
303+
value: configured ?? definition.defaultValue,
304+
configured: configured != nil,
305+
supportedOnCurrentPlatform: definition.platforms.contains(platform)
306+
)
307+
}
308+
288309
private func accessibleDefinition(
289310
_ key: String, caller: SettingsCaller, write: Bool
290311
) throws -> SettingDefinition {
@@ -364,6 +385,56 @@ public final class SettingsStore: @unchecked Sendable {
364385
}
365386
}
366387

388+
public struct SettingSnapshot: Equatable, Sendable {
389+
public let definition: SettingDefinition
390+
public let value: String
391+
public let configured: Bool
392+
public let supportedOnCurrentPlatform: Bool
393+
394+
public var agentsMayModify: Bool { definition.access == .agentWritable }
395+
396+
public var selectableValues: [String]? {
397+
switch definition.valueType {
398+
case .boolean:
399+
return ["false", "true"]
400+
case .enumeration(let values):
401+
return values
402+
case .integer, .string:
403+
return nil
404+
}
405+
}
406+
407+
public var platformSummary: String {
408+
definition.platforms.sorted { $0.rawValue < $1.rawValue }.map(\.rawValue).joined(separator: ", ")
409+
}
410+
}
411+
412+
/// Trusted native surface over `SettingsStore`. Always acts as the user so
413+
/// CLI `config` and the macOS Settings window share validation and storage.
414+
public final class SettingsController: @unchecked Sendable {
415+
public let store: SettingsStore
416+
417+
public init(store: SettingsStore) {
418+
self.store = store
419+
}
420+
421+
public func snapshots() throws -> [SettingSnapshot] {
422+
try store.snapshots(caller: .user)
423+
}
424+
425+
@discardableResult
426+
public func set(_ key: String, rawValue: String) throws -> SettingSnapshot {
427+
_ = try store.set(key, rawValue: rawValue, caller: .user)
428+
return try store.snapshot(key, caller: .user)
429+
}
430+
431+
@discardableResult
432+
public func reset(_ key: String) throws -> SettingSnapshot {
433+
_ = try store.reset(key, caller: .user)
434+
return try store.snapshot(key, caller: .user)
435+
}
436+
}
437+
367438
public final class UserDefaultsSettingsBackend: @unchecked Sendable, SettingsBackend {
368439
private static let domain = "com.headless.app"
369440
private static let canonicalPrefix = "HeadlessSetting."

0 commit comments

Comments
 (0)