mac: add sessions tab to settings

main
Peter Steinberger 2025-12-06 02:06:00 +01:00
parent 67fe5ed699
commit cb35e3a766
1 changed files with 825 additions and 39 deletions

View File

@ -7,6 +7,8 @@ import class Foundation.Bundle
import OSLog
import CoreGraphics
@preconcurrency import ScreenCaptureKit
import AVFoundation
import Speech
import VideoToolbox
import ServiceManagement
import SwiftUI
@ -18,6 +20,9 @@ private let launchdLabel = "com.steipete.clawdis"
private let onboardingVersionKey = "clawdis.onboardingVersion"
private let currentOnboardingVersion = 2
private let pauseDefaultsKey = "clawdis.pauseEnabled"
private let swabbleEnabledKey = "clawdis.swabbleEnabled"
private let swabbleTriggersKey = "clawdis.swabbleTriggers"
private let defaultVoiceWakeTriggers = ["clawd", "claude"]
// MARK: - App model
@ -38,6 +43,19 @@ final class AppState: ObservableObject {
@Published var debugPaneEnabled: Bool {
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() {
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
@ -45,6 +63,8 @@ final class AppState: ObservableObject {
self.launchAtLogin = SMAppService.mainApp.status == .enabled
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
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 {
Toggle(isOn: activeBinding) { Text("Clawdis Active") }
Toggle(isOn: $state.swabbleEnabled) { Text("Voice Wake") }
Button("Settings…") { open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command])
Button("About Clawdis") { open(tab: .about) }
@ -662,6 +683,450 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
// 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 {
@ObservedObject var state: AppState
@State private var permStatus: [Capability: Bool] = [:]
@ -674,6 +1139,14 @@ struct SettingsRootView: View {
.tabItem { Label("General", systemImage: "gearshape") }
.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() })
.tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions)
@ -727,12 +1200,14 @@ struct SettingsRootView: View {
}
enum SettingsTab: CaseIterable {
case general, permissions, debug, about
case general, sessions, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 520
static let windowHeight: CGFloat = 520
var title: String {
switch self {
case .general: return "General"
case .sessions: return "Sessions"
case .voiceWake: return "Voice Wake"
case .permissions: return "Permissions"
case .debug: return "Debug"
case .about: return "About"
@ -758,6 +1233,110 @@ extension Notification.Name {
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
struct SettingsToggleRow: View {
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 {
let status: [Capability: Bool]
let refresh: () async -> Void
@ -1228,49 +2016,48 @@ struct OnboardingView: View {
@State private var copied = false
@ObservedObject private var state = AppStateStore.shared
private let pageWidth: CGFloat = 640
private let contentHeight: CGFloat = 260
private var pageCount: Int { 6 }
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"
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) {
GlowingClawdisIcon(size: 148)
.padding(.top, 22)
.padding(.bottom, 12)
.frame(maxWidth: .infinity, minHeight: 200, maxHeight: 220)
GlowingClawdisIcon(size: 156)
.padding(.top, 40)
.padding(.bottom, 20)
.frame(height: 240)
GeometryReader { _ in
HStack(spacing: 0) {
welcomePage(width: width)
focusPage(width: width)
permissionsPage(width: width)
cliPage(width: width)
launchPage(width: width)
readyPage(width: width)
welcomePage().frame(width: pageWidth)
focusPage().frame(width: pageWidth)
permissionsPage().frame(width: pageWidth)
cliPage().frame(width: pageWidth)
launchPage().frame(width: pageWidth)
readyPage().frame(width: pageWidth)
}
.frame(width: width, height: contentHeight, alignment: .top)
.offset(x: CGFloat(-currentPage) * width)
.offset(x: CGFloat(-currentPage) * pageWidth)
.animation(
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
value: currentPage
)
.frame(height: contentHeight, alignment: .top)
.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))
}
.frame(width: 640, height: 560)
.onAppear { currentPage = 0 }
.task { await refreshPerms() }
}
private func welcomePage(width: CGFloat) -> some View {
onboardingPage(width: width) {
private func welcomePage() -> some View {
onboardingPage {
Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold))
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 {
onboardingPage(width: width) {
private func focusPage() -> some View {
onboardingPage {
Text("What Clawdis handles")
.font(.largeTitle.weight(.semibold))
onboardingCard {
@ -1312,8 +2099,8 @@ struct OnboardingView: View {
}
}
private func permissionsPage(width: CGFloat) -> some View {
onboardingPage(width: width) {
private func permissionsPage() -> some View {
onboardingPage {
Text("Grant permissions")
.font(.largeTitle.weight(.semibold))
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 {
onboardingPage(width: width) {
private func cliPage() -> some View {
onboardingPage {
Text("Install the helper CLI")
.font(.largeTitle.weight(.semibold))
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 {
onboardingPage(width: width) {
private func launchPage() -> some View {
onboardingPage {
Text("Keep it running")
.font(.largeTitle.weight(.semibold))
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 {
onboardingPage(width: width) {
private func readyPage() -> some View {
onboardingPage {
Text("All set")
.font(.largeTitle.weight(.semibold))
onboardingCard {
@ -1435,7 +2222,7 @@ struct OnboardingView: View {
}
}
private func navigationBar(pageWidth: CGFloat) -> some View {
private var navigationBar: some View {
HStack(spacing: 20) {
ZStack(alignment: .leading) {
Button(action: {}, label: {
@ -1486,13 +2273,12 @@ struct OnboardingView: View {
.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) {
content()
Spacer()
}
.frame(width: width, alignment: .top)
.padding(.horizontal, 26)
.frame(width: pageWidth, alignment: .top)
}
private func onboardingCard(@ViewBuilder _ content: () -> some View) -> some View {