chore: run formatters and lint

This commit is contained in:
Peter Steinberger
2025-12-06 04:03:48 +01:00
parent c6e3b490f5
commit fbf5333b39
5 changed files with 262 additions and 239 deletions

View File

@@ -6,7 +6,6 @@
--wrapcollections before-first --wrapcollections before-first
--trimwhitespace always --trimwhitespace always
--stripunusedargs closure-only --stripunusedargs closure-only
--stripunusedimports
--self init-only --self init-only
--disable redundantSelf --disable redundantSelf
--enable wrapMultilineStatementBraces --enable wrapMultilineStatementBraces

View File

@@ -1,19 +1,18 @@
import AppKit import AppKit
import ApplicationServices import ApplicationServices
import AsyncXPCConnection import AsyncXPCConnection
import ClawdisIPC
import Foundation
import class Foundation.Bundle
import OSLog
import CoreGraphics
@preconcurrency import ScreenCaptureKit
import AVFoundation import AVFoundation
import Speech import ClawdisIPC
import VideoToolbox import CoreGraphics
import Foundation
import MenuBarExtraAccess
import OSLog
@preconcurrency import ScreenCaptureKit
import ServiceManagement import ServiceManagement
import Speech
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
import MenuBarExtraAccess import VideoToolbox
private let serviceName = "com.steipete.clawdis.xpc" private let serviceName = "com.steipete.clawdis.xpc"
private let launchdLabel = "com.steipete.clawdis" private let launchdLabel = "com.steipete.clawdis"
@@ -22,6 +21,7 @@ private let currentOnboardingVersion = 2
private let pauseDefaultsKey = "clawdis.pauseEnabled" private let pauseDefaultsKey = "clawdis.pauseEnabled"
private let swabbleEnabledKey = "clawdis.swabbleEnabled" private let swabbleEnabledKey = "clawdis.swabbleEnabled"
private let swabbleTriggersKey = "clawdis.swabbleTriggers" private let swabbleTriggersKey = "clawdis.swabbleTriggers"
private let showDockIconKey = "clawdis.showDockIcon"
private let defaultVoiceWakeTriggers = ["clawd", "claude"] private let defaultVoiceWakeTriggers = ["clawd", "claude"]
private let voiceWakeMicKey = "clawdis.voiceWakeMicID" private let voiceWakeMicKey = "clawdis.voiceWakeMicID"
private let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" private let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
@@ -35,21 +35,27 @@ final class AppState: ObservableObject {
@Published var isPaused: Bool { @Published var isPaused: Bool {
didSet { UserDefaults.standard.set(isPaused, forKey: pauseDefaultsKey) } didSet { UserDefaults.standard.set(isPaused, forKey: pauseDefaultsKey) }
} }
@Published var defaultSound: String { @Published var defaultSound: String {
didSet { UserDefaults.standard.set(defaultSound, forKey: "clawdis.defaultSound") } didSet { UserDefaults.standard.set(defaultSound, forKey: "clawdis.defaultSound") }
} }
@Published var launchAtLogin: Bool { @Published var launchAtLogin: Bool {
didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: launchAtLogin) } } didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: launchAtLogin) } }
} }
@Published var onboardingSeen: Bool { @Published var onboardingSeen: Bool {
didSet { UserDefaults.standard.set(onboardingSeen, forKey: "clawdis.onboardingSeen") } didSet { UserDefaults.standard.set(onboardingSeen, forKey: "clawdis.onboardingSeen") }
} }
@Published var debugPaneEnabled: Bool { @Published var debugPaneEnabled: Bool {
didSet { UserDefaults.standard.set(debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") } didSet { UserDefaults.standard.set(debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
} }
@Published var swabbleEnabled: Bool { @Published var swabbleEnabled: Bool {
didSet { UserDefaults.standard.set(swabbleEnabled, forKey: swabbleEnabledKey) } didSet { UserDefaults.standard.set(swabbleEnabled, forKey: swabbleEnabledKey) }
} }
@Published var swabbleTriggerWords: [String] { @Published var swabbleTriggerWords: [String] {
didSet { didSet {
let cleaned = swabbleTriggerWords.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } let cleaned = swabbleTriggerWords.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -60,12 +66,22 @@ final class AppState: ObservableObject {
} }
} }
} }
@Published var showDockIcon: Bool {
didSet {
UserDefaults.standard.set(showDockIcon, forKey: showDockIconKey)
AppActivationPolicy.apply(showDockIcon: showDockIcon)
}
}
@Published var voiceWakeMicID: String { @Published var voiceWakeMicID: String {
didSet { UserDefaults.standard.set(voiceWakeMicID, forKey: voiceWakeMicKey) } didSet { UserDefaults.standard.set(voiceWakeMicID, forKey: voiceWakeMicKey) }
} }
@Published var voiceWakeLocaleID: String { @Published var voiceWakeLocaleID: String {
didSet { UserDefaults.standard.set(voiceWakeLocaleID, forKey: voiceWakeLocaleKey) } didSet { UserDefaults.standard.set(voiceWakeLocaleID, forKey: voiceWakeLocaleKey) }
} }
@Published var voiceWakeAdditionalLocaleIDs: [String] { @Published var voiceWakeAdditionalLocaleIDs: [String] {
didSet { UserDefaults.standard.set(voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) } didSet { UserDefaults.standard.set(voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) }
} }
@@ -79,6 +95,7 @@ final class AppState: ObservableObject {
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
self.swabbleTriggerWords = UserDefaults.standard.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers self.swabbleTriggerWords = UserDefaults.standard.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
@@ -100,6 +117,13 @@ enum AppStateStore {
} }
} }
@MainActor
enum AppActivationPolicy {
static func apply(showDockIcon: Bool) {
NSApp.setActivationPolicy(showDockIcon ? .regular : .accessory)
}
}
// MARK: - XPC service protocol // MARK: - XPC service protocol
@objc protocol ClawdisXPCProtocol { @objc protocol ClawdisXPCProtocol {
@@ -135,18 +159,20 @@ final class ClawdisXPCService: NSObject, ClawdisXPCProtocol {
switch request { switch request {
case let .notify(title, body, sound): case let .notify(title, body, sound):
let chosenSound: String let chosenSound: String = if let sound { sound } else { await MainActor.run { AppStateStore.defaultSound } }
if let sound { chosenSound = sound } else { chosenSound = await MainActor.run { AppStateStore.defaultSound } }
let ok = await notifier.send(title: title, body: body, sound: chosenSound) let ok = await notifier.send(title: title, body: body, sound: chosenSound)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized") return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case let .ensurePermissions(caps, interactive): case let .ensurePermissions(caps, interactive):
let statuses = await PermissionManager.ensure(caps, interactive: interactive) let statuses = await PermissionManager.ensure(caps, interactive: interactive)
let missing = statuses.filter { !$0.value }.map { $0.key.rawValue } let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
let ok = missing.isEmpty let ok = missing.isEmpty
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))" let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
return Response(ok: ok, message: msg) return Response(ok: ok, message: msg)
case .status: case .status:
return Response(ok: true, message: "ready") return Response(ok: true, message: "ready")
case let .screenshot(displayID, windowID, _): case let .screenshot(displayID, windowID, _):
let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") } guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
@@ -154,6 +180,7 @@ final class ClawdisXPCService: NSObject, ClawdisXPCProtocol {
return Response(ok: true, payload: data) return Response(ok: true, payload: data)
} }
return Response(ok: false, message: "screenshot failed") return Response(ok: false, message: "screenshot failed")
case let .runShell(command, cwd, env, timeoutSec, needsSR): case let .runShell(command, cwd, env, timeoutSec, needsSR):
if needsSR { if needsSR {
let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
@@ -206,37 +233,41 @@ enum PermissionManager {
case .notifications: case .notifications:
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let status = await center.notificationSettings() let status = await center.notificationSettings()
if status.authorizationStatus == .notDetermined && interactive { if status.authorizationStatus == .notDetermined, interactive {
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
let post = await center.notificationSettings() let post = await center.notificationSettings()
results[cap] = post.authorizationStatus == .authorized results[cap] = post.authorizationStatus == .authorized
} else { } else {
results[cap] = status.authorizationStatus == .authorized results[cap] = status.authorizationStatus == .authorized
} }
case .accessibility: case .accessibility:
// Accessing AX APIs must be on main thread. // Accessing AX APIs must be on main thread.
let trusted = AXIsProcessTrusted() let trusted = AXIsProcessTrusted()
results[cap] = trusted results[cap] = trusted
if interactive && !trusted { if interactive, !trusted {
_ = AXIsProcessTrustedWithOptions(nil) _ = AXIsProcessTrustedWithOptions(nil)
} }
case .screenRecording: case .screenRecording:
let granted = ScreenRecordingProbe.isAuthorized() let granted = ScreenRecordingProbe.isAuthorized()
if interactive && !granted { if interactive, !granted {
await ScreenRecordingProbe.requestAuthorization() await ScreenRecordingProbe.requestAuthorization()
} }
results[cap] = ScreenRecordingProbe.isAuthorized() results[cap] = ScreenRecordingProbe.isAuthorized()
case .microphone: case .microphone:
let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
if interactive && !granted { if interactive, !granted {
let ok = await AVCaptureDevice.requestAccess(for: .audio) let ok = await AVCaptureDevice.requestAccess(for: .audio)
results[cap] = ok results[cap] = ok
} else { } else {
results[cap] = granted results[cap] = granted
} }
case .speechRecognition: case .speechRecognition:
let status = SFSpeechRecognizer.authorizationStatus() let status = SFSpeechRecognizer.authorizationStatus()
if status == .notDetermined && interactive { if status == .notDetermined, interactive {
let ok = await withCheckedContinuation { cont in let ok = await withCheckedContinuation { cont in
SFSpeechRecognizer.requestAuthorization { auth in cont.resume(returning: auth == .authorized) } SFSpeechRecognizer.requestAuthorization { auth in cont.resume(returning: auth == .authorized) }
} }
@@ -258,16 +289,20 @@ enum PermissionManager {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings() let settings = await center.notificationSettings()
results[cap] = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional results[cap] = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional
case .accessibility: case .accessibility:
results[cap] = AXIsProcessTrusted() results[cap] = AXIsProcessTrusted()
case .screenRecording: case .screenRecording:
if #available(macOS 10.15, *) { if #available(macOS 10.15, *) {
results[cap] = CGPreflightScreenCaptureAccess() results[cap] = CGPreflightScreenCaptureAccess()
} else { } else {
results[cap] = true results[cap] = true
} }
case .microphone: case .microphone:
results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
case .speechRecognition: case .speechRecognition:
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
} }
@@ -299,11 +334,10 @@ enum Screenshotter {
static func capture(displayID: UInt32?, windowID: UInt32?) async -> Data? { static func capture(displayID: UInt32?, windowID: UInt32?) async -> Data? {
guard let content = try? await SCShareableContent.current else { return nil } guard let content = try? await SCShareableContent.current else { return nil }
let targetDisplay: SCDisplay? let targetDisplay: SCDisplay? = if let displayID {
if let displayID { content.displays.first(where: { $0.displayID == displayID })
targetDisplay = content.displays.first(where: { $0.displayID == displayID })
} else { } else {
targetDisplay = content.displays.first content.displays.first
} }
let filter: SCContentFilter let filter: SCContentFilter
@@ -394,7 +428,7 @@ enum ShellRunner {
} }
if let timeout, timeout > 0 { if let timeout, timeout > 0 {
let nanos = UInt64(timeout * 1_000_000_000) let nanos = UInt64(timeout * 1000000000)
try? await Task.sleep(nanoseconds: nanos) try? await Task.sleep(nanoseconds: nanos)
if process.isRunning { if process.isRunning {
process.terminate() process.terminate()
@@ -481,7 +515,7 @@ private struct MenuContent: View {
let storePath = SessionLoader.defaultStorePath let storePath = SessionLoader.defaultStorePath
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)), if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) { let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) {
let sorted = decoded.sorted { (a, b) -> Bool in let sorted = decoded.sorted { a, b -> Bool in
let lhs = a.value.updatedAt ?? 0 let lhs = a.value.updatedAt ?? 0
let rhs = b.value.updatedAt ?? 0 let rhs = b.value.updatedAt ?? 0
return lhs > rhs return lhs > rhs
@@ -514,11 +548,9 @@ private struct CritterStatusLabel: View {
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0)) Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
.frame(width: 18, height: 16) .frame(width: 18, height: 16)
} else { } else {
Image(nsImage: CritterIconRenderer.makeIcon( Image(nsImage: CritterIconRenderer.makeIcon(blink: blinkAmount,
blink: blinkAmount, legWiggle: legWiggle,
legWiggle: legWiggle, earWiggle: earWiggle))
earWiggle: earWiggle
))
.frame(width: 18, height: 16) .frame(width: 18, height: 16)
.rotationEffect(.degrees(wiggleAngle), anchor: .center) .rotationEffect(.degrees(wiggleAngle), anchor: .center)
.offset(x: wiggleOffset) .offset(x: wiggleOffset)
@@ -641,22 +673,20 @@ enum CritterIconRenderer {
// Body // Body
ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH), cornerWidth: bodyCorner, cornerHeight: bodyCorner, transform: nil)) ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH), cornerWidth: bodyCorner, cornerHeight: bodyCorner, transform: nil))
// Ears (tiny wiggle) // Ears (tiny wiggle)
ctx.addPath(CGPath(roundedRect: CGRect( ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX - earW * 0.55 + earWiggle,
x: bodyX - earW * 0.55 + earWiggle, y: bodyY + bodyH * 0.08 + earWiggle * 0.4,
y: bodyY + bodyH * 0.08 + earWiggle * 0.4, width: earW,
width: earW, height: earH),
height: earH), cornerWidth: earCorner,
cornerWidth: earCorner, cornerHeight: earCorner,
cornerHeight: earCorner, transform: nil))
transform: nil)) ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX + bodyW - earW * 0.45 - earWiggle,
ctx.addPath(CGPath(roundedRect: CGRect( y: bodyY + bodyH * 0.08 - earWiggle * 0.4,
x: bodyX + bodyW - earW * 0.45 - earWiggle, width: earW,
y: bodyY + bodyH * 0.08 - earWiggle * 0.4, height: earH),
width: earW, cornerWidth: earCorner,
height: earH), cornerHeight: earCorner,
cornerWidth: earCorner, transform: nil))
cornerHeight: earCorner,
transform: nil))
// Legs // Legs
for i in 0 ..< 4 { for i in 0 ..< 4 {
let x = legStartX + CGFloat(i) * (legW + legSpacing) let x = legStartX + CGFloat(i) * (legW + legSpacing)
@@ -814,19 +844,19 @@ private enum SessionKind {
var label: String { var label: String {
switch self { switch self {
case .direct: return "Direct" case .direct: "Direct"
case .group: return "Group" case .group: "Group"
case .global: return "Global" case .global: "Global"
case .unknown: return "Unknown" case .unknown: "Unknown"
} }
} }
var tint: Color { var tint: Color {
switch self { switch self {
case .direct: return .accentColor case .direct: .accentColor
case .group: return .orange case .group: .orange
case .global: return .purple case .global: .purple
case .unknown: return .gray case .unknown: .gray
} }
} }
} }
@@ -849,16 +879,17 @@ private enum SessionLoadError: LocalizedError {
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case let .missingStore(path): case let .missingStore(path):
return "No session store found at \(path) yet. Send or receive a message to create it." "No session store found at \(path) yet. Send or receive a message to create it."
case let .decodeFailed(reason): case let .decodeFailed(reason):
return "Could not read the session store: \(reason)" "Could not read the session store: \(reason)"
} }
} }
} }
private enum SessionLoader { private enum SessionLoader {
static let fallbackModel = "claude-opus-4-5" static let fallbackModel = "claude-opus-4-5"
static let fallbackContextTokens = 200_000 static let fallbackContextTokens = 200000
static let defaultStorePath = standardize( static let defaultStorePath = standardize(
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
@@ -868,7 +899,7 @@ private enum SessionLoader {
private static let legacyStorePaths: [String] = [ private static let legacyStorePaths: [String] = [
standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".clawdis/sessions.json").path), standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".clawdis/sessions.json").path),
standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".warelay/sessions/sessions.json").path), standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".warelay/sessions/sessions.json").path),
standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".warelay/sessions.json").path), standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".warelay/sessions.json").path)
] ]
static func configHints() -> SessionConfigHints { static func configHints() -> SessionConfigHints {
@@ -890,11 +921,9 @@ private enum SessionLoader {
let model = agent?["model"] as? String let model = agent?["model"] as? String
let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue
return SessionConfigHints( return SessionConfigHints(storePath: store.map { standardize($0) },
storePath: store.map { standardize($0) }, model: model,
model: model, contextTokens: contextTokens)
contextTokens: contextTokens
)
} }
static func resolveStorePath(override: String?) -> String { static func resolveStorePath(override: String?) -> String {
@@ -928,24 +957,20 @@ private enum SessionLoader {
let context = entry.contextTokens ?? defaults.contextTokens let context = entry.contextTokens ?? defaults.contextTokens
let model = entry.model ?? defaults.model let model = entry.model ?? defaults.model
return SessionRow( return SessionRow(id: key,
id: key, key: key,
key: key, kind: SessionKind.from(key: key),
kind: SessionKind.from(key: key), updatedAt: updated,
updatedAt: updated, sessionId: entry.sessionId,
sessionId: entry.sessionId, thinkingLevel: entry.thinkingLevel,
thinkingLevel: entry.thinkingLevel, verboseLevel: entry.verboseLevel,
verboseLevel: entry.verboseLevel, systemSent: entry.systemSent ?? false,
systemSent: entry.systemSent ?? false, abortedLastRun: entry.abortedLastRun ?? false,
abortedLastRun: entry.abortedLastRun ?? false, tokens: SessionTokenStats(input: input,
tokens: SessionTokenStats( output: output,
input: input, total: total,
output: output, contextTokens: context),
total: total, model: model)
contextTokens: context
),
model: model
)
} }
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
}.value }.value
@@ -1056,7 +1081,7 @@ struct SessionsSettings: View {
private var content: some View { private var content: some View {
Group { Group {
if rows.isEmpty && errorMessage == nil { if rows.isEmpty, errorMessage == nil {
Text("No sessions yet. They appear after the first inbound message or heartbeat.") Text("No sessions yet. They appear after the first inbound message or heartbeat.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -1117,10 +1142,8 @@ struct SessionsSettings: View {
let hints = SessionLoader.configHints() let hints = SessionLoader.configHints()
let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath) let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults( let defaults = SessionDefaults(model: hints.model ?? SessionLoader.fallbackModel,
model: hints.model ?? SessionLoader.fallbackModel, contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens
)
do { do {
let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults) let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults)
@@ -1288,7 +1311,7 @@ struct SettingsRootView: View {
} }
} }
.onChange(of: state.debugPaneEnabled) { _, enabled in .onChange(of: state.debugPaneEnabled) { _, enabled in
if !enabled && selectedTab == .debug { if !enabled, selectedTab == .debug {
selectedTab = .general selectedTab = .general
} }
} }
@@ -1296,7 +1319,7 @@ struct SettingsRootView: View {
} }
private func validTab(for requested: SettingsTab) -> SettingsTab { private func validTab(for requested: SettingsTab) -> SettingsTab {
if requested == .debug && !state.debugPaneEnabled { return .general } if requested == .debug, !state.debugPaneEnabled { return .general }
return requested return requested
} }
@@ -1315,12 +1338,12 @@ enum SettingsTab: CaseIterable {
static let windowHeight: CGFloat = 520 static let windowHeight: CGFloat = 520
var title: String { var title: String {
switch self { switch self {
case .general: return "General" case .general: "General"
case .sessions: return "Sessions" case .sessions: "Sessions"
case .voiceWake: return "Voice Wake" case .voiceWake: "Voice Wake"
case .permissions: return "Permissions" case .permissions: "Permissions"
case .debug: return "Debug" case .debug: "Debug"
case .about: return "About" case .about: "About"
} }
} }
} }
@@ -1399,7 +1422,7 @@ actor MicLevelMonitor {
let frameCount = Int(buffer.frameLength) let frameCount = Int(buffer.frameLength)
guard frameCount > 0 else { return 0 } guard frameCount > 0 else { return 0 }
var sum: Float = 0 var sum: Float = 0
for i in 0..<frameCount { for i in 0 ..< frameCount {
let s = channel[i] let s = channel[i]
sum += s * s sum += s * s
} }
@@ -1484,13 +1507,11 @@ final class VoiceWakeTester {
} }
@MainActor @MainActor
private func handleResult( private func handleResult(matched: Bool,
matched: Bool, text: String,
text: String, isFinal: Bool,
isFinal: Bool, errorMessage: String?,
errorMessage: String?, onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void
) {
if matched, !text.isEmpty { if matched, !text.isEmpty {
stop() stop()
onUpdate(.detected(text)) onUpdate(.detected(text))
@@ -1520,7 +1541,7 @@ final class VoiceWakeTester {
return triggers.contains { lowered.contains($0.lowercased()) } return triggers.contains { lowered.contains($0.lowercased()) }
} }
nonisolated private static func ensurePermissions() async throws -> Bool { private nonisolated static func ensurePermissions() async throws -> Bool {
let speechStatus = SFSpeechRecognizer.authorizationStatus() let speechStatus = SFSpeechRecognizer.authorizationStatus()
if speechStatus == .notDetermined { if speechStatus == .notDetermined {
let granted = await withCheckedContinuation { continuation in let granted = await withCheckedContinuation { continuation in
@@ -1536,12 +1557,14 @@ final class VoiceWakeTester {
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
switch micStatus { switch micStatus {
case .authorized: return true case .authorized: return true
case .notDetermined: case .notDetermined:
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted) continuation.resume(returning: granted)
} }
} }
default: default:
return false return false
} }
@@ -1593,20 +1616,17 @@ struct GeneralSettings: View {
} }
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SettingsToggleRow( SettingsToggleRow(title: "Clawdis active",
title: "Clawdis active", subtitle: "Pause to stop Clawdis background helpers and notifications.",
subtitle: "Pause to stop Clawdis background helpers and notifications.", binding: activeBinding)
binding: activeBinding)
SettingsToggleRow( SettingsToggleRow(title: "Launch at login",
title: "Launch at login", subtitle: "Automatically start Clawdis after you sign in.",
subtitle: "Automatically start Clawdis after you sign in.", binding: $state.launchAtLogin)
binding: $state.launchAtLogin)
SettingsToggleRow( SettingsToggleRow(title: "Enable debug tools",
title: "Enable debug tools", subtitle: "Show the Debug tab with development utilities.",
subtitle: "Show the Debug tab with development utilities.", binding: $state.debugPaneEnabled)
binding: $state.debugPaneEnabled)
LabeledContent("Default sound") { LabeledContent("Default sound") {
Picker("Sound", selection: $state.defaultSound) { Picker("Sound", selection: $state.defaultSound) {
@@ -1638,10 +1658,8 @@ struct GeneralSettings: View {
} }
private var activeBinding: Binding<Bool> { private var activeBinding: Binding<Bool> {
Binding( Binding(get: { !state.isPaused },
get: { !state.isPaused }, set: { state.isPaused = !$0 })
set: { state.isPaused = !$0 }
)
} }
private var cliInstaller: some View { private var cliInstaller: some View {
@@ -1701,12 +1719,10 @@ struct VoiceWakeSettings: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
SettingsToggleRow( SettingsToggleRow(title: "Enable Voice Wake",
title: "Enable Voice Wake", subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands.",
subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands.", binding: $state.swabbleEnabled)
binding: $state.swabbleEnabled .disabled(!voiceWakeSupported)
)
.disabled(!voiceWakeSupported)
if !voiceWakeSupported { if !voiceWakeSupported {
Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill") Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill")
@@ -1767,16 +1783,16 @@ struct VoiceWakeSettings: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.task { await loadMicsIfNeeded() } .task { await loadMicsIfNeeded() }
.task { await loadLocalesIfNeeded() } .task { await loadLocalesIfNeeded() }
.task { await restartMeter() } .task { await restartMeter() }
.onChange(of: state.voiceWakeMicID) { _, _ in .onChange(of: state.voiceWakeMicID) { _, _ in
Task { await restartMeter() } Task { await restartMeter() }
} }
.onDisappear { .onDisappear {
Task { await meter.stop() } Task { await meter.stop() }
} }
@@ -1824,16 +1840,20 @@ struct VoiceWakeSettings: View {
switch testState { switch testState {
case .idle: case .idle:
AnyView(Image(systemName: "waveform").foregroundStyle(.secondary)) AnyView(Image(systemName: "waveform").foregroundStyle(.secondary))
case .requesting: case .requesting:
AnyView(ProgressView().controlSize(.small)) AnyView(ProgressView().controlSize(.small))
case .listening, .hearing: case .listening, .hearing:
AnyView( AnyView(
Image(systemName: "ear.and.waveform") Image(systemName: "ear.and.waveform")
.symbolEffect(.pulse) .symbolEffect(.pulse)
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
) )
case .detected: case .detected:
AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)) AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green))
case .failed: case .failed:
AnyView(Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)) AnyView(Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow))
} }
@@ -1842,17 +1862,22 @@ struct VoiceWakeSettings: View {
private var statusText: String { private var statusText: String {
switch testState { switch testState {
case .idle: case .idle:
return "Press start, say a trigger word, and wait for detection." "Press start, say a trigger word, and wait for detection."
case .requesting: case .requesting:
return "Requesting mic & speech permission…" "Requesting mic & speech permission…"
case .listening: case .listening:
return "Listening… say your trigger word." "Listening… say your trigger word."
case let .hearing(text): case let .hearing(text):
return "Heard: \(text)" "Heard: \(text)"
case .detected: case .detected:
return "Voice wake detected!" "Voice wake detected!"
case let .failed(reason): case let .failed(reason):
return reason reason
} }
} }
@@ -1866,16 +1891,14 @@ struct VoiceWakeSettings: View {
} }
private func binding(for index: Int) -> Binding<String> { private func binding(for index: Int) -> Binding<String> {
Binding( Binding(get: {
get: { guard state.swabbleTriggerWords.indices.contains(index) else { return "" }
guard state.swabbleTriggerWords.indices.contains(index) else { return "" } return state.swabbleTriggerWords[index]
return state.swabbleTriggerWords[index] },
}, set: { newValue in
set: { newValue in guard state.swabbleTriggerWords.indices.contains(index) else { return }
guard state.swabbleTriggerWords.indices.contains(index) else { return } state.swabbleTriggerWords[index] = newValue
state.swabbleTriggerWords[index] = newValue })
}
)
} }
private func toggleTest() { private func toggleTest() {
@@ -1895,20 +1918,18 @@ struct VoiceWakeSettings: View {
testState = .requesting testState = .requesting
Task { @MainActor in Task { @MainActor in
do { do {
try await tester.start( try await tester.start(triggers: triggers,
triggers: triggers, micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, localeID: state.voiceWakeLocaleID,
localeID: state.voiceWakeLocaleID, onUpdate: { newState in
onUpdate: { newState in DispatchQueue.main.async { [self] in
DispatchQueue.main.async { [self] in testState = newState
testState = newState if case .detected = newState { isTesting = false }
if case .detected = newState { isTesting = false } if case .failed = newState { isTesting = false }
if case .failed = newState { isTesting = false } }
} })
}
)
// timeout after 10s // timeout after 10s
try await Task.sleep(nanoseconds: 10 * 1_000_000_000) try await Task.sleep(nanoseconds: 10 * 1000000000)
if isTesting { if isTesting {
tester.stop() tester.stop()
testState = .failed("Timeout: no trigger heard") testState = .failed("Timeout: no trigger heard")
@@ -1953,7 +1974,7 @@ struct VoiceWakeSettings: View {
Picker("Language", selection: $state.voiceWakeLocaleID) { Picker("Language", selection: $state.voiceWakeLocaleID) {
let current = Locale(identifier: Locale.current.identifier) let current = Locale(identifier: Locale.current.identifier)
Text("\(friendlyName(for: current)) (System)").tag(Locale.current.identifier) Text("\(friendlyName(for: current)) (System)").tag(Locale.current.identifier)
ForEach(availableLocales.map { $0.identifier }, id: \.self) { id in ForEach(availableLocales.map(\.identifier), id: \.self) { id in
if id != Locale.current.identifier { if id != Locale.current.identifier {
Text(friendlyName(for: Locale(identifier: id))).tag(id) Text(friendlyName(for: Locale(identifier: id))).tag(id)
} }
@@ -1969,14 +1990,12 @@ struct VoiceWakeSettings: View {
.font(.footnote.weight(.semibold)) .font(.footnote.weight(.semibold))
ForEach(Array(state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in ForEach(Array(state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
HStack(spacing: 8) { HStack(spacing: 8) {
Picker("Extra \(idx + 1)", selection: Binding( Picker("Extra \(idx + 1)", selection: Binding(get: { localeID },
get: { localeID }, set: { newValue in
set: { newValue in guard state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
guard state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } state.voiceWakeAdditionalLocaleIDs[idx] = newValue
state.voiceWakeAdditionalLocaleIDs[idx] = newValue })) {
} ForEach(availableLocales.map(\.identifier), id: \.self) { id in
)) {
ForEach(availableLocales.map { $0.identifier }, id: \.self) { id in
Text(friendlyName(for: Locale(identifier: id))).tag(id) Text(friendlyName(for: Locale(identifier: id))).tag(id)
} }
} }
@@ -2027,11 +2046,9 @@ struct VoiceWakeSettings: View {
private func loadMicsIfNeeded() async { private func loadMicsIfNeeded() async {
guard availableMics.isEmpty, !loadingMics else { return } guard availableMics.isEmpty, !loadingMics else { return }
loadingMics = true loadingMics = true
let discovery = AVCaptureDevice.DiscoverySession( let discovery = AVCaptureDevice.DiscoverySession(deviceTypes: [.external, .microphone],
deviceTypes: [.external, .microphone], mediaType: .audio,
mediaType: .audio, position: .unspecified)
position: .unspecified
)
availableMics = discovery.devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } availableMics = discovery.devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
loadingMics = false loadingMics = false
} }
@@ -2305,12 +2322,16 @@ struct PermissionStatusList: View {
case .notifications: case .notifications:
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
case .accessibility: case .accessibility:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
case .screenRecording: case .screenRecording:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording")
case .microphone: case .microphone:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
case .speechRecognition: case .speechRecognition:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition") openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition")
} }
@@ -2400,31 +2421,31 @@ private struct PermissionRow: View {
private var title: String { private var title: String {
switch capability { switch capability {
case .notifications: return "Notifications" case .notifications: "Notifications"
case .accessibility: return "Accessibility" case .accessibility: "Accessibility"
case .screenRecording: return "Screen Recording" case .screenRecording: "Screen Recording"
case .microphone: return "Microphone" case .microphone: "Microphone"
case .speechRecognition: return "Speech Recognition" case .speechRecognition: "Speech Recognition"
} }
} }
private var subtitle: String { private var subtitle: String {
switch capability { switch capability {
case .notifications: return "Show desktop alerts for agent activity" case .notifications: "Show desktop alerts for agent activity"
case .accessibility: return "Control UI elements when an action requires it" case .accessibility: "Control UI elements when an action requires it"
case .screenRecording: return "Capture the screen for context or screenshots" case .screenRecording: "Capture the screen for context or screenshots"
case .microphone: return "Allow Voice Wake and audio capture" case .microphone: "Allow Voice Wake and audio capture"
case .speechRecognition: return "Transcribe Voice Wake trigger phrases on-device" case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
} }
} }
private var icon: String { private var icon: String {
switch capability { switch capability {
case .notifications: return "bell" case .notifications: "bell"
case .accessibility: return "hand.raised" case .accessibility: "hand.raised"
case .screenRecording: return "display" case .screenRecording: "display"
case .microphone: return "mic" case .microphone: "mic"
case .speechRecognition: return "waveform" case .speechRecognition: "waveform"
} }
} }
} }
@@ -2435,7 +2456,7 @@ struct MicLevelBar: View {
var body: some View { var body: some View {
HStack(spacing: 3) { HStack(spacing: 3) {
ForEach(0..<segments, id: \.self) { idx in ForEach(0 ..< segments, id: \.self) { idx in
let fill = level * Double(segments) > Double(idx) let fill = level * Double(segments) > Double(idx)
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 2)
.fill(fill ? segmentColor(for: idx) : Color.gray.opacity(0.35)) .fill(fill ? segmentColor(for: idx) : Color.gray.opacity(0.35))
@@ -2519,10 +2540,8 @@ struct OnboardingView: View {
readyPage().frame(width: pageWidth) readyPage().frame(width: pageWidth)
} }
.offset(x: CGFloat(-currentPage) * pageWidth) .offset(x: CGFloat(-currentPage) * pageWidth)
.animation( .animation(.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), value: currentPage)
value: currentPage
)
.frame(width: pageWidth, height: contentHeight, alignment: .top) .frame(width: pageWidth, height: contentHeight, alignment: .top)
.clipped() .clipped()
} }
@@ -2560,21 +2579,15 @@ struct OnboardingView: View {
Text("What Clawdis handles") Text("What Clawdis handles")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
onboardingCard { onboardingCard {
featureRow( featureRow(title: "Owns the TCC prompts",
title: "Owns the TCC prompts", subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.",
subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.", systemImage: "lock.shield")
systemImage: "lock.shield" featureRow(title: "Native notifications",
) subtitle: "Shows desktop toasts for agent events with your preferred sound.",
featureRow( systemImage: "bell.and.waveform")
title: "Native notifications", featureRow(title: "Privileged helpers",
subtitle: "Shows desktop toasts for agent events with your preferred sound.", subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.",
systemImage: "bell.and.waveform" systemImage: "terminal")
)
featureRow(
title: "Privileged helpers",
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.",
systemImage: "terminal"
)
} }
} }
} }
@@ -2683,16 +2696,12 @@ struct OnboardingView: View {
Text("All set") Text("All set")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
onboardingCard { onboardingCard {
featureRow( featureRow(title: "Run the dashboard",
title: "Run the dashboard", subtitle: "Use the CLI helper from your scripts, and reopen onboarding from Settings if you add a new user.",
subtitle: "Use the CLI helper from your scripts, and reopen onboarding from Settings if you add a new user.", systemImage: "checkmark.seal")
systemImage: "checkmark.seal" featureRow(title: "Test a notification",
) subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
featureRow( systemImage: "bell.badge")
title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
systemImage: "bell.badge"
)
} }
Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.") Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.")
.font(.footnote) .font(.footnote)
@@ -2728,7 +2737,7 @@ struct OnboardingView: View {
Spacer() Spacer()
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(0..<pageCount, id: \.self) { index in ForEach(0 ..< pageCount, id: \.self) { index in
Button { Button {
withAnimation { currentPage = index } withAnimation { currentPage = index }
} label: { } label: {
@@ -2858,14 +2867,12 @@ private struct GlowingClawdisIcon: View {
ZStack { ZStack {
Circle() Circle()
.fill( .fill(
LinearGradient( LinearGradient(colors: [
colors: [ Color.accentColor.opacity(glowIntensity),
Color.accentColor.opacity(glowIntensity), Color.blue.opacity(glowIntensity * 0.6)
Color.blue.opacity(glowIntensity * 0.6) ],
], startPoint: .topLeading,
startPoint: .topLeading, endPoint: .bottomTrailing)
endPoint: .bottomTrailing
)
) )
.blur(radius: 22) .blur(radius: 22)
.scaleEffect(breathe ? 1.12 : 0.95) .scaleEffect(breathe ? 1.12 : 0.95)

View File

@@ -14,16 +14,15 @@ struct ClawdisCLI {
do { do {
let request = try parseCommandLine() let request = try parseCommandLine()
let response = try await send(request: request) let response = try await send(request: request)
let payloadString: String? let payloadString: String? = if let payload = response.payload, let text = String(data: payload, encoding: .utf8) {
if let payload = response.payload, let text = String(data: payload, encoding: .utf8) { text
payloadString = text
} else { } else {
payloadString = nil nil
} }
let output: [String: Any] = [ let output: [String: Any] = [
"ok": response.ok, "ok": response.ok,
"message": response.message ?? "", "message": response.message ?? "",
"payload": payloadString ?? "", "payload": payloadString ?? ""
] ]
let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted]) let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted])
FileHandle.standardOutput.write(json) FileHandle.standardOutput.write(json)
@@ -56,6 +55,7 @@ struct ClawdisCLI {
} }
guard let t = title, let b = body else { throw CLIError.help } guard let t = title, let b = body else { throw CLIError.help }
return .notify(title: t, body: b, sound: sound) return .notify(title: t, body: b, sound: sound)
case "ensure-permissions": case "ensure-permissions":
var caps: [Capability] = [] var caps: [Capability] = []
var interactive = false var interactive = false
@@ -70,6 +70,7 @@ struct ClawdisCLI {
} }
if caps.isEmpty { caps = Capability.allCases } if caps.isEmpty { caps = Capability.allCases }
return .ensurePermissions(caps, interactive: interactive) return .ensurePermissions(caps, interactive: interactive)
case "screenshot": case "screenshot":
var displayID: UInt32? var displayID: UInt32?
var windowID: UInt32? var windowID: UInt32?
@@ -82,6 +83,7 @@ struct ClawdisCLI {
} }
} }
return .screenshot(displayID: displayID, windowID: windowID, format: "png") return .screenshot(displayID: displayID, windowID: windowID, format: "png")
case "run": case "run":
var cwd: String? var cwd: String?
var env: [String: String] = [:] var env: [String: String] = [:]
@@ -92,19 +94,23 @@ struct ClawdisCLI {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--cwd": cwd = args.popFirst() case "--cwd": cwd = args.popFirst()
case "--env": case "--env":
if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") { if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") {
let k = String(pair[..<eq]); let v = String(pair[pair.index(after: eq)...]); env[k] = v let k = String(pair[..<eq]); let v = String(pair[pair.index(after: eq)...]); env[k] = v
} }
case "--timeout": if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl } case "--timeout": if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
case "--needs-screen-recording": needsSR = true case "--needs-screen-recording": needsSR = true
default: default:
cmd.append(arg) cmd.append(arg)
} }
} }
return .runShell(command: cmd, cwd: cwd, env: env.isEmpty ? nil : env, timeoutSec: timeout, needsScreenRecording: needsSR) return .runShell(command: cmd, cwd: cwd, env: env.isEmpty ? nil : env, timeoutSec: timeout, needsScreenRecording: needsSR)
case "status": case "status":
return .status return .status
default: default:
throw CLIError.help throw CLIError.help
} }
@@ -131,7 +137,7 @@ struct ClawdisCLI {
enum CLIError: Error { case help } enum CLIError: Error { case help }
extension Array where Element == String { extension [String] {
mutating func popFirst() -> String? { mutating func popFirst() -> String? {
guard let first else { return nil } guard let first else { return nil }
self = Array(self.dropFirst()) self = Array(self.dropFirst())

View File

@@ -62,15 +62,18 @@ extension Request: Codable {
try container.encode(title, forKey: .title) try container.encode(title, forKey: .title)
try container.encode(body, forKey: .body) try container.encode(body, forKey: .body)
try container.encodeIfPresent(sound, forKey: .sound) try container.encodeIfPresent(sound, forKey: .sound)
case let .ensurePermissions(caps, interactive): case let .ensurePermissions(caps, interactive):
try container.encode(Kind.ensurePermissions, forKey: .type) try container.encode(Kind.ensurePermissions, forKey: .type)
try container.encode(caps, forKey: .caps) try container.encode(caps, forKey: .caps)
try container.encode(interactive, forKey: .interactive) try container.encode(interactive, forKey: .interactive)
case let .screenshot(displayID, windowID, format): case let .screenshot(displayID, windowID, format):
try container.encode(Kind.screenshot, forKey: .type) try container.encode(Kind.screenshot, forKey: .type)
try container.encodeIfPresent(displayID, forKey: .displayID) try container.encodeIfPresent(displayID, forKey: .displayID)
try container.encodeIfPresent(windowID, forKey: .windowID) try container.encodeIfPresent(windowID, forKey: .windowID)
try container.encode(format, forKey: .format) try container.encode(format, forKey: .format)
case let .runShell(command, cwd, env, timeoutSec, needsSR): case let .runShell(command, cwd, env, timeoutSec, needsSR):
try container.encode(Kind.runShell, forKey: .type) try container.encode(Kind.runShell, forKey: .type)
try container.encode(command, forKey: .command) try container.encode(command, forKey: .command)
@@ -78,6 +81,7 @@ extension Request: Codable {
try container.encodeIfPresent(env, forKey: .env) try container.encodeIfPresent(env, forKey: .env)
try container.encodeIfPresent(timeoutSec, forKey: .timeoutSec) try container.encodeIfPresent(timeoutSec, forKey: .timeoutSec)
try container.encode(needsSR, forKey: .needsScreenRecording) try container.encode(needsSR, forKey: .needsScreenRecording)
case .status: case .status:
try container.encode(Kind.status, forKey: .type) try container.encode(Kind.status, forKey: .type)
} }
@@ -92,15 +96,18 @@ extension Request: Codable {
let body = try container.decode(String.self, forKey: .body) let body = try container.decode(String.self, forKey: .body)
let sound = try container.decodeIfPresent(String.self, forKey: .sound) let sound = try container.decodeIfPresent(String.self, forKey: .sound)
self = .notify(title: title, body: body, sound: sound) self = .notify(title: title, body: body, sound: sound)
case .ensurePermissions: case .ensurePermissions:
let caps = try container.decode([Capability].self, forKey: .caps) let caps = try container.decode([Capability].self, forKey: .caps)
let interactive = try container.decode(Bool.self, forKey: .interactive) let interactive = try container.decode(Bool.self, forKey: .interactive)
self = .ensurePermissions(caps, interactive: interactive) self = .ensurePermissions(caps, interactive: interactive)
case .screenshot: case .screenshot:
let displayID = try container.decodeIfPresent(UInt32.self, forKey: .displayID) let displayID = try container.decodeIfPresent(UInt32.self, forKey: .displayID)
let windowID = try container.decodeIfPresent(UInt32.self, forKey: .windowID) let windowID = try container.decodeIfPresent(UInt32.self, forKey: .windowID)
let format = try container.decode(String.self, forKey: .format) let format = try container.decode(String.self, forKey: .format)
self = .screenshot(displayID: displayID, windowID: windowID, format: format) self = .screenshot(displayID: displayID, windowID: windowID, format: format)
case .runShell: case .runShell:
let command = try container.decode([String].self, forKey: .command) let command = try container.decode([String].self, forKey: .command)
let cwd = try container.decodeIfPresent(String.self, forKey: .cwd) let cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
@@ -108,6 +115,7 @@ extension Request: Codable {
let timeout = try container.decodeIfPresent(Double.self, forKey: .timeoutSec) let timeout = try container.decodeIfPresent(Double.self, forKey: .timeoutSec)
let needsSR = try container.decode(Bool.self, forKey: .needsScreenRecording) let needsSR = try container.decode(Bool.self, forKey: .needsScreenRecording)
self = .runShell(command: command, cwd: cwd, env: env, timeoutSec: timeout, needsScreenRecording: needsSR) self = .runShell(command: command, cwd: cwd, env: env, timeoutSec: timeout, needsScreenRecording: needsSR)
case .status: case .status:
self = .status self = .status
} }

View File

@@ -36,7 +36,8 @@ const TOKENS_PAD = 20;
const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0); const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0);
const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
const truncateKey = (key: string) => { const truncateKey = (key: string) => {
if (key.length <= KEY_PAD) return key; if (key.length <= KEY_PAD) return key;
@@ -60,7 +61,9 @@ const formatTokensCell = (
if (!total) return "-".padEnd(TOKENS_PAD); if (!total) return "-".padEnd(TOKENS_PAD);
const totalLabel = formatKTokens(total); const totalLabel = formatKTokens(total);
const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?"; const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?";
const pct = contextTokens ? Math.min(999, Math.round((total / contextTokens) * 100)) : null; const pct = contextTokens
? Math.min(999, Math.round((total / contextTokens) * 100))
: null;
const label = `${totalLabel}/${ctxLabel} (${pct ?? "?"}%)`; const label = `${totalLabel}/${ctxLabel} (${pct ?? "?"}%)`;
const padded = label.padEnd(TOKENS_PAD); const padded = label.padEnd(TOKENS_PAD);
return colorByPct(padded, pct, rich); return colorByPct(padded, pct, rich);