From 12d6e1cdddee838af9869fb264f4f09aa651d591 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 21:52:42 +0100 Subject: [PATCH] feat(macos): choose skill install target --- .../Sources/Clawdis/SettingsRootView.swift | 2 +- .../Sources/Clawdis/SkillsSettings.swift | 80 +++++++++++++++++-- .../SettingsViewSmokeTests.swift | 2 +- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 434f704e9..185ed30ad 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -41,7 +41,7 @@ struct SettingsRootView: View { .tabItem { Label("Cron", systemImage: "calendar") } .tag(SettingsTab.cron) - SkillsSettings() + SkillsSettings(state: self.state) .tabItem { Label("Skills", systemImage: "sparkles") } .tag(SettingsTab.skills) diff --git a/apps/macos/Sources/Clawdis/SkillsSettings.swift b/apps/macos/Sources/Clawdis/SkillsSettings.swift index 4a9951536..b02f48383 100644 --- a/apps/macos/Sources/Clawdis/SkillsSettings.swift +++ b/apps/macos/Sources/Clawdis/SkillsSettings.swift @@ -3,11 +3,16 @@ import Observation import SwiftUI struct SkillsSettings: View { + @Bindable var state: AppState @State private var model = SkillsSettingsModel() @State private var envEditor: EnvEditorState? @State private var searchQuery = "" @State private var filter: SkillsFilter = .all + init(state: AppState = AppStateStore.shared) { + self.state = state + } + var body: some View { VStack(alignment: .leading, spacing: 12) { self.header @@ -78,11 +83,13 @@ struct SkillsSettings: View { SkillRow( skill: skill, isBusy: self.model.isBusy(skill: skill), + canInstallLocally: self.state.connectionMode == .local, + defaultInstallTarget: self.defaultInstallTarget(for: skill), onToggleEnabled: { enabled in Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) } }, - onInstall: { option in - Task { await self.model.install(skill: skill, option: option) } + onInstall: { option, target in + Task { await self.model.install(skill: skill, option: option, target: target) } }, onSetEnv: { envKey, isPrimary in self.envEditor = EnvEditorState( @@ -136,6 +143,11 @@ struct SkillsSettings: View { } } } + + private func defaultInstallTarget(for skill: SkillStatus) -> InstallTarget { + let localPreferred = ["imsg", "peekaboo", "spotify-player"] + return localPreferred.contains(skill.skillKey) ? .local : .gateway + } } private enum SkillsFilter: String, CaseIterable, Identifiable { @@ -160,17 +172,45 @@ private enum SkillsFilter: String, CaseIterable, Identifiable { } } +private enum InstallTarget: String, CaseIterable { + case gateway + case local +} + private struct SkillRow: View { let skill: SkillStatus let isBusy: Bool + let canInstallLocally: Bool let onToggleEnabled: (Bool) -> Void - let onInstall: (SkillInstallOption) -> Void + let onInstall: (SkillInstallOption, InstallTarget) -> Void let onSetEnv: (String, Bool) -> Void + @State private var installTarget: InstallTarget private var missingBins: [String] { self.skill.missing.bins } private var missingEnv: [String] { self.skill.missing.env } private var missingConfig: [String] { self.skill.missing.config } + init( + skill: SkillStatus, + isBusy: Bool, + canInstallLocally: Bool, + defaultInstallTarget: InstallTarget, + onToggleEnabled: @escaping (Bool) -> Void, + onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void, + onSetEnv: @escaping (String, Bool) -> Void) + { + self.skill = skill + self.isBusy = isBusy + self.canInstallLocally = canInstallLocally + self.onToggleEnabled = onToggleEnabled + self.onInstall = onInstall + self.onSetEnv = onSetEnv + let initialTarget: InstallTarget = (defaultInstallTarget == .local && !canInstallLocally) + ? .gateway + : defaultInstallTarget + self._installTarget = State(initialValue: initialTarget) + } + var body: some View { HStack(alignment: .top, spacing: 12) { Text(self.skill.emoji ?? "✨") @@ -308,10 +348,28 @@ private struct SkillRow: View { private var trailingActions: some View { VStack(alignment: .trailing, spacing: 8) { if !self.installOptions.isEmpty { + HStack(spacing: 6) { + Text("Install on") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Install on", selection: self.$installTarget) { + Text("Gateway") + .tag(InstallTarget.gateway) + Text("This Mac") + .tag(InstallTarget.local) + .disabled(!self.canInstallLocally) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 160) + .controlSize(.small) + .help(self.canInstallLocally ? "" : "Local install requires a local gateway connection.") + } ForEach(self.installOptions) { option in - Button("Install") { self.onInstall(option) } + Button("Install") { self.onInstall(option, self.installTarget) } .buttonStyle(.borderedProminent) - .disabled(self.isBusy) + .disabled(self.isBusy || self.installBlocked) + .help(self.installBlocked ? "Local install requires a local gateway connection." : "") } } else { Toggle("", isOn: self.enabledBinding) @@ -350,6 +408,10 @@ private struct SkillRow: View { !self.missingConfig.isEmpty } + private var installBlocked: Bool { + self.installTarget == .local && !self.canInstallLocally + } + private func formatConfigValue(_ value: AnyCodable?) -> String { guard let value else { return "" } switch value.value { @@ -455,9 +517,13 @@ final class SkillsSettingsModel { self.isLoading = false } - func install(skill: SkillStatus, option: SkillInstallOption) async { + func install(skill: SkillStatus, option: SkillInstallOption, target: InstallTarget) async { await self.withBusy(skill.skillKey) { do { + if target == .local, AppStateStore.shared.connectionMode != .local { + self.statusMessage = "Local install requires a local gateway connection" + return + } let result = try await GatewayConnection.shared.skillsInstall( name: skill.name, installId: option.id, @@ -515,7 +581,7 @@ final class SkillsSettingsModel { #if DEBUG struct SkillsSettings_Previews: PreviewProvider { static var previews: some View { - SkillsSettings() + SkillsSettings(state: .preview) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } } diff --git a/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift index 2947d4fcf..acaa6c666 100644 --- a/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift @@ -145,7 +145,7 @@ struct SettingsViewSmokeTests { } @Test func skillsSettingsBuildsBody() { - let view = SkillsSettings() + let view = SkillsSettings(state: .preview) _ = view.body } }