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,16 +673,14 @@ 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( ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX + bodyW - earW * 0.45 - earWiggle,
x: bodyX + bodyW - earW * 0.45 - 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),
@@ -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,8 +957,7 @@ 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,
@@ -938,14 +966,11 @@ private enum SessionLoader {
verboseLevel: entry.verboseLevel, verboseLevel: entry.verboseLevel,
systemSent: entry.systemSent ?? false, systemSent: entry.systemSent ?? false,
abortedLastRun: entry.abortedLastRun ?? false, abortedLastRun: entry.abortedLastRun ?? false,
tokens: SessionTokenStats( tokens: SessionTokenStats(input: input,
input: input,
output: output, output: output,
total: total, total: total,
contextTokens: context contextTokens: context),
), model: model)
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,18 +1616,15 @@ 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)
@@ -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,11 +1719,9 @@ 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 {
@@ -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,8 +1918,7 @@ 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
@@ -1905,10 +1927,9 @@ struct VoiceWakeSettings: View {
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",
featureRow(
title: "Native notifications",
subtitle: "Shows desktop toasts for agent events with your preferred sound.", subtitle: "Shows desktop toasts for agent events with your preferred sound.",
systemImage: "bell.and.waveform" systemImage: "bell.and.waveform")
) featureRow(title: "Privileged helpers",
featureRow(
title: "Privileged helpers",
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.", subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.",
systemImage: "terminal" 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",
featureRow(
title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.", subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
systemImage: "bell.badge" 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);