mac: add sessions tab to settings

This commit is contained in:
Peter Steinberger
2025-12-06 02:06:00 +01:00
parent 67fe5ed699
commit cb35e3a766

View File

@@ -7,6 +7,8 @@ import class Foundation.Bundle
import OSLog import OSLog
import CoreGraphics import CoreGraphics
@preconcurrency import ScreenCaptureKit @preconcurrency import ScreenCaptureKit
import AVFoundation
import Speech
import VideoToolbox import VideoToolbox
import ServiceManagement import ServiceManagement
import SwiftUI import SwiftUI
@@ -18,6 +20,9 @@ private let launchdLabel = "com.steipete.clawdis"
private let onboardingVersionKey = "clawdis.onboardingVersion" private let onboardingVersionKey = "clawdis.onboardingVersion"
private let currentOnboardingVersion = 2 private let currentOnboardingVersion = 2
private let pauseDefaultsKey = "clawdis.pauseEnabled" private let pauseDefaultsKey = "clawdis.pauseEnabled"
private let swabbleEnabledKey = "clawdis.swabbleEnabled"
private let swabbleTriggersKey = "clawdis.swabbleTriggers"
private let defaultVoiceWakeTriggers = ["clawd", "claude"]
// MARK: - App model // MARK: - App model
@@ -38,6 +43,19 @@ final class AppState: ObservableObject {
@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 {
didSet { UserDefaults.standard.set(swabbleEnabled, forKey: swabbleEnabledKey) }
}
@Published var swabbleTriggerWords: [String] {
didSet {
let cleaned = swabbleTriggerWords.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
UserDefaults.standard.set(cleaned, forKey: swabbleTriggersKey)
if cleaned.count != swabbleTriggerWords.count {
swabbleTriggerWords = cleaned
}
}
}
init() { init() {
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
@@ -45,6 +63,8 @@ final class AppState: ObservableObject {
self.launchAtLogin = SMAppService.mainApp.status == .enabled self.launchAtLogin = SMAppService.mainApp.status == .enabled
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled") self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled")
self.swabbleEnabled = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleTriggerWords = UserDefaults.standard.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
} }
} }
@@ -392,6 +412,7 @@ private struct MenuContent: View {
var body: some View { var body: some View {
Toggle(isOn: activeBinding) { Text("Clawdis Active") } Toggle(isOn: activeBinding) { Text("Clawdis Active") }
Toggle(isOn: $state.swabbleEnabled) { Text("Voice Wake") }
Button("Settings…") { open(tab: .general) } Button("Settings…") { open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command]) .keyboardShortcut(",", modifiers: [.command])
Button("About Clawdis") { open(tab: .about) } Button("About Clawdis") { open(tab: .about) }
@@ -662,6 +683,450 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
// MARK: - Settings UI // MARK: - Settings UI
private struct SessionEntryRecord: Decodable {
let sessionId: String?
let updatedAt: Double?
let systemSent: Bool?
let abortedLastRun: Bool?
let thinkingLevel: String?
let verboseLevel: String?
let inputTokens: Int?
let outputTokens: Int?
let totalTokens: Int?
let model: String?
let contextTokens: Int?
}
private struct SessionTokenStats {
let input: Int
let output: Int
let total: Int
let contextTokens: Int
var percentUsed: Int? {
guard contextTokens > 0, total > 0 else { return nil }
return min(100, Int(round((Double(total) / Double(contextTokens)) * 100)))
}
var summary: String {
let parts = ["in \(input)", "out \(output)", "total \(total)"]
var text = parts.joined(separator: " | ")
if let percentUsed {
text += " (\(percentUsed)% of \(contextTokens))"
}
return text
}
}
private struct SessionRow: Identifiable {
let id: String
let key: String
let kind: SessionKind
let updatedAt: Date?
let sessionId: String?
let thinkingLevel: String?
let verboseLevel: String?
let systemSent: Bool
let abortedLastRun: Bool
let tokens: SessionTokenStats
let model: String?
var ageText: String { relativeAge(from: updatedAt) }
var flagLabels: [String] {
var flags: [String] = []
if let thinkingLevel { flags.append("think \(thinkingLevel)") }
if let verboseLevel { flags.append("verbose \(verboseLevel)") }
if systemSent { flags.append("system sent") }
if abortedLastRun { flags.append("aborted") }
return flags
}
}
private enum SessionKind {
case direct, group, global, unknown
static func from(key: String) -> SessionKind {
if key == "global" { return .global }
if key.hasPrefix("group:") { return .group }
if key == "unknown" { return .unknown }
return .direct
}
var label: String {
switch self {
case .direct: return "Direct"
case .group: return "Group"
case .global: return "Global"
case .unknown: return "Unknown"
}
}
var tint: Color {
switch self {
case .direct: return .accentColor
case .group: return .orange
case .global: return .purple
case .unknown: return .gray
}
}
}
private struct SessionDefaults {
let model: String
let contextTokens: Int
}
private struct SessionConfigHints {
let storePath: String?
let model: String?
let contextTokens: Int?
}
private enum SessionLoadError: LocalizedError {
case missingStore(String)
case decodeFailed(String)
var errorDescription: String? {
switch self {
case let .missingStore(path):
return "No session store found at \(path) yet. Send or receive a message to create it."
case let .decodeFailed(reason):
return "Could not read the session store: \(reason)"
}
}
}
private enum SessionLoader {
static let fallbackModel = "claude-opus-4-5"
static let fallbackContextTokens = 200_000
static let defaultStorePath = standardize(
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis/sessions/sessions.json").path
)
private static let legacyStorePaths: [String] = [
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.json").path),
]
static func configHints() -> SessionConfigHints {
let configURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis/clawdis.json")
guard let data = try? Data(contentsOf: configURL) else {
return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil)
}
guard let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil)
}
let inbound = parsed["inbound"] as? [String: Any]
let reply = inbound?["reply"] as? [String: Any]
let session = reply?["session"] as? [String: Any]
let agent = reply?["agent"] as? [String: Any]
let store = session?["store"] as? String
let model = agent?["model"] as? String
let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue
return SessionConfigHints(
storePath: store.map { standardize($0) },
model: model,
contextTokens: contextTokens
)
}
static func resolveStorePath(override: String?) -> String {
let preferred = standardize(override ?? defaultStorePath)
let candidates = [preferred] + legacyStorePaths
if let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) {
return existing
}
return preferred
}
static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] {
try await Task.detached(priority: .utility) {
guard FileManager.default.fileExists(atPath: path) else {
throw SessionLoadError.missingStore(path)
}
let data = try Data(contentsOf: URL(fileURLWithPath: path))
let decoded: [String: SessionEntryRecord]
do {
decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
} catch {
throw SessionLoadError.decodeFailed(error.localizedDescription)
}
return decoded.map { key, entry in
let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) }
let input = entry.inputTokens ?? 0
let output = entry.outputTokens ?? 0
let total = entry.totalTokens ?? input + output
let context = entry.contextTokens ?? defaults.contextTokens
let model = entry.model ?? defaults.model
return SessionRow(
id: key,
key: key,
kind: SessionKind.from(key: key),
updatedAt: updated,
sessionId: entry.sessionId,
thinkingLevel: entry.thinkingLevel,
verboseLevel: entry.verboseLevel,
systemSent: entry.systemSent ?? false,
abortedLastRun: entry.abortedLastRun ?? false,
tokens: SessionTokenStats(
input: input,
output: output,
total: total,
contextTokens: context
),
model: model
)
}
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
}.value
}
private static func standardize(_ path: String) -> String {
(path as NSString).expandingTildeInPath.replacingOccurrences(of: "//", with: "/")
}
}
private func relativeAge(from date: Date?) -> String {
guard let date else { return "unknown" }
let delta = Date().timeIntervalSince(date)
if delta < 60 { return "just now" }
let minutes = Int(round(delta / 60))
if minutes < 60 { return "\(minutes)m ago" }
let hours = Int(round(Double(minutes) / 60))
if hours < 48 { return "\(hours)h ago" }
let days = Int(round(Double(hours) / 24))
return "\(days)d ago"
}
@MainActor
struct SessionsSettings: View {
@State private var rows: [SessionRow] = []
@State private var storePath: String = SessionLoader.defaultStorePath
@State private var lastLoaded: Date?
@State private var errorMessage: String?
@State private var loading = false
@State private var hasLoaded = false
var body: some View {
VStack(alignment: .leading, spacing: 14) {
header
storeMetadata
Divider().padding(.vertical, 4)
content
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.task {
guard !hasLoaded else { return }
hasLoaded = true
await refresh()
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Sessions")
.font(.title3.weight(.semibold))
Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var storeMetadata: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text("Session store")
.font(.callout.weight(.semibold))
if let lastLoaded {
Text("Updated \(relativeAge(from: lastLoaded))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(storePath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
HStack(spacing: 10) {
Button {
Task { await refresh() }
} label: {
Label(loading ? "Refreshing..." : "Refresh", systemImage: "arrow.clockwise")
.labelStyle(.titleAndIcon)
}
.disabled(loading)
Button {
revealStore()
} label: {
Label("Reveal", systemImage: "folder")
.labelStyle(.titleAndIcon)
}
.disabled(!FileManager.default.fileExists(atPath: storePath))
if loading {
ProgressView().controlSize(.small)
}
}
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
}
private var content: some View {
Group {
if rows.isEmpty && errorMessage == nil {
Text("No sessions yet. They appear after the first inbound message or heartbeat.")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 6)
} else {
ScrollView {
LazyVStack(spacing: 10) {
ForEach(rows) { row in
SessionRowView(row: row)
}
}
}
}
}
}
private func refresh() async {
guard !loading else { return }
loading = true
errorMessage = nil
let hints = SessionLoader.configHints()
let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults(
model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens
)
do {
let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults)
rows = newRows
storePath = resolvedStore
lastLoaded = Date()
} catch {
rows = []
storePath = resolvedStore
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
loading = false
}
private func revealStore() {
let url = URL(fileURLWithPath: storePath)
if FileManager.default.fileExists(atPath: storePath) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
}
}
}
private struct SessionRowView: View {
let row: SessionRow
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(row.key)
.font(.body.weight(.semibold))
SessionKindBadge(kind: row.kind)
Spacer()
Text(row.ageText)
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Label(row.tokens.summary, systemImage: "chart.bar.doc.horizontal")
.labelStyle(.titleAndIcon)
.foregroundStyle(.secondary)
if let model = row.model {
Label(model, systemImage: "brain.head.profile")
.labelStyle(.titleAndIcon)
.foregroundStyle(.secondary)
}
if let sessionId = row.sessionId {
Label(sessionId, systemImage: "number")
.labelStyle(.titleAndIcon)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
.font(.caption)
.lineLimit(1)
if !row.flagLabels.isEmpty {
HStack(spacing: 6) {
ForEach(row.flagLabels, id: \.self) { flag in
Text(flag)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.12))
.clipShape(Capsule())
}
}
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1)
)
}
}
private struct SessionKindBadge: View {
let kind: SessionKind
var body: some View {
Text(kind.label)
.font(.caption2.weight(.bold))
.padding(.horizontal, 7)
.padding(.vertical, 4)
.foregroundStyle(kind.tint)
.background(kind.tint.opacity(0.15))
.clipShape(Capsule())
}
}
struct SettingsRootView: View { struct SettingsRootView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@State private var permStatus: [Capability: Bool] = [:] @State private var permStatus: [Capability: Bool] = [:]
@@ -674,6 +1139,14 @@ struct SettingsRootView: View {
.tabItem { Label("General", systemImage: "gearshape") } .tabItem { Label("General", systemImage: "gearshape") }
.tag(SettingsTab.general) .tag(SettingsTab.general)
SessionsSettings()
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
.tag(SettingsTab.sessions)
VoiceWakeSettings(state: state)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
.tag(SettingsTab.voiceWake)
PermissionsSettings(status: permStatus, refresh: refreshPerms, showOnboarding: { OnboardingController.shared.show() }) PermissionsSettings(status: permStatus, refresh: refreshPerms, showOnboarding: { OnboardingController.shared.show() })
.tabItem { Label("Permissions", systemImage: "lock.shield") } .tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions) .tag(SettingsTab.permissions)
@@ -727,12 +1200,14 @@ struct SettingsRootView: View {
} }
enum SettingsTab: CaseIterable { enum SettingsTab: CaseIterable {
case general, permissions, debug, about case general, sessions, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 520 static let windowWidth: CGFloat = 520
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: return "General"
case .sessions: return "Sessions"
case .voiceWake: return "Voice Wake"
case .permissions: return "Permissions" case .permissions: return "Permissions"
case .debug: return "Debug" case .debug: return "Debug"
case .about: return "About" case .about: return "About"
@@ -758,6 +1233,110 @@ extension Notification.Name {
static let clawdisSelectSettingsTab = Notification.Name("clawdisSelectSettingsTab") static let clawdisSelectSettingsTab = Notification.Name("clawdisSelectSettingsTab")
} }
enum VoiceWakeTestState: Equatable {
case idle
case requesting
case listening
case detected(String)
case failed(String)
}
@MainActor
final class VoiceWakeTester {
private let recognizer: SFSpeechRecognizer?
private let audioEngine = AVAudioEngine()
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
init(locale: Locale = .current) {
self.recognizer = SFSpeechRecognizer(locale: locale)
}
func start(triggers: [String], onUpdate: @MainActor @escaping @Sendable (VoiceWakeTestState) -> Void) async throws {
guard recognitionTask == nil else { return }
guard let recognizer, recognizer.isAvailable else {
throw NSError(domain: "VoiceWakeTester", code: 1, userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"])
}
let granted = try await Self.ensurePermissions()
guard granted else {
throw NSError(domain: "VoiceWakeTester", code: 2, userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"])
}
recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
recognitionRequest?.shouldReportPartialResults = true
let inputNode = audioEngine.inputNode
let format = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self] buffer, _ in
guard let self else { return }
Task { @MainActor in self.recognitionRequest?.append(buffer) }
}
audioEngine.prepare()
try audioEngine.start()
onUpdate(.listening)
guard let request = recognitionRequest else { return }
recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self else { return }
Task { @MainActor in
if let result {
let text = result.bestTranscription.formattedString
if Self.matches(text: text, triggers: triggers) {
self.stop()
onUpdate(.detected(text))
return
}
}
if let error {
self.stop()
onUpdate(.failed(error.localizedDescription))
}
}
}
}
func stop() {
audioEngine.stop()
recognitionRequest?.endAudio()
recognitionTask?.cancel()
recognitionTask = nil
recognitionRequest = nil
audioEngine.inputNode.removeTap(onBus: 0)
}
private static func matches(text: String, triggers: [String]) -> Bool {
let lowered = text.lowercased()
return triggers.contains { lowered.contains($0.lowercased()) }
}
private static func ensurePermissions() async throws -> Bool {
let speechStatus = SFSpeechRecognizer.authorizationStatus()
if speechStatus == .notDetermined {
let granted = await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status == .authorized)
}
}
guard granted else { return false }
} else if speechStatus != .authorized {
return false
}
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
switch micStatus {
case .authorized: return true
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .audio)
default:
return false
}
}
}
@MainActor @MainActor
struct SettingsToggleRow: View { struct SettingsToggleRow: View {
let title: String let title: String
@@ -886,6 +1465,215 @@ struct GeneralSettings: View {
} }
} }
struct VoiceWakeSettings: View {
@ObservedObject var state: AppState
@State private var testState: VoiceWakeTestState = .idle
@State private var tester = VoiceWakeTester()
@State private var isTesting = false
private struct IndexedWord: Identifiable {
let id: Int
let value: String
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
SettingsToggleRow(
title: "Enable Voice Wake",
subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands.",
binding: $state.swabbleEnabled
)
testCard
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Trigger words")
.font(.callout.weight(.semibold))
Spacer()
Button {
addWord()
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(state.swabbleTriggerWords.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { state.swabbleTriggerWords = defaultVoiceWakeTriggers }
}
Table(indexedWords) {
TableColumn("Word") { row in
TextField("Wake word", text: binding(for: row.id))
.textFieldStyle(.roundedBorder)
}
TableColumn("") { row in
Button {
removeWord(at: row.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.frame(minHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
)
Text("Clawdis reacts when any trigger appears in a transcription. Keep them short to avoid false positives.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
}
private var indexedWords: [IndexedWord] {
state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
}
private var testCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Test Voice Wake")
.font(.callout.weight(.semibold))
Spacer()
Button(action: toggleTest) {
Label(isTesting ? "Stop" : "Start test", systemImage: isTesting ? "stop.circle.fill" : "play.circle")
}
.buttonStyle(.borderedProminent)
.tint(isTesting ? .red : .accentColor)
}
HStack(spacing: 8) {
statusIcon
VStack(alignment: .leading, spacing: 4) {
Text(statusText)
.font(.subheadline)
if case let .detected(text) = testState {
Text("Heard: \(text)")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
Spacer()
}
.padding(10)
.background(.quaternary.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.vertical, 2)
}
private var statusIcon: some View {
switch testState {
case .idle:
AnyView(Image(systemName: "waveform").foregroundStyle(.secondary))
case .requesting:
AnyView(ProgressView().controlSize(.small))
case .listening:
AnyView(
Image(systemName: "ear.and.waveform")
.symbolEffect(.pulse)
.foregroundStyle(Color.accentColor)
)
case .detected:
AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green))
case .failed:
AnyView(Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow))
}
}
private var statusText: String {
switch testState {
case .idle:
return "Press start, say a trigger word, and wait for detection."
case .requesting:
return "Requesting mic & speech permission…"
case .listening:
return "Listening… say your trigger word."
case .detected:
return "Voice wake detected!"
case let .failed(reason):
return reason
}
}
private func addWord() {
state.swabbleTriggerWords.append("")
}
private func removeWord(at index: Int) {
guard state.swabbleTriggerWords.indices.contains(index) else { return }
state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard state.swabbleTriggerWords.indices.contains(index) else { return "" }
return state.swabbleTriggerWords[index]
},
set: { newValue in
guard state.swabbleTriggerWords.indices.contains(index) else { return }
state.swabbleTriggerWords[index] = newValue
}
)
}
private func toggleTest() {
if isTesting {
tester.stop()
isTesting = false
testState = .idle
return
}
let triggers = sanitizedTriggers()
isTesting = true
testState = .requesting
Task { @MainActor in
do {
try await tester.start(
triggers: triggers,
onUpdate: { newState in
self.testState = newState
if case .detected = newState { self.isTesting = false }
if case .failed = newState { self.isTesting = false }
}
)
// timeout after 10s
try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
if isTesting {
tester.stop()
testState = .failed("Timeout: no trigger heard")
isTesting = false
}
} catch {
tester.stop()
testState = .failed(error.localizedDescription)
isTesting = false
}
}
}
private func sanitizedTriggers() -> [String] {
let cleaned = state.swabbleTriggerWords
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
}
}
struct PermissionsSettings: View { struct PermissionsSettings: View {
let status: [Capability: Bool] let status: [Capability: Bool]
let refresh: () async -> Void let refresh: () async -> Void
@@ -1228,49 +2016,48 @@ struct OnboardingView: View {
@State private var copied = false @State private var copied = false
@ObservedObject private var state = AppStateStore.shared @ObservedObject private var state = AppStateStore.shared
private let pageWidth: CGFloat = 640
private let contentHeight: CGFloat = 260
private var pageCount: Int { 6 } private var pageCount: Int { 6 }
private var buttonTitle: String { currentPage == pageCount - 1 ? "Finish" : "Next" } private var buttonTitle: String { currentPage == pageCount - 1 ? "Finish" : "Next" }
private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac" private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
var body: some View { var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
let contentHeight = max(proxy.size.height - 300, 240) // leave room for header + nav
VStack(spacing: 0) { VStack(spacing: 0) {
GlowingClawdisIcon(size: 148) GlowingClawdisIcon(size: 156)
.padding(.top, 22) .padding(.top, 40)
.padding(.bottom, 12) .padding(.bottom, 20)
.frame(maxWidth: .infinity, minHeight: 200, maxHeight: 220) .frame(height: 240)
GeometryReader { _ in
HStack(spacing: 0) { HStack(spacing: 0) {
welcomePage(width: width) welcomePage().frame(width: pageWidth)
focusPage(width: width) focusPage().frame(width: pageWidth)
permissionsPage(width: width) permissionsPage().frame(width: pageWidth)
cliPage(width: width) cliPage().frame(width: pageWidth)
launchPage(width: width) launchPage().frame(width: pageWidth)
readyPage(width: width) readyPage().frame(width: pageWidth)
} }
.frame(width: width, height: contentHeight, alignment: .top) .offset(x: CGFloat(-currentPage) * pageWidth)
.offset(x: CGFloat(-currentPage) * width)
.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(height: contentHeight, alignment: .top)
.clipped() .clipped()
}
.frame(height: 260)
navigationBar(pageWidth: width) navigationBar
} }
.frame(width: width, height: proxy.size.height, alignment: .top) .frame(width: pageWidth, height: 560)
.background(Color(NSColor.windowBackgroundColor)) .background(Color(NSColor.windowBackgroundColor))
}
.frame(width: 640, height: 560)
.onAppear { currentPage = 0 } .onAppear { currentPage = 0 }
.task { await refreshPerms() } .task { await refreshPerms() }
} }
private func welcomePage(width: CGFloat) -> some View { private func welcomePage() -> some View {
onboardingPage(width: width) { onboardingPage {
Text("Welcome to Clawdis") Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Your macOS menu bar companion for notifications, screenshots, and privileged agent actions.") Text("Your macOS menu bar companion for notifications, screenshots, and privileged agent actions.")
@@ -1288,8 +2075,8 @@ struct OnboardingView: View {
} }
} }
private func focusPage(width: CGFloat) -> some View { private func focusPage() -> some View {
onboardingPage(width: width) { onboardingPage {
Text("What Clawdis handles") Text("What Clawdis handles")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
onboardingCard { onboardingCard {
@@ -1312,8 +2099,8 @@ struct OnboardingView: View {
} }
} }
private func permissionsPage(width: CGFloat) -> some View { private func permissionsPage() -> some View {
onboardingPage(width: width) { onboardingPage {
Text("Grant permissions") Text("Grant permissions")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Approve these once and the helper CLI reuses the same grants.") Text("Approve these once and the helper CLI reuses the same grants.")
@@ -1343,8 +2130,8 @@ struct OnboardingView: View {
} }
} }
private func cliPage(width: CGFloat) -> some View { private func cliPage() -> some View {
onboardingPage(width: width) { onboardingPage {
Text("Install the helper CLI") Text("Install the helper CLI")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Link `clawdis-mac` so scripts and the agent can talk to this app.") Text("Link `clawdis-mac` so scripts and the agent can talk to this app.")
@@ -1387,8 +2174,8 @@ struct OnboardingView: View {
} }
} }
private func launchPage(width: CGFloat) -> some View { private func launchPage() -> some View {
onboardingPage(width: width) { onboardingPage {
Text("Keep it running") Text("Keep it running")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Let Clawdis launch with macOS so permissions and notifications are ready when automations start.") Text("Let Clawdis launch with macOS so permissions and notifications are ready when automations start.")
@@ -1411,8 +2198,8 @@ struct OnboardingView: View {
} }
} }
private func readyPage(width: CGFloat) -> some View { private func readyPage() -> some View {
onboardingPage(width: width) { onboardingPage {
Text("All set") Text("All set")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
onboardingCard { onboardingCard {
@@ -1435,7 +2222,7 @@ struct OnboardingView: View {
} }
} }
private func navigationBar(pageWidth: CGFloat) -> some View { private var navigationBar: some View {
HStack(spacing: 20) { HStack(spacing: 20) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Button(action: {}, label: { Button(action: {}, label: {
@@ -1486,13 +2273,12 @@ struct OnboardingView: View {
.frame(height: 60) .frame(height: 60)
} }
private func onboardingPage(width: CGFloat, @ViewBuilder _ content: () -> some View) -> some View { private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
VStack(spacing: 22) { VStack(spacing: 22) {
content() content()
Spacer() Spacer()
} }
.frame(width: width, alignment: .top) .frame(width: pageWidth, alignment: .top)
.padding(.horizontal, 26)
} }
private func onboardingCard(@ViewBuilder _ content: () -> some View) -> some View { private func onboardingCard(@ViewBuilder _ content: () -> some View) -> some View {