style(macos): tidy settings and CLI

main
Peter Steinberger 2025-12-13 19:23:41 +00:00
parent 02fe19effa
commit 0b990443de
13 changed files with 116 additions and 89 deletions

View File

@ -38,4 +38,3 @@ enum ClawdisConfigFile {
self.saveDict(root) self.saveDict(root)
} }
} }

View File

@ -203,14 +203,14 @@ struct ConfigSettings: View {
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.disabled(!self.browserEnabled) .disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() } .onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help("When enabled, the browser server will only connect if the clawd browser is already running.") .help(
"When enabled, the browser server will only connect if the clawd browser is already running.")
} }
GridRow { GridRow {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"Clawd uses a separate Chrome profile and ports (default 18791/18792) so it wont interfere with your daily browser." "Clawd uses a separate Chrome profile and ports (default 18791/18792) so it wont interfere with your daily browser.")
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@ -525,7 +525,8 @@ private struct CronJobEditor: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(self.job == nil ? "New cron job" : "Edit cron job") Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text("Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean.") Text(
"Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean.")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@ -570,7 +571,8 @@ private struct CronJobEditor: View {
GridRow { GridRow {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text("Main jobs post a system event into the current main session. Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc).") Text(
"Main jobs post a system event into the current main session. Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc).")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -594,7 +596,8 @@ private struct CronJobEditor: View {
GridRow { GridRow {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text("“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression.") Text(
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -604,7 +607,10 @@ private struct CronJobEditor: View {
case .at: case .at:
GridRow { GridRow {
self.gridLabel("At") self.gridLabel("At")
DatePicker("", selection: self.$atDate, displayedComponents: [.date, .hourAndMinute]) DatePicker(
"",
selection: self.$atDate,
displayedComponents: [.date, .hourAndMinute])
.labelsHidden() .labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@ -635,7 +641,8 @@ private struct CronJobEditor: View {
GroupBox("Payload") { GroupBox("Payload") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if self.sessionTarget == .isolated { if self.sessionTarget == .isolated {
Text("Isolated jobs always run an agent turn. The result can be delivered to a surface, and a short summary is posted back to your main chat.") Text(
"Isolated jobs always run an agent turn. The result can be delivered to a surface, and a short summary is posted back to your main chat.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@ -655,7 +662,8 @@ private struct CronJobEditor: View {
GridRow { GridRow {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text("System events are injected into the current main session. Agent turns require an isolated session target.") Text(
"System events are injected into the current main session. Agent turns require an isolated session target.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -687,7 +695,8 @@ private struct CronJobEditor: View {
GridRow { GridRow {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text("Controls the label used when posting the completion summary back to the main session.") Text(
"Controls the label used when posting the completion summary back to the main session.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@ -45,13 +45,13 @@ struct MenuContent: View {
WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey()) WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey())
} }
} }
Toggle(isOn: Binding( Toggle(
isOn: Binding(
get: { self.browserControlEnabled }, get: { self.browserControlEnabled },
set: { enabled in set: { enabled in
self.browserControlEnabled = enabled self.browserControlEnabled = enabled
ClawdisConfigFile.setBrowserControlEnabled(enabled) ClawdisConfigFile.setBrowserControlEnabled(enabled)
}) })) {
) {
Text("Browser Control") Text("Browser Control")
} }
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
@ -110,7 +110,9 @@ struct MenuContent: View {
await self.reloadSessionMenu() await self.reloadSessionMenu()
} }
} label: { } label: {
Label(level.capitalized, systemImage: row.thinkingLevel == normalized ? "checkmark" : "") Label(
level.capitalized,
systemImage: row.thinkingLevel == normalized ? "checkmark" : "")
} }
} }
} }
@ -126,7 +128,9 @@ struct MenuContent: View {
await self.reloadSessionMenu() await self.reloadSessionMenu()
} }
} label: { } label: {
Label(level.capitalized, systemImage: row.verboseLevel == normalized ? "checkmark" : "") Label(
level.capitalized,
systemImage: row.verboseLevel == normalized ? "checkmark" : "")
} }
} }
} }

View File

@ -53,7 +53,8 @@ final class PeekabooBridgeHostCoordinator {
self.host = host self.host = host
await host.start() await host.start()
self.logger.info("PeekabooBridge host started at \(PeekabooBridgeConstants.clawdisSocketPath, privacy: .public)") self.logger
.info("PeekabooBridge host started at \(PeekabooBridgeConstants.clawdisSocketPath, privacy: .public)")
} }
} }

View File

@ -28,4 +28,3 @@ extension View {
self.modifier(PointingHandCursorModifier()) self.modifier(PointingHandCursorModifier())
} }
} }

View File

@ -15,4 +15,3 @@ enum ScreenshotSize {
return Size(width: width, height: height) return Size(width: width, height: height)
} }
} }

View File

@ -36,8 +36,8 @@ struct GatewayChatMessage: Codable, Identifiable {
id: UUID = .init(), id: UUID = .init(),
role: String, role: String,
content: [GatewayChatMessageContent], content: [GatewayChatMessageContent],
timestamp: Double? timestamp: Double?)
) { {
self.id = id self.id = id
self.role = role self.role = role
self.content = content self.content = content
@ -124,6 +124,7 @@ final class WebChatViewModel: ObservableObject {
private var pendingRuns = Set<String>() { private var pendingRuns = Set<String>() {
didSet { self.pendingRunCount = self.pendingRuns.count } didSet { self.pendingRunCount = self.pendingRuns.count }
} }
private var lastHealthPollAt: Date? private var lastHealthPollAt: Date?
init(sessionKey: String) { init(sessionKey: String) {
@ -162,7 +163,8 @@ final class WebChatViewModel: ObservableObject {
do { do {
let data = try await Task.detached { try Data(contentsOf: url) }.value let data = try await Task.detached { try Data(contentsOf: url) }.value
guard data.count <= 5_000_000 else { guard data.count <= 5_000_000 else {
await MainActor.run { self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" } await MainActor
.run { self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" }
continue continue
} }
let uti = UTType(filenameExtension: url.pathExtension) ?? .data let uti = UTType(filenameExtension: url.pathExtension) ?? .data
@ -445,7 +447,8 @@ struct WebChatView: View {
.foregroundStyle(Color.accentColor.opacity(0.9)) .foregroundStyle(Color.accentColor.opacity(0.9))
Text("Say hi to Clawd") Text("Say hi to Clawd")
.font(.headline) .font(.headline)
Text(self.viewModel.healthOK ? "This is the native SwiftUI debug chat." : "Connecting to the gateway…") Text(self.viewModel
.healthOK ? "This is the native SwiftUI debug chat." : "Connecting to the gateway…")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -458,7 +461,9 @@ struct WebChatView: View {
} else { } else {
ForEach(self.viewModel.messages) { msg in ForEach(self.viewModel.messages) { msg in
MessageBubble(message: msg) MessageBubble(message: msg)
.frame(maxWidth: .infinity, alignment: msg.role.lowercased() == "user" ? .trailing : .leading) .frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
} }
} }
@ -673,7 +678,7 @@ private struct ChatMessageBody: View {
switch block.kind { switch block.kind {
case .text: case .text:
MarkdownTextView(text: block.text) MarkdownTextView(text: block.text)
case .code(let language): case let .code(language):
CodeBlockView(code: block.text, language: language) CodeBlockView(code: block.text, language: language)
} }
} }
@ -747,7 +752,7 @@ private struct AttachmentRow: View {
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "paperclip") Image(systemName: "paperclip")
Text(att.fileName ?? "Attachment") Text(self.att.fileName ?? "Attachment")
.font(.footnote) .font(.footnote)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()

View File

@ -83,25 +83,34 @@ enum BrowserCLI {
do { do {
switch sub { switch sub {
case "status": case "status":
self.printResult( try await self.printResult(
jsonOutput: jsonOutput, jsonOutput: jsonOutput,
res: try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))) res: self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")))
return 0 return 0
case "start": case "start":
self.printResult( try await self.printResult(
jsonOutput: jsonOutput, jsonOutput: jsonOutput,
res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/start"), timeoutInterval: 15.0)) res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/start"),
timeoutInterval: 15.0))
return 0 return 0
case "stop": case "stop":
self.printResult( try await self.printResult(
jsonOutput: jsonOutput, jsonOutput: jsonOutput,
res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/stop"), timeoutInterval: 15.0)) res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/stop"),
timeoutInterval: 15.0))
return 0 return 0
case "tabs": case "tabs":
let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/tabs"), timeoutInterval: 3.0) let res = try await self.httpJSON(
method: "GET",
url: baseURL.appendingPathComponent("/tabs"),
timeoutInterval: 3.0)
if jsonOutput { if jsonOutput {
self.printJSON(ok: true, result: res) self.printJSON(ok: true, result: res)
} else { } else {
@ -114,9 +123,9 @@ enum BrowserCLI {
self.printHelp() self.printHelp()
return 2 return 2
} }
self.printResult( try await self.printResult(
jsonOutput: jsonOutput, jsonOutput: jsonOutput,
res: try await self.httpJSON( res: self.httpJSON(
method: "POST", method: "POST",
url: baseURL.appendingPathComponent("/tabs/open"), url: baseURL.appendingPathComponent("/tabs/open"),
body: ["url": url], body: ["url": url],
@ -128,9 +137,9 @@ enum BrowserCLI {
self.printHelp() self.printHelp()
return 2 return 2
} }
self.printResult( try await self.printResult(
jsonOutput: jsonOutput, jsonOutput: jsonOutput,
res: try await self.httpJSON( res: self.httpJSON(
method: "POST", method: "POST",
url: baseURL.appendingPathComponent("/tabs/focus"), url: baseURL.appendingPathComponent("/tabs/focus"),
body: ["targetId": id], body: ["targetId": id],
@ -142,9 +151,9 @@ enum BrowserCLI {
self.printHelp() self.printHelp()
return 2 return 2
} }
self.printResult( try await self.printResult(
jsonOutput: jsonOutput, jsonOutput: jsonOutput,
res: try await self.httpJSON( res: self.httpJSON(
method: "DELETE", method: "DELETE",
url: baseURL.appendingPathComponent("/tabs/\(id)"), url: baseURL.appendingPathComponent("/tabs/\(id)"),
timeoutInterval: 5.0)) timeoutInterval: 5.0))
@ -345,8 +354,8 @@ enum BrowserCLI {
method: String, method: String,
url: URL, url: URL,
body: [String: Any]? = nil, body: [String: Any]? = nil,
timeoutInterval: TimeInterval = 2.0 timeoutInterval: TimeInterval = 2.0) async throws -> [String: Any]
) async throws -> [String: Any] { {
var req = URLRequest(url: url, timeoutInterval: timeoutInterval) var req = URLRequest(url: url, timeoutInterval: timeoutInterval)
req.httpMethod = method req.httpMethod = method
if let body { if let body {
@ -369,7 +378,7 @@ enum BrowserCLI {
]) ])
} }
if status >= 200 && status < 300 { if status >= 200, status < 300 {
return obj return obj
} }

View File

@ -1,5 +1,5 @@
import Foundation
import Darwin import Darwin
import Foundation
import PeekabooAutomationKit import PeekabooAutomationKit
import PeekabooBridge import PeekabooBridge
import PeekabooFoundation import PeekabooFoundation
@ -94,7 +94,7 @@ enum UICLI {
private static func runPermissions(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 { private static func runPermissions(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
let sub = args.first ?? "status" let sub = args.first ?? "status"
if sub != "status" && sub != "--help" && sub != "-h" && sub != "help" { if sub != "status", sub != "--help", sub != "-h", sub != "help" {
self.printHelp() self.printHelp()
return 1 return 1
} }
@ -103,7 +103,7 @@ enum UICLI {
try self.writeJSON([ try self.writeJSON([
"ok": true, "ok": true,
"host": context.hostDescription, "host": context.hostDescription,
"result": try self.toJSONObject(status), "result": self.toJSONObject(status),
]) ])
} else { } else {
FileHandle.standardOutput.write(Data((self.formatPermissions(status) + "\n").utf8)) FileHandle.standardOutput.write(Data((self.formatPermissions(status) + "\n").utf8))
@ -123,7 +123,7 @@ enum UICLI {
try self.writeJSON([ try self.writeJSON([
"ok": true, "ok": true,
"host": context.hostDescription, "host": context.hostDescription,
"app": try self.toJSONObject(app), "app": self.toJSONObject(app),
"window": windowObject, "window": windowObject,
]) ])
} else { } else {
@ -131,7 +131,7 @@ enum UICLI {
let line = "\(bundle) (pid \(app.processIdentifier))" let line = "\(bundle) (pid \(app.processIdentifier))"
FileHandle.standardOutput.write(Data((line + "\n").utf8)) FileHandle.standardOutput.write(Data((line + "\n").utf8))
if let window { if let window {
FileHandle.standardOutput.write(Data(("window \(window.windowID): \(window.title)\n").utf8)) FileHandle.standardOutput.write(Data("window \(window.windowID): \(window.title)\n".utf8))
} }
} }
return 0 return 0
@ -143,12 +143,12 @@ enum UICLI {
try self.writeJSON([ try self.writeJSON([
"ok": true, "ok": true,
"host": context.hostDescription, "host": context.hostDescription,
"result": try self.toJSONObject(apps), "result": self.toJSONObject(apps),
]) ])
} else { } else {
for app in apps { for app in apps {
let bundle = app.bundleIdentifier ?? "<unknown>" let bundle = app.bundleIdentifier ?? "<unknown>"
FileHandle.standardOutput.write(Data(("\(bundle)\t\(app.name)\n").utf8)) FileHandle.standardOutput.write(Data("\(bundle)\t\(app.name)\n".utf8))
} }
} }
return 0 return 0
@ -176,11 +176,11 @@ enum UICLI {
try self.writeJSON([ try self.writeJSON([
"ok": true, "ok": true,
"host": context.hostDescription, "host": context.hostDescription,
"result": try self.toJSONObject(windows), "result": self.toJSONObject(windows),
]) ])
} else { } else {
for window in windows { for window in windows {
FileHandle.standardOutput.write(Data(("\(window.windowID)\t\(window.title)\n").utf8)) FileHandle.standardOutput.write(Data("\(window.windowID)\t\(window.title)\n".utf8))
} }
} }
return 0 return 0
@ -217,20 +217,19 @@ enum UICLI {
} }
} }
let capture: CaptureResult let capture: CaptureResult = if let bundleId, !bundleId.isEmpty {
if let bundleId, !bundleId.isEmpty { try await context.client.captureWindow(
capture = try await context.client.captureWindow(
appIdentifier: bundleId, appIdentifier: bundleId,
windowIndex: windowIndex, windowIndex: windowIndex,
visualizerMode: mode, visualizerMode: mode,
scale: scale) scale: scale)
} else if displayIndex != nil { } else if displayIndex != nil {
capture = try await context.client.captureScreen( try await context.client.captureScreen(
displayIndex: displayIndex, displayIndex: displayIndex,
visualizerMode: mode, visualizerMode: mode,
scale: scale) scale: scale)
} else { } else {
capture = try await context.client.captureFrontmost(visualizerMode: mode, scale: scale) try await context.client.captureFrontmost(visualizerMode: mode, scale: scale)
} }
let path = try self.writeTempPNG(capture.imageData) let path = try self.writeTempPNG(capture.imageData)
@ -240,7 +239,7 @@ enum UICLI {
"ok": true, "ok": true,
"host": context.hostDescription, "host": context.hostDescription,
"path": path, "path": path,
"metadata": try self.toJSONObject(capture.metadata), "metadata": self.toJSONObject(capture.metadata),
"warning": capture.warning ?? "", "warning": capture.warning ?? "",
]) ])
} else { } else {
@ -287,7 +286,8 @@ enum UICLI {
let resolvedSnapshotId: String = if let snapshotId, !snapshotId.isEmpty { let resolvedSnapshotId: String = if let snapshotId, !snapshotId.isEmpty {
snapshotId snapshotId
} else if let bundleId, !bundleId.isEmpty, let existing = try? await context.client } else if let bundleId, !bundleId.isEmpty, let existing = try? await context.client
.getMostRecentSnapshot(applicationBundleId: bundleId) { .getMostRecentSnapshot(applicationBundleId: bundleId)
{
existing existing
} else { } else {
try await context.client.createSnapshot() try await context.client.createSnapshot()
@ -321,7 +321,7 @@ enum UICLI {
"host": context.hostDescription, "host": context.hostDescription,
"snapshotId": resolvedSnapshotId, "snapshotId": resolvedSnapshotId,
"screenshotPath": screenshotPath, "screenshotPath": screenshotPath,
"result": try self.toJSONObject(detection), "result": self.toJSONObject(detection),
]) ])
} else { } else {
FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8)) FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8))
@ -494,7 +494,7 @@ enum UICLI {
try self.writeJSON([ try self.writeJSON([
"ok": true, "ok": true,
"host": context.hostDescription, "host": context.hostDescription,
"result": try self.toJSONObject(result), "result": self.toJSONObject(result),
]) ])
} else { } else {
FileHandle.standardOutput.write(Data((result.found ? "found\n" : "not found\n").utf8)) FileHandle.standardOutput.write(Data((result.found ? "found\n" : "not found\n").utf8))
@ -549,7 +549,7 @@ enum UICLI {
return "\(sr) \(ax) \(ascr)" return "\(sr) \(ax) \(ascr)"
} }
private static func toJSONObject<T: Encodable>(_ value: T) throws -> Any { private static func toJSONObject(_ value: some Encodable) throws -> Any {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(value) let data = try encoder.encode(value)

View File

@ -15,8 +15,10 @@
"clawdis": "tsx src/index.ts", "clawdis": "tsx src/index.ts",
"clawdis:rpc": "tsx src/index.ts agent --mode rpc --json", "clawdis:rpc": "tsx src/index.ts agent --mode rpc --json",
"lint": "biome check src", "lint": "biome check src",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:fix": "biome check --write --unsafe src && biome format --write src", "lint:fix": "biome check --write --unsafe src && biome format --write src",
"format": "biome format src", "format": "biome format src",
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdisKit/Sources",
"format:fix": "biome format src --write", "format:fix": "biome format src --write",
"test": "vitest", "test": "vitest",
"test:force": "tsx scripts/test-force.ts", "test:force": "tsx scripts/test-force.ts",